Rebrand from Casera/MyCrib to honeyDue
Total rebrand across KMM project: - Kotlin package: com.example.casera -> com.tt.honeyDue (dirs + declarations) - Gradle: rootProject.name, namespace, applicationId - Android: manifest, strings.xml (all languages), widget resources - iOS: pbxproj bundle IDs, Info.plist, entitlements, xcconfig - iOS directories: Casera/ -> HoneyDue/, CaseraTests/ -> HoneyDueTests/, etc. - Swift source: all class/struct/enum renames - Deep links: casera:// -> honeydue://, .casera -> .honeydue - App icons replaced with honeyDue honeycomb icon - Domains: casera.treytartt.com -> honeyDue.treytartt.com - Bundle IDs: com.tt.casera -> com.tt.honeyDue - Database table names preserved Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,326 @@
|
||||
package com.tt.honeyDue
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import coil3.ImageLoader
|
||||
import coil3.PlatformContext
|
||||
import coil3.SingletonImageLoader
|
||||
import coil3.network.ktor3.KtorNetworkFetcherFactory
|
||||
import coil3.disk.DiskCache
|
||||
import coil3.memory.MemoryCache
|
||||
import coil3.request.crossfade
|
||||
import coil3.util.DebugLogger
|
||||
import okio.FileSystem
|
||||
import com.tt.honeyDue.storage.TokenManager
|
||||
import com.tt.honeyDue.storage.TokenStorage
|
||||
import com.tt.honeyDue.storage.TaskCacheManager
|
||||
import com.tt.honeyDue.storage.TaskCacheStorage
|
||||
import com.tt.honeyDue.storage.ThemeStorage
|
||||
import com.tt.honeyDue.storage.ThemeStorageManager
|
||||
import com.tt.honeyDue.ui.theme.ThemeManager
|
||||
import com.tt.honeyDue.fcm.FCMManager
|
||||
import com.tt.honeyDue.platform.BillingManager
|
||||
import com.tt.honeyDue.network.APILayer
|
||||
import com.tt.honeyDue.sharing.ContractorSharingManager
|
||||
import com.tt.honeyDue.data.DataManager
|
||||
import com.tt.honeyDue.data.PersistenceManager
|
||||
import com.tt.honeyDue.models.honeyDuePackageType
|
||||
import com.tt.honeyDue.models.detecthoneyDuePackageType
|
||||
import com.tt.honeyDue.analytics.PostHogAnalytics
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class MainActivity : ComponentActivity(), SingletonImageLoader.Factory {
|
||||
private var deepLinkResetToken by mutableStateOf<String?>(null)
|
||||
private var navigateToTaskId by mutableStateOf<Int?>(null)
|
||||
private var pendingContractorImportUri by mutableStateOf<Uri?>(null)
|
||||
private var pendingResidenceImportUri by mutableStateOf<Uri?>(null)
|
||||
private lateinit var billingManager: BillingManager
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
enableEdgeToEdge()
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
// Initialize TokenStorage with Android TokenManager
|
||||
TokenStorage.initialize(TokenManager.getInstance(applicationContext))
|
||||
|
||||
// Initialize TaskCacheStorage for offline task caching
|
||||
TaskCacheStorage.initialize(TaskCacheManager.getInstance(applicationContext))
|
||||
|
||||
// Initialize ThemeStorage and ThemeManager
|
||||
ThemeStorage.initialize(ThemeStorageManager.getInstance(applicationContext))
|
||||
ThemeManager.initialize()
|
||||
|
||||
// Initialize DataManager with platform-specific managers
|
||||
// This loads cached lookup data from disk for faster startup
|
||||
DataManager.initialize(
|
||||
tokenMgr = TokenManager.getInstance(applicationContext),
|
||||
themeMgr = ThemeStorageManager.getInstance(applicationContext),
|
||||
persistenceMgr = PersistenceManager.getInstance(applicationContext)
|
||||
)
|
||||
|
||||
// Initialize BillingManager for subscription management
|
||||
billingManager = BillingManager.getInstance(applicationContext)
|
||||
|
||||
// Initialize PostHog Analytics
|
||||
PostHogAnalytics.initialize(application, debug = true) // Set debug=false for release
|
||||
|
||||
// Handle deep link, notification navigation, and file import from intent
|
||||
handleDeepLink(intent)
|
||||
handleNotificationNavigation(intent)
|
||||
handleFileImport(intent)
|
||||
|
||||
// Request notification permission and setup FCM
|
||||
setupFCM()
|
||||
|
||||
// Verify subscriptions if user is authenticated
|
||||
verifySubscriptionsOnLaunch()
|
||||
|
||||
setContent {
|
||||
App(
|
||||
deepLinkResetToken = deepLinkResetToken,
|
||||
onClearDeepLinkToken = {
|
||||
deepLinkResetToken = null
|
||||
},
|
||||
navigateToTaskId = navigateToTaskId,
|
||||
onClearNavigateToTask = {
|
||||
navigateToTaskId = null
|
||||
},
|
||||
pendingContractorImportUri = pendingContractorImportUri,
|
||||
onClearContractorImport = {
|
||||
pendingContractorImportUri = null
|
||||
},
|
||||
pendingResidenceImportUri = pendingResidenceImportUri,
|
||||
onClearResidenceImport = {
|
||||
pendingResidenceImportUri = null
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify subscriptions with Google Play and sync with backend on app launch
|
||||
*/
|
||||
private fun verifySubscriptionsOnLaunch() {
|
||||
val authToken = TokenStorage.getToken()
|
||||
if (authToken == null) {
|
||||
Log.d("MainActivity", "No auth token, skipping subscription verification")
|
||||
return
|
||||
}
|
||||
|
||||
Log.d("MainActivity", "🔄 Verifying subscriptions on launch...")
|
||||
|
||||
billingManager.startConnection(
|
||||
onSuccess = {
|
||||
Log.d("MainActivity", "✅ Billing connected, restoring purchases...")
|
||||
lifecycleScope.launch {
|
||||
val restored = billingManager.restorePurchases()
|
||||
if (restored) {
|
||||
Log.d("MainActivity", "✅ Subscriptions verified and synced with backend")
|
||||
} else {
|
||||
Log.d("MainActivity", "📦 No active subscriptions found")
|
||||
}
|
||||
}
|
||||
},
|
||||
onError = { error ->
|
||||
Log.e("MainActivity", "❌ Failed to connect to billing: $error")
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun setupFCM() {
|
||||
// Request notification permission if needed
|
||||
if (!FCMManager.isNotificationPermissionGranted(this)) {
|
||||
FCMManager.requestNotificationPermission(this)
|
||||
}
|
||||
|
||||
// Get FCM token and register with backend
|
||||
lifecycleScope.launch {
|
||||
val fcmToken = FCMManager.getFCMToken()
|
||||
if (fcmToken != null) {
|
||||
Log.d("MainActivity", "FCM Token: $fcmToken")
|
||||
registerDeviceWithBackend(fcmToken)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun registerDeviceWithBackend(fcmToken: String) {
|
||||
try {
|
||||
val authToken = TokenStorage.getToken()
|
||||
if (authToken != null) {
|
||||
val notificationApi = com.tt.honeyDue.network.NotificationApi()
|
||||
val deviceId = android.provider.Settings.Secure.getString(
|
||||
contentResolver,
|
||||
android.provider.Settings.Secure.ANDROID_ID
|
||||
)
|
||||
val request = com.tt.honeyDue.models.DeviceRegistrationRequest(
|
||||
deviceId = deviceId,
|
||||
registrationId = fcmToken,
|
||||
platform = "android",
|
||||
name = android.os.Build.MODEL
|
||||
)
|
||||
|
||||
when (val result = notificationApi.registerDevice(authToken, request)) {
|
||||
is com.tt.honeyDue.network.ApiResult.Success -> {
|
||||
Log.d("MainActivity", "Device registered successfully: ${result.data}")
|
||||
}
|
||||
is com.tt.honeyDue.network.ApiResult.Error -> {
|
||||
Log.e("MainActivity", "Failed to register device: ${result.message}")
|
||||
}
|
||||
is com.tt.honeyDue.network.ApiResult.Loading,
|
||||
is com.tt.honeyDue.network.ApiResult.Idle -> {
|
||||
// These states shouldn't occur for direct API calls
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Log.d("MainActivity", "No auth token available, will register device after login")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("MainActivity", "Error registering device", e)
|
||||
}
|
||||
}
|
||||
|
||||
@Deprecated("Deprecated in Java")
|
||||
override fun onRequestPermissionsResult(
|
||||
requestCode: Int,
|
||||
permissions: Array<String>,
|
||||
grantResults: IntArray
|
||||
) {
|
||||
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
|
||||
when (requestCode) {
|
||||
FCMManager.NOTIFICATION_PERMISSION_REQUEST_CODE -> {
|
||||
if (grantResults.isNotEmpty() && grantResults[0] == android.content.pm.PackageManager.PERMISSION_GRANTED) {
|
||||
Log.d("MainActivity", "Notification permission granted")
|
||||
// Get FCM token now that permission is granted
|
||||
lifecycleScope.launch {
|
||||
FCMManager.getFCMToken()
|
||||
}
|
||||
} else {
|
||||
Log.d("MainActivity", "Notification permission denied")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
// Check if lookups have changed on server (efficient ETag-based check)
|
||||
// This ensures app has fresh data when coming back from background
|
||||
lifecycleScope.launch {
|
||||
Log.d("MainActivity", "🔄 App resumed, checking for lookup updates...")
|
||||
APILayer.refreshLookupsIfChanged()
|
||||
|
||||
// Check if widget completed a task - refresh data if dirty
|
||||
if (TaskCacheStorage.areTasksDirty()) {
|
||||
Log.d("MainActivity", "🔄 Tasks marked dirty by widget, refreshing...")
|
||||
TaskCacheStorage.clearDirtyFlag()
|
||||
// Force refresh tasks from API
|
||||
APILayer.getTasks(forceRefresh = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
handleDeepLink(intent)
|
||||
handleNotificationNavigation(intent)
|
||||
handleFileImport(intent)
|
||||
}
|
||||
|
||||
private fun handleNotificationNavigation(intent: Intent?) {
|
||||
val taskId = intent?.getIntExtra(NotificationActionReceiver.EXTRA_NAVIGATE_TO_TASK, -1)
|
||||
if (taskId != null && taskId != -1) {
|
||||
Log.d("MainActivity", "Navigating to task from notification: $taskId")
|
||||
navigateToTaskId = taskId
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleDeepLink(intent: Intent?) {
|
||||
val data: Uri? = intent?.data
|
||||
val isResetLink = data != null &&
|
||||
data.scheme == "honeydue" &&
|
||||
data.host == "reset-password"
|
||||
if (isResetLink) {
|
||||
// Extract token from query parameter
|
||||
val token = data.getQueryParameter("token")
|
||||
if (token != null) {
|
||||
deepLinkResetToken = token
|
||||
println("Deep link received with token: $token")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleFileImport(intent: Intent?) {
|
||||
if (intent?.action == Intent.ACTION_VIEW) {
|
||||
val uri = intent.data
|
||||
if (uri != null && ContractorSharingManager.ishoneyDueFile(applicationContext, uri)) {
|
||||
Log.d("MainActivity", "honeyDue file received: $uri")
|
||||
|
||||
// Read file content to detect package type
|
||||
try {
|
||||
val inputStream = contentResolver.openInputStream(uri)
|
||||
if (inputStream != null) {
|
||||
val jsonString = inputStream.bufferedReader().use { it.readText() }
|
||||
inputStream.close()
|
||||
|
||||
val packageType = detecthoneyDuePackageType(jsonString)
|
||||
Log.d("MainActivity", "Detected package type: $packageType")
|
||||
|
||||
when (packageType) {
|
||||
honeyDuePackageType.RESIDENCE -> {
|
||||
Log.d("MainActivity", "Routing to residence import")
|
||||
pendingResidenceImportUri = uri
|
||||
}
|
||||
else -> {
|
||||
// Default to contractor for backward compatibility
|
||||
Log.d("MainActivity", "Routing to contractor import")
|
||||
pendingContractorImportUri = uri
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("MainActivity", "Failed to detect package type, defaulting to contractor", e)
|
||||
// Default to contractor on error for backward compatibility
|
||||
pendingContractorImportUri = uri
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun newImageLoader(context: PlatformContext): ImageLoader {
|
||||
return ImageLoader.Builder(context)
|
||||
.components {
|
||||
add(KtorNetworkFetcherFactory())
|
||||
}
|
||||
.memoryCache {
|
||||
MemoryCache.Builder()
|
||||
.maxSizePercent(context, 0.25)
|
||||
.build()
|
||||
}
|
||||
.diskCache {
|
||||
DiskCache.Builder()
|
||||
.directory(FileSystem.SYSTEM_TEMPORARY_DIRECTORY / "image_cache")
|
||||
.maxSizeBytes(512L * 1024 * 1024) // 512MB
|
||||
.build()
|
||||
}
|
||||
.crossfade(true)
|
||||
.logger(DebugLogger())
|
||||
.build()
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun AppAndroidPreview() {
|
||||
App(deepLinkResetToken = null)
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
package com.tt.honeyDue
|
||||
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import com.tt.honeyDue.data.DataManager
|
||||
import com.google.firebase.messaging.FirebaseMessagingService
|
||||
import com.google.firebase.messaging.RemoteMessage
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class MyFirebaseMessagingService : FirebaseMessagingService() {
|
||||
|
||||
override fun onNewToken(token: String) {
|
||||
super.onNewToken(token)
|
||||
Log.d(TAG, "New FCM token: $token")
|
||||
|
||||
// Store token locally for registration with backend
|
||||
getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.putString(KEY_FCM_TOKEN, token)
|
||||
.apply()
|
||||
|
||||
// Send token to backend API if user is logged in
|
||||
// Note: In a real app, you might want to use WorkManager for reliable delivery
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
try {
|
||||
val authToken = com.tt.honeyDue.storage.TokenStorage.getToken()
|
||||
if (authToken != null) {
|
||||
val notificationApi = com.tt.honeyDue.network.NotificationApi()
|
||||
val deviceId = android.provider.Settings.Secure.getString(
|
||||
applicationContext.contentResolver,
|
||||
android.provider.Settings.Secure.ANDROID_ID
|
||||
)
|
||||
val request = com.tt.honeyDue.models.DeviceRegistrationRequest(
|
||||
deviceId = deviceId,
|
||||
registrationId = token,
|
||||
platform = "android",
|
||||
name = android.os.Build.MODEL
|
||||
)
|
||||
|
||||
when (val result = notificationApi.registerDevice(authToken, request)) {
|
||||
is com.tt.honeyDue.network.ApiResult.Success -> {
|
||||
Log.d(TAG, "Device registered successfully with new token")
|
||||
}
|
||||
is com.tt.honeyDue.network.ApiResult.Error -> {
|
||||
Log.e(TAG, "Failed to register device with new token: ${result.message}")
|
||||
}
|
||||
is com.tt.honeyDue.network.ApiResult.Loading,
|
||||
is com.tt.honeyDue.network.ApiResult.Idle -> {
|
||||
// These states shouldn't occur for direct API calls
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error registering device with new token", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onMessageReceived(message: RemoteMessage) {
|
||||
super.onMessageReceived(message)
|
||||
|
||||
Log.d(TAG, "Message received from: ${message.from}")
|
||||
|
||||
// Check if message contains notification payload
|
||||
message.notification?.let { notification ->
|
||||
Log.d(TAG, "Notification: ${notification.title} - ${notification.body}")
|
||||
sendNotification(
|
||||
notification.title ?: "honeyDue",
|
||||
notification.body ?: "",
|
||||
message.data
|
||||
)
|
||||
}
|
||||
|
||||
// Check if message contains data payload
|
||||
if (message.data.isNotEmpty()) {
|
||||
Log.d(TAG, "Message data: ${message.data}")
|
||||
|
||||
// If there's no notification payload, create one from data
|
||||
if (message.notification == null) {
|
||||
val title = message.data["title"] ?: "honeyDue"
|
||||
val body = message.data["body"] ?: ""
|
||||
sendNotification(title, body, message.data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun sendNotification(title: String, body: String, data: Map<String, String>) {
|
||||
val taskIdStr = data["task_id"]
|
||||
val taskId = taskIdStr?.toIntOrNull()
|
||||
val buttonTypesStr = data["button_types"]
|
||||
val notificationId = taskId ?: NOTIFICATION_ID
|
||||
|
||||
// Create main tap intent
|
||||
val mainIntent = Intent(this, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
|
||||
data.forEach { (key, value) ->
|
||||
putExtra(key, value)
|
||||
}
|
||||
// Add navigation extra if task notification
|
||||
if (taskId != null) {
|
||||
putExtra(NotificationActionReceiver.EXTRA_NAVIGATE_TO_TASK, taskId)
|
||||
}
|
||||
}
|
||||
|
||||
val pendingIntentFlags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
} else {
|
||||
PendingIntent.FLAG_UPDATE_CURRENT
|
||||
}
|
||||
|
||||
val mainPendingIntent = PendingIntent.getActivity(
|
||||
this,
|
||||
notificationId,
|
||||
mainIntent,
|
||||
pendingIntentFlags
|
||||
)
|
||||
|
||||
val channelId = getString(R.string.default_notification_channel_id)
|
||||
val notificationBuilder = NotificationCompat.Builder(this, channelId)
|
||||
.setSmallIcon(R.mipmap.ic_launcher)
|
||||
.setContentTitle(title)
|
||||
.setContentText(body)
|
||||
.setAutoCancel(true)
|
||||
.setContentIntent(mainPendingIntent)
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
|
||||
// Add action buttons for premium users with task notifications
|
||||
if (taskId != null && isPremiumUser()) {
|
||||
addActionButtons(notificationBuilder, taskId, notificationId, buttonTypesStr)
|
||||
}
|
||||
|
||||
val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
|
||||
// Create notification channel for Android O and above
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val channel = NotificationChannel(
|
||||
channelId,
|
||||
"honeyDue Notifications",
|
||||
NotificationManager.IMPORTANCE_HIGH
|
||||
).apply {
|
||||
description = "Notifications for tasks, residences, and warranties"
|
||||
}
|
||||
notificationManager.createNotificationChannel(channel)
|
||||
}
|
||||
|
||||
notificationManager.notify(notificationId, notificationBuilder.build())
|
||||
}
|
||||
|
||||
private fun isPremiumUser(): Boolean {
|
||||
val subscription = DataManager.subscription.value
|
||||
// User is premium if limitations are disabled
|
||||
return subscription?.limitationsEnabled == false
|
||||
}
|
||||
|
||||
private fun addActionButtons(
|
||||
builder: NotificationCompat.Builder,
|
||||
taskId: Int,
|
||||
notificationId: Int,
|
||||
buttonTypesStr: String?
|
||||
) {
|
||||
val buttonTypes = buttonTypesStr?.split(",")?.map { it.trim() } ?: emptyList()
|
||||
|
||||
val pendingIntentFlags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
} else {
|
||||
PendingIntent.FLAG_UPDATE_CURRENT
|
||||
}
|
||||
|
||||
// Add up to 3 action buttons (Android notification limit)
|
||||
var actionCount = 0
|
||||
val maxActions = 3
|
||||
|
||||
for (buttonType in buttonTypes) {
|
||||
if (actionCount >= maxActions) break
|
||||
|
||||
val (action, label) = when (buttonType) {
|
||||
"complete" -> NotificationActionReceiver.ACTION_COMPLETE_TASK to "Complete"
|
||||
"mark_in_progress" -> NotificationActionReceiver.ACTION_MARK_IN_PROGRESS to "Start"
|
||||
"cancel" -> NotificationActionReceiver.ACTION_CANCEL_TASK to "Cancel"
|
||||
"uncancel" -> NotificationActionReceiver.ACTION_UNCANCEL_TASK to "Restore"
|
||||
else -> continue
|
||||
}
|
||||
|
||||
val intent = Intent(this, NotificationActionReceiver::class.java).apply {
|
||||
this.action = action
|
||||
putExtra(NotificationActionReceiver.EXTRA_TASK_ID, taskId)
|
||||
putExtra(NotificationActionReceiver.EXTRA_NOTIFICATION_ID, notificationId)
|
||||
}
|
||||
|
||||
val pendingIntent = PendingIntent.getBroadcast(
|
||||
this,
|
||||
notificationId * 10 + actionCount, // Unique request code per action
|
||||
intent,
|
||||
pendingIntentFlags
|
||||
)
|
||||
|
||||
builder.addAction(0, label, pendingIntent)
|
||||
actionCount++
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "FCMService"
|
||||
private const val NOTIFICATION_ID = 0
|
||||
private const val PREFS_NAME = "honeydue_prefs"
|
||||
private const val KEY_FCM_TOKEN = "fcm_token"
|
||||
|
||||
fun getStoredToken(context: Context): String? {
|
||||
return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
.getString(KEY_FCM_TOKEN, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
package com.tt.honeyDue
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import com.tt.honeyDue.data.DataManager
|
||||
import com.tt.honeyDue.models.TaskCompletionCreateRequest
|
||||
import com.tt.honeyDue.network.APILayer
|
||||
import com.tt.honeyDue.network.ApiResult
|
||||
import com.tt.honeyDue.storage.TokenStorage
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* BroadcastReceiver for handling notification action button clicks.
|
||||
* Performs task actions (complete, cancel, etc.) directly from notifications.
|
||||
*/
|
||||
class NotificationActionReceiver : BroadcastReceiver() {
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
val action = intent.action ?: return
|
||||
val taskId = intent.getIntExtra(EXTRA_TASK_ID, -1)
|
||||
val notificationId = intent.getIntExtra(EXTRA_NOTIFICATION_ID, 0)
|
||||
|
||||
Log.d(TAG, "Action received: $action for task $taskId")
|
||||
|
||||
if (taskId == -1) {
|
||||
Log.e(TAG, "No task ID provided")
|
||||
return
|
||||
}
|
||||
|
||||
// Dismiss the notification
|
||||
NotificationManagerCompat.from(context).cancel(notificationId)
|
||||
|
||||
// Check subscription status
|
||||
val isPremium = isPremiumUser()
|
||||
if (!isPremium) {
|
||||
Log.d(TAG, "Non-premium user, ignoring action")
|
||||
launchMainActivity(context, null)
|
||||
return
|
||||
}
|
||||
|
||||
// Handle action
|
||||
when (action) {
|
||||
ACTION_VIEW_TASK -> {
|
||||
launchMainActivity(context, taskId)
|
||||
}
|
||||
ACTION_COMPLETE_TASK -> {
|
||||
performCompleteTask(context, taskId)
|
||||
}
|
||||
ACTION_MARK_IN_PROGRESS -> {
|
||||
performMarkInProgress(context, taskId)
|
||||
}
|
||||
ACTION_CANCEL_TASK -> {
|
||||
performCancelTask(context, taskId)
|
||||
}
|
||||
ACTION_UNCANCEL_TASK -> {
|
||||
performUncancelTask(context, taskId)
|
||||
}
|
||||
else -> {
|
||||
Log.w(TAG, "Unknown action: $action")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun isPremiumUser(): Boolean {
|
||||
val subscription = DataManager.subscription.value
|
||||
// User is premium if limitations are disabled
|
||||
return subscription?.limitationsEnabled == false
|
||||
}
|
||||
|
||||
private fun launchMainActivity(context: Context, taskId: Int?) {
|
||||
val intent = Intent(context, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
if (taskId != null) {
|
||||
putExtra(EXTRA_NAVIGATE_TO_TASK, taskId)
|
||||
}
|
||||
}
|
||||
context.startActivity(intent)
|
||||
}
|
||||
|
||||
private fun performCompleteTask(context: Context, taskId: Int) {
|
||||
Log.d(TAG, "Completing task $taskId")
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
val request = TaskCompletionCreateRequest(
|
||||
taskId = taskId,
|
||||
completedAt = null,
|
||||
notes = null,
|
||||
actualCost = null,
|
||||
rating = null,
|
||||
imageUrls = null
|
||||
)
|
||||
|
||||
when (val result = APILayer.createTaskCompletion(request)) {
|
||||
is ApiResult.Success -> {
|
||||
Log.d(TAG, "Task $taskId completed successfully")
|
||||
// Launch app to show result
|
||||
launchMainActivity(context, taskId)
|
||||
}
|
||||
is ApiResult.Error -> {
|
||||
Log.e(TAG, "Failed to complete task: ${result.message}")
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun performMarkInProgress(context: Context, taskId: Int) {
|
||||
Log.d(TAG, "Marking task $taskId as in progress")
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
when (val result = APILayer.markInProgress(taskId)) {
|
||||
is ApiResult.Success -> {
|
||||
Log.d(TAG, "Task $taskId marked as in progress")
|
||||
}
|
||||
is ApiResult.Error -> {
|
||||
Log.e(TAG, "Failed to mark task in progress: ${result.message}")
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun performCancelTask(context: Context, taskId: Int) {
|
||||
Log.d(TAG, "Cancelling task $taskId")
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
when (val result = APILayer.cancelTask(taskId)) {
|
||||
is ApiResult.Success -> {
|
||||
Log.d(TAG, "Task $taskId cancelled")
|
||||
}
|
||||
is ApiResult.Error -> {
|
||||
Log.e(TAG, "Failed to cancel task: ${result.message}")
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun performUncancelTask(context: Context, taskId: Int) {
|
||||
Log.d(TAG, "Uncancelling task $taskId")
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
when (val result = APILayer.uncancelTask(taskId)) {
|
||||
is ApiResult.Success -> {
|
||||
Log.d(TAG, "Task $taskId uncancelled")
|
||||
}
|
||||
is ApiResult.Error -> {
|
||||
Log.e(TAG, "Failed to uncancel task: ${result.message}")
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "NotificationAction"
|
||||
|
||||
// Action constants
|
||||
const val ACTION_VIEW_TASK = "com.tt.honeyDue.ACTION_VIEW_TASK"
|
||||
const val ACTION_COMPLETE_TASK = "com.tt.honeyDue.ACTION_COMPLETE_TASK"
|
||||
const val ACTION_MARK_IN_PROGRESS = "com.tt.honeyDue.ACTION_MARK_IN_PROGRESS"
|
||||
const val ACTION_CANCEL_TASK = "com.tt.honeyDue.ACTION_CANCEL_TASK"
|
||||
const val ACTION_UNCANCEL_TASK = "com.tt.honeyDue.ACTION_UNCANCEL_TASK"
|
||||
|
||||
// Extra constants
|
||||
const val EXTRA_TASK_ID = "task_id"
|
||||
const val EXTRA_NOTIFICATION_ID = "notification_id"
|
||||
const val EXTRA_NAVIGATE_TO_TASK = "navigate_to_task"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.tt.honeyDue
|
||||
|
||||
import android.os.Build
|
||||
|
||||
class AndroidPlatform : Platform {
|
||||
override val name: String = "Android ${Build.VERSION.SDK_INT}"
|
||||
}
|
||||
|
||||
actual fun getPlatform(): Platform = AndroidPlatform()
|
||||
@@ -0,0 +1,74 @@
|
||||
package com.tt.honeyDue.analytics
|
||||
|
||||
import android.app.Application
|
||||
import com.posthog.PostHog
|
||||
import com.posthog.android.PostHogAndroid
|
||||
import com.posthog.android.PostHogAndroidConfig
|
||||
|
||||
/**
|
||||
* Android implementation of PostHog Analytics.
|
||||
*/
|
||||
actual object PostHogAnalytics {
|
||||
// TODO: Replace with your actual PostHog API key
|
||||
private const val API_KEY = "YOUR_POSTHOG_API_KEY"
|
||||
private const val HOST = "https://us.i.posthog.com"
|
||||
|
||||
private var isInitialized = false
|
||||
private var application: Application? = null
|
||||
|
||||
/**
|
||||
* Initialize PostHog SDK with Application context.
|
||||
* Call this in MainActivity.onCreate() before using other methods.
|
||||
*/
|
||||
fun initialize(application: Application, debug: Boolean = false) {
|
||||
if (isInitialized) return
|
||||
this.application = application
|
||||
|
||||
val config = PostHogAndroidConfig(API_KEY, HOST).apply {
|
||||
captureScreenViews = false // We'll track screens manually
|
||||
captureApplicationLifecycleEvents = true
|
||||
captureDeepLinks = true
|
||||
this.debug = debug
|
||||
|
||||
// Session Replay
|
||||
sessionReplay = true
|
||||
sessionReplayConfig.maskAllTextInputs = true
|
||||
sessionReplayConfig.maskAllImages = false
|
||||
}
|
||||
|
||||
PostHogAndroid.setup(application, config)
|
||||
isInitialized = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize from common code (no-op on Android, use initialize(Application) instead)
|
||||
*/
|
||||
actual fun initialize() {
|
||||
// No-op - Android requires Application context, use initialize(Application) instead
|
||||
}
|
||||
|
||||
actual fun identify(userId: String, properties: Map<String, Any>?) {
|
||||
if (!isInitialized) return
|
||||
PostHog.identify(userId, userProperties = properties)
|
||||
}
|
||||
|
||||
actual fun capture(event: String, properties: Map<String, Any>?) {
|
||||
if (!isInitialized) return
|
||||
PostHog.capture(event, properties = properties)
|
||||
}
|
||||
|
||||
actual fun screen(screenName: String, properties: Map<String, Any>?) {
|
||||
if (!isInitialized) return
|
||||
PostHog.screen(screenName, properties = properties)
|
||||
}
|
||||
|
||||
actual fun reset() {
|
||||
if (!isInitialized) return
|
||||
PostHog.reset()
|
||||
}
|
||||
|
||||
actual fun flush() {
|
||||
if (!isInitialized) return
|
||||
PostHog.flush()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package com.tt.honeyDue.auth
|
||||
|
||||
import android.content.Context
|
||||
import androidx.credentials.CredentialManager
|
||||
import androidx.credentials.CustomCredential
|
||||
import androidx.credentials.GetCredentialRequest
|
||||
import androidx.credentials.GetCredentialResponse
|
||||
import androidx.credentials.exceptions.GetCredentialException
|
||||
import com.tt.honeyDue.network.ApiConfig
|
||||
import com.google.android.libraries.identity.googleid.GetGoogleIdOption
|
||||
import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential
|
||||
|
||||
/**
|
||||
* Result of a Google Sign In attempt
|
||||
*/
|
||||
sealed class GoogleSignInResult {
|
||||
data class Success(val idToken: String) : GoogleSignInResult()
|
||||
data class Error(val message: String, val exception: Exception? = null) : GoogleSignInResult()
|
||||
object Cancelled : GoogleSignInResult()
|
||||
}
|
||||
|
||||
/**
|
||||
* Manager for Google Sign In using Android Credential Manager
|
||||
*/
|
||||
class GoogleSignInManager(private val context: Context) {
|
||||
|
||||
private val credentialManager = CredentialManager.create(context)
|
||||
|
||||
/**
|
||||
* Initiates Google Sign In flow and returns the ID token
|
||||
*/
|
||||
suspend fun signIn(): GoogleSignInResult {
|
||||
return try {
|
||||
val googleIdOption = GetGoogleIdOption.Builder()
|
||||
.setFilterByAuthorizedAccounts(false)
|
||||
.setServerClientId(ApiConfig.GOOGLE_WEB_CLIENT_ID)
|
||||
.setAutoSelectEnabled(true)
|
||||
.build()
|
||||
|
||||
val request = GetCredentialRequest.Builder()
|
||||
.addCredentialOption(googleIdOption)
|
||||
.build()
|
||||
|
||||
val result = credentialManager.getCredential(
|
||||
request = request,
|
||||
context = context
|
||||
)
|
||||
|
||||
handleSignInResult(result)
|
||||
} catch (e: GetCredentialException) {
|
||||
when {
|
||||
e.message?.contains("cancelled", ignoreCase = true) == true ||
|
||||
e.message?.contains("user cancelled", ignoreCase = true) == true -> {
|
||||
GoogleSignInResult.Cancelled
|
||||
}
|
||||
else -> {
|
||||
GoogleSignInResult.Error("Sign in failed: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
GoogleSignInResult.Error("Unexpected error during sign in: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleSignInResult(result: GetCredentialResponse): GoogleSignInResult {
|
||||
val credential = result.credential
|
||||
|
||||
return when (credential) {
|
||||
is CustomCredential -> {
|
||||
if (credential.type == GoogleIdTokenCredential.TYPE_GOOGLE_ID_TOKEN_CREDENTIAL) {
|
||||
try {
|
||||
val googleIdTokenCredential = GoogleIdTokenCredential.createFrom(credential.data)
|
||||
val idToken = googleIdTokenCredential.idToken
|
||||
|
||||
if (idToken.isNotEmpty()) {
|
||||
GoogleSignInResult.Success(idToken)
|
||||
} else {
|
||||
GoogleSignInResult.Error("Empty ID token received")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
GoogleSignInResult.Error("Failed to parse Google credential: ${e.message}", e)
|
||||
}
|
||||
} else {
|
||||
GoogleSignInResult.Error("Unexpected credential type: ${credential.type}")
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
GoogleSignInResult.Error("Unexpected credential type")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.tt.honeyDue.data
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
|
||||
/**
|
||||
* Android implementation of PersistenceManager using SharedPreferences.
|
||||
*/
|
||||
actual class PersistenceManager(context: Context) {
|
||||
private val prefs: SharedPreferences = context.getSharedPreferences(
|
||||
PREFS_NAME,
|
||||
Context.MODE_PRIVATE
|
||||
)
|
||||
|
||||
actual fun save(key: String, value: String) {
|
||||
prefs.edit().putString(key, value).apply()
|
||||
}
|
||||
|
||||
actual fun load(key: String): String? {
|
||||
return prefs.getString(key, null)
|
||||
}
|
||||
|
||||
actual fun remove(key: String) {
|
||||
prefs.edit().remove(key).apply()
|
||||
}
|
||||
|
||||
actual fun clear() {
|
||||
prefs.edit().clear().apply()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val PREFS_NAME = "honeydue_data_manager"
|
||||
|
||||
@Volatile
|
||||
private var instance: PersistenceManager? = null
|
||||
|
||||
fun getInstance(context: Context): PersistenceManager {
|
||||
return instance ?: synchronized(this) {
|
||||
instance ?: PersistenceManager(context.applicationContext).also { instance = it }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package com.tt.honeyDue.fcm
|
||||
|
||||
import android.Manifest
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.google.android.gms.tasks.Task
|
||||
import com.google.firebase.messaging.FirebaseMessaging
|
||||
import kotlinx.coroutines.tasks.await
|
||||
|
||||
object FCMManager {
|
||||
private const val TAG = "FCMManager"
|
||||
const val NOTIFICATION_PERMISSION_REQUEST_CODE = 1001
|
||||
|
||||
/**
|
||||
* Check if notification permission is granted (Android 13+)
|
||||
*/
|
||||
fun isNotificationPermissionGranted(context: Context): Boolean {
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
ContextCompat.checkSelfPermission(
|
||||
context,
|
||||
Manifest.permission.POST_NOTIFICATIONS
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
} else {
|
||||
// Permission automatically granted on older versions
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Request notification permission (Android 13+)
|
||||
*/
|
||||
fun requestNotificationPermission(activity: Activity) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
ActivityCompat.requestPermissions(
|
||||
activity,
|
||||
arrayOf(Manifest.permission.POST_NOTIFICATIONS),
|
||||
NOTIFICATION_PERMISSION_REQUEST_CODE
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get FCM token
|
||||
*/
|
||||
suspend fun getFCMToken(): String? {
|
||||
return try {
|
||||
val token = FirebaseMessaging.getInstance().token.await()
|
||||
Log.d(TAG, "FCM token retrieved: $token")
|
||||
token
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to get FCM token", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to a topic
|
||||
*/
|
||||
suspend fun subscribeToTopic(topic: String) {
|
||||
try {
|
||||
FirebaseMessaging.getInstance().subscribeToTopic(topic).await()
|
||||
Log.d(TAG, "Subscribed to topic: $topic")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to subscribe to topic: $topic", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsubscribe from a topic
|
||||
*/
|
||||
suspend fun unsubscribeFromTopic(topic: String) {
|
||||
try {
|
||||
FirebaseMessaging.getInstance().unsubscribeFromTopic(topic).await()
|
||||
Log.d(TAG, "Unsubscribed from topic: $topic")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to unsubscribe from topic: $topic", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.tt.honeyDue.network
|
||||
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.engine.okhttp.*
|
||||
import io.ktor.client.plugins.*
|
||||
import io.ktor.client.plugins.contentnegotiation.*
|
||||
import io.ktor.client.plugins.logging.*
|
||||
import io.ktor.serialization.kotlinx.json.*
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
|
||||
actual fun getLocalhostAddress(): String = "10.0.2.2"
|
||||
|
||||
actual fun getDeviceLanguage(): String {
|
||||
return Locale.getDefault().language
|
||||
}
|
||||
|
||||
actual fun getDeviceTimezone(): String {
|
||||
return TimeZone.getDefault().id
|
||||
}
|
||||
|
||||
actual fun createHttpClient(): HttpClient {
|
||||
return HttpClient(OkHttp) {
|
||||
install(ContentNegotiation) {
|
||||
json(Json {
|
||||
ignoreUnknownKeys = true
|
||||
isLenient = true
|
||||
prettyPrint = true
|
||||
})
|
||||
}
|
||||
|
||||
install(Logging) {
|
||||
logger = Logger.DEFAULT
|
||||
// Only log full request/response bodies in debug builds to avoid
|
||||
// leaking auth tokens and PII in production logcat.
|
||||
level = if (com.tt.honeyDue.BuildConfig.DEBUG) LogLevel.ALL else LogLevel.INFO
|
||||
}
|
||||
|
||||
install(DefaultRequest) {
|
||||
headers.append("Accept-Language", getDeviceLanguage())
|
||||
headers.append("X-Timezone", getDeviceTimezone())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,432 @@
|
||||
package com.tt.honeyDue.platform
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.android.billingclient.api.*
|
||||
import com.tt.honeyDue.network.APILayer
|
||||
import com.tt.honeyDue.network.ApiResult
|
||||
import com.tt.honeyDue.utils.SubscriptionProducts
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Google Play Billing manager for in-app purchases
|
||||
* Handles subscription purchases, verification, and restoration
|
||||
*/
|
||||
class BillingManager private constructor(private val context: Context) {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "BillingManager"
|
||||
|
||||
@Volatile
|
||||
private var INSTANCE: BillingManager? = null
|
||||
|
||||
fun getInstance(context: Context): BillingManager {
|
||||
return INSTANCE ?: synchronized(this) {
|
||||
INSTANCE ?: BillingManager(context.applicationContext).also { INSTANCE = it }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Product IDs (must match Google Play Console)
|
||||
private val productIDs = SubscriptionProducts.all
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
||||
|
||||
// StateFlows for UI observation
|
||||
private val _isLoading = MutableStateFlow(false)
|
||||
val isLoading: StateFlow<Boolean> = _isLoading
|
||||
|
||||
private val _products = MutableStateFlow<List<ProductDetails>>(emptyList())
|
||||
val products: StateFlow<List<ProductDetails>> = _products
|
||||
|
||||
private val _purchasedProductIDs = MutableStateFlow<Set<String>>(emptySet())
|
||||
val purchasedProductIDs: StateFlow<Set<String>> = _purchasedProductIDs
|
||||
|
||||
private val _purchaseError = MutableStateFlow<String?>(null)
|
||||
val purchaseError: StateFlow<String?> = _purchaseError
|
||||
|
||||
private val _connectionState = MutableStateFlow(false)
|
||||
val isConnected: StateFlow<Boolean> = _connectionState
|
||||
|
||||
private val purchasesUpdatedListener = PurchasesUpdatedListener { billingResult, purchases ->
|
||||
when (billingResult.responseCode) {
|
||||
BillingClient.BillingResponseCode.OK -> {
|
||||
purchases?.forEach { purchase ->
|
||||
handlePurchase(purchase)
|
||||
}
|
||||
}
|
||||
BillingClient.BillingResponseCode.USER_CANCELED -> {
|
||||
Log.d(TAG, "User canceled purchase")
|
||||
_purchaseError.value = null // Not really an error
|
||||
}
|
||||
else -> {
|
||||
val errorMessage = "Purchase failed: ${billingResult.debugMessage}"
|
||||
Log.e(TAG, errorMessage)
|
||||
_purchaseError.value = errorMessage
|
||||
}
|
||||
}
|
||||
_isLoading.value = false
|
||||
}
|
||||
|
||||
private val billingClient: BillingClient = BillingClient.newBuilder(context)
|
||||
.setListener(purchasesUpdatedListener)
|
||||
.enablePendingPurchases()
|
||||
.build()
|
||||
|
||||
init {
|
||||
Log.d(TAG, "BillingManager initialized")
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to Google Play Billing
|
||||
*/
|
||||
fun startConnection(onSuccess: () -> Unit = {}, onError: (String) -> Unit = {}) {
|
||||
if (billingClient.isReady) {
|
||||
Log.d(TAG, "Already connected to billing")
|
||||
onSuccess()
|
||||
return
|
||||
}
|
||||
|
||||
billingClient.startConnection(object : BillingClientStateListener {
|
||||
override fun onBillingSetupFinished(billingResult: BillingResult) {
|
||||
if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
|
||||
Log.d(TAG, "Billing connection successful")
|
||||
_connectionState.value = true
|
||||
onSuccess()
|
||||
} else {
|
||||
val error = "Billing setup failed: ${billingResult.debugMessage}"
|
||||
Log.e(TAG, error)
|
||||
_connectionState.value = false
|
||||
onError(error)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onBillingServiceDisconnected() {
|
||||
Log.w(TAG, "Billing service disconnected, will retry")
|
||||
_connectionState.value = false
|
||||
// Retry connection after a delay
|
||||
scope.launch {
|
||||
kotlinx.coroutines.delay(3000)
|
||||
startConnection(onSuccess, onError)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Query available subscription products from Google Play
|
||||
*/
|
||||
suspend fun loadProducts() {
|
||||
if (!billingClient.isReady) {
|
||||
Log.w(TAG, "Billing client not ready, cannot load products")
|
||||
return
|
||||
}
|
||||
|
||||
_isLoading.value = true
|
||||
|
||||
try {
|
||||
val productList = productIDs.map { productId ->
|
||||
QueryProductDetailsParams.Product.newBuilder()
|
||||
.setProductId(productId)
|
||||
.setProductType(BillingClient.ProductType.SUBS)
|
||||
.build()
|
||||
}
|
||||
|
||||
val params = QueryProductDetailsParams.newBuilder()
|
||||
.setProductList(productList)
|
||||
.build()
|
||||
|
||||
val result = billingClient.queryProductDetails(params)
|
||||
|
||||
if (result.billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
|
||||
_products.value = result.productDetailsList ?: emptyList()
|
||||
Log.d(TAG, "Loaded ${_products.value.size} products")
|
||||
|
||||
// Log product details for debugging
|
||||
_products.value.forEach { product ->
|
||||
Log.d(TAG, "Product: ${product.productId} - ${product.title}")
|
||||
product.subscriptionOfferDetails?.forEach { offer ->
|
||||
Log.d(TAG, " Offer: ${offer.basePlanId} - ${offer.pricingPhases.pricingPhaseList.firstOrNull()?.formattedPrice}")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Log.e(TAG, "Failed to load products: ${result.billingResult.debugMessage}")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error loading products", e)
|
||||
} finally {
|
||||
_isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Launch purchase flow for a subscription
|
||||
*/
|
||||
fun launchPurchaseFlow(
|
||||
activity: Activity,
|
||||
productDetails: ProductDetails,
|
||||
onSuccess: () -> Unit = {},
|
||||
onError: (String) -> Unit = {}
|
||||
) {
|
||||
if (!billingClient.isReady) {
|
||||
onError("Billing not ready")
|
||||
return
|
||||
}
|
||||
|
||||
val offerToken = productDetails.subscriptionOfferDetails?.firstOrNull()?.offerToken
|
||||
if (offerToken == null) {
|
||||
onError("No offer available for this product")
|
||||
return
|
||||
}
|
||||
|
||||
_isLoading.value = true
|
||||
_purchaseError.value = null
|
||||
|
||||
val productDetailsParams = BillingFlowParams.ProductDetailsParams.newBuilder()
|
||||
.setProductDetails(productDetails)
|
||||
.setOfferToken(offerToken)
|
||||
.build()
|
||||
|
||||
val billingFlowParams = BillingFlowParams.newBuilder()
|
||||
.setProductDetailsParamsList(listOf(productDetailsParams))
|
||||
.build()
|
||||
|
||||
val billingResult = billingClient.launchBillingFlow(activity, billingFlowParams)
|
||||
|
||||
if (billingResult.responseCode != BillingClient.BillingResponseCode.OK) {
|
||||
_isLoading.value = false
|
||||
val error = "Failed to launch purchase: ${billingResult.debugMessage}"
|
||||
Log.e(TAG, error)
|
||||
onError(error)
|
||||
}
|
||||
// Note: Success/failure is handled in purchasesUpdatedListener
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a completed purchase
|
||||
*/
|
||||
private fun handlePurchase(purchase: Purchase) {
|
||||
if (purchase.purchaseState == Purchase.PurchaseState.PURCHASED) {
|
||||
Log.d(TAG, "Purchase successful: ${purchase.products}")
|
||||
|
||||
// Verify with backend and acknowledge
|
||||
scope.launch {
|
||||
try {
|
||||
// Verify purchase with backend
|
||||
val verified = verifyPurchaseWithBackend(
|
||||
purchaseToken = purchase.purchaseToken,
|
||||
productId = purchase.products.firstOrNull() ?: ""
|
||||
)
|
||||
|
||||
if (verified) {
|
||||
// Acknowledge if not already acknowledged
|
||||
if (!purchase.isAcknowledged) {
|
||||
acknowledgePurchase(purchase.purchaseToken)
|
||||
}
|
||||
|
||||
// Update local state
|
||||
_purchasedProductIDs.value = _purchasedProductIDs.value + purchase.products.toSet()
|
||||
|
||||
// Refresh subscription status from backend (updates DataManager which derives tier)
|
||||
APILayer.getSubscriptionStatus(forceRefresh = true)
|
||||
|
||||
Log.d(TAG, "Purchase verified and acknowledged")
|
||||
} else {
|
||||
_purchaseError.value = "Failed to verify purchase with server"
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error handling purchase", e)
|
||||
_purchaseError.value = "Error processing purchase: ${e.message}"
|
||||
}
|
||||
}
|
||||
} else if (purchase.purchaseState == Purchase.PurchaseState.PENDING) {
|
||||
Log.d(TAG, "Purchase pending: ${purchase.products}")
|
||||
// Handle pending purchases (e.g., waiting for payment method)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify purchase with backend server
|
||||
*/
|
||||
private suspend fun verifyPurchaseWithBackend(purchaseToken: String, productId: String): Boolean {
|
||||
return try {
|
||||
val result = APILayer.verifyAndroidPurchase(
|
||||
purchaseToken = purchaseToken,
|
||||
productId = productId
|
||||
)
|
||||
|
||||
when (result) {
|
||||
is ApiResult.Success -> {
|
||||
Log.d(TAG, "Backend verification successful: tier=${result.data.tier}")
|
||||
result.data.success
|
||||
}
|
||||
is ApiResult.Error -> {
|
||||
Log.e(TAG, "Backend verification failed: ${result.message}")
|
||||
false
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error verifying purchase with backend", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Acknowledge purchase (required by Google Play - purchases refund after 3 days if not acknowledged)
|
||||
*/
|
||||
private suspend fun acknowledgePurchase(purchaseToken: String) {
|
||||
val params = AcknowledgePurchaseParams.newBuilder()
|
||||
.setPurchaseToken(purchaseToken)
|
||||
.build()
|
||||
|
||||
val result = billingClient.acknowledgePurchase(params)
|
||||
|
||||
if (result.responseCode == BillingClient.BillingResponseCode.OK) {
|
||||
Log.d(TAG, "Purchase acknowledged")
|
||||
} else {
|
||||
Log.e(TAG, "Failed to acknowledge purchase: ${result.debugMessage}")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore purchases (check for existing subscriptions)
|
||||
*/
|
||||
suspend fun restorePurchases(): Boolean {
|
||||
if (!billingClient.isReady) {
|
||||
Log.w(TAG, "Billing client not ready, cannot restore purchases")
|
||||
return false
|
||||
}
|
||||
|
||||
_isLoading.value = true
|
||||
|
||||
return try {
|
||||
val params = QueryPurchasesParams.newBuilder()
|
||||
.setProductType(BillingClient.ProductType.SUBS)
|
||||
.build()
|
||||
|
||||
val result = billingClient.queryPurchasesAsync(params)
|
||||
|
||||
if (result.billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
|
||||
val activePurchases = result.purchasesList.filter {
|
||||
it.purchaseState == Purchase.PurchaseState.PURCHASED
|
||||
}
|
||||
|
||||
Log.d(TAG, "Found ${activePurchases.size} active purchases")
|
||||
|
||||
if (activePurchases.isNotEmpty()) {
|
||||
// Update purchased products
|
||||
_purchasedProductIDs.value = activePurchases
|
||||
.flatMap { it.products }
|
||||
.toSet()
|
||||
|
||||
// Verify each with backend
|
||||
activePurchases.forEach { purchase ->
|
||||
verifyPurchaseWithBackend(
|
||||
purchaseToken = purchase.purchaseToken,
|
||||
productId = purchase.products.firstOrNull() ?: ""
|
||||
)
|
||||
}
|
||||
|
||||
// Refresh subscription status from backend (updates DataManager which derives tier)
|
||||
APILayer.getSubscriptionStatus(forceRefresh = true)
|
||||
|
||||
true
|
||||
} else {
|
||||
Log.d(TAG, "No active purchases to restore")
|
||||
// Still fetch subscription status from backend to get free tier limits
|
||||
APILayer.getSubscriptionStatus(forceRefresh = true)
|
||||
false
|
||||
}
|
||||
} else {
|
||||
Log.e(TAG, "Failed to query purchases: ${result.billingResult.debugMessage}")
|
||||
false
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error restoring purchases", e)
|
||||
false
|
||||
} finally {
|
||||
_isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear error state
|
||||
*/
|
||||
fun clearError() {
|
||||
_purchaseError.value = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Get formatted price for a product
|
||||
*/
|
||||
fun getFormattedPrice(productDetails: ProductDetails): String? {
|
||||
return productDetails.subscriptionOfferDetails
|
||||
?.firstOrNull()
|
||||
?.pricingPhases
|
||||
?.pricingPhaseList
|
||||
?.firstOrNull()
|
||||
?.formattedPrice
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate savings percentage for annual vs monthly
|
||||
*/
|
||||
fun calculateAnnualSavings(monthly: ProductDetails?, annual: ProductDetails?): Int? {
|
||||
if (monthly == null || annual == null) return null
|
||||
|
||||
val monthlyPrice = monthly.subscriptionOfferDetails
|
||||
?.firstOrNull()
|
||||
?.pricingPhases
|
||||
?.pricingPhaseList
|
||||
?.firstOrNull()
|
||||
?.priceAmountMicros ?: return null
|
||||
|
||||
val annualPrice = annual.subscriptionOfferDetails
|
||||
?.firstOrNull()
|
||||
?.pricingPhases
|
||||
?.pricingPhaseList
|
||||
?.firstOrNull()
|
||||
?.priceAmountMicros ?: return null
|
||||
|
||||
// Calculate what 12 months would cost vs annual price
|
||||
val yearlyAtMonthlyRate = monthlyPrice * 12
|
||||
val savings = ((yearlyAtMonthlyRate - annualPrice).toDouble() / yearlyAtMonthlyRate * 100).toInt()
|
||||
|
||||
return if (savings > 0) savings else null
|
||||
}
|
||||
|
||||
/**
|
||||
* Get product by ID
|
||||
*/
|
||||
fun getProduct(productId: String): ProductDetails? {
|
||||
return _products.value.find { it.productId == productId }
|
||||
}
|
||||
|
||||
/**
|
||||
* Get monthly product
|
||||
*/
|
||||
fun getMonthlyProduct(): ProductDetails? {
|
||||
return _products.value.find { it.productId == SubscriptionProducts.MONTHLY }
|
||||
}
|
||||
|
||||
/**
|
||||
* Get annual product
|
||||
*/
|
||||
fun getAnnualProduct(): ProductDetails? {
|
||||
return _products.value.find { it.productId == SubscriptionProducts.ANNUAL }
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has an active subscription
|
||||
*/
|
||||
fun hasActiveSubscription(): Boolean {
|
||||
return _purchasedProductIDs.value.isNotEmpty()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.tt.honeyDue.platform
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.compose.runtime.Composable
|
||||
import com.tt.honeyDue.models.Contractor
|
||||
import com.tt.honeyDue.ui.components.ContractorImportHandler as ContractorImportHandlerImpl
|
||||
|
||||
@Composable
|
||||
actual fun ContractorImportHandler(
|
||||
pendingContractorImportUri: Any?,
|
||||
onClearContractorImport: () -> Unit,
|
||||
onImportSuccess: (Contractor) -> Unit
|
||||
) {
|
||||
// Cast to Android Uri
|
||||
val uri = pendingContractorImportUri as? Uri
|
||||
|
||||
ContractorImportHandlerImpl(
|
||||
pendingImportUri = uri,
|
||||
onClearImport = onClearContractorImport,
|
||||
onImportSuccess = onImportSuccess
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.tt.honeyDue.platform
|
||||
|
||||
import android.content.Intent
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import com.tt.honeyDue.models.Contractor
|
||||
import com.tt.honeyDue.sharing.ContractorSharingManager
|
||||
import com.tt.honeyDue.analytics.PostHogAnalytics
|
||||
import com.tt.honeyDue.analytics.AnalyticsEvents
|
||||
|
||||
@Composable
|
||||
actual fun rememberShareContractor(): (Contractor) -> Unit {
|
||||
val context = LocalContext.current
|
||||
|
||||
return { contractor: Contractor ->
|
||||
val intent = ContractorSharingManager.createShareIntent(context, contractor)
|
||||
if (intent != null) {
|
||||
// Track contractor shared event
|
||||
PostHogAnalytics.capture(AnalyticsEvents.CONTRACTOR_SHARED)
|
||||
context.startActivity(Intent.createChooser(intent, "Share Contractor"))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package com.tt.honeyDue.platform
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.os.VibrationEffect
|
||||
import android.os.Vibrator
|
||||
import android.os.VibratorManager
|
||||
import android.view.HapticFeedbackConstants
|
||||
import android.view.View
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
|
||||
/**
|
||||
* Android implementation of haptic feedback using system vibrator.
|
||||
*/
|
||||
class AndroidHapticFeedbackPerformer(
|
||||
private val view: View,
|
||||
private val vibrator: Vibrator?
|
||||
) : HapticFeedbackPerformer {
|
||||
|
||||
override fun perform(type: HapticFeedbackType) {
|
||||
// First try View-based haptic feedback (works best)
|
||||
val hapticConstant = when (type) {
|
||||
HapticFeedbackType.Light -> HapticFeedbackConstants.KEYBOARD_TAP
|
||||
HapticFeedbackType.Medium -> HapticFeedbackConstants.CONTEXT_CLICK
|
||||
HapticFeedbackType.Heavy -> HapticFeedbackConstants.LONG_PRESS
|
||||
HapticFeedbackType.Selection -> HapticFeedbackConstants.CLOCK_TICK
|
||||
HapticFeedbackType.Success -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
HapticFeedbackConstants.CONFIRM
|
||||
} else {
|
||||
HapticFeedbackConstants.CONTEXT_CLICK
|
||||
}
|
||||
HapticFeedbackType.Warning -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
HapticFeedbackConstants.REJECT
|
||||
} else {
|
||||
HapticFeedbackConstants.LONG_PRESS
|
||||
}
|
||||
HapticFeedbackType.Error -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
HapticFeedbackConstants.REJECT
|
||||
} else {
|
||||
HapticFeedbackConstants.LONG_PRESS
|
||||
}
|
||||
}
|
||||
|
||||
val success = view.performHapticFeedback(hapticConstant)
|
||||
|
||||
// Fallback to vibrator if view-based feedback fails
|
||||
if (!success && vibrator?.hasVibrator() == true) {
|
||||
performVibration(type)
|
||||
}
|
||||
}
|
||||
|
||||
private fun performVibration(type: HapticFeedbackType) {
|
||||
vibrator ?: return
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val effect = when (type) {
|
||||
HapticFeedbackType.Light -> VibrationEffect.createOneShot(10, VibrationEffect.DEFAULT_AMPLITUDE)
|
||||
HapticFeedbackType.Medium -> VibrationEffect.createOneShot(20, VibrationEffect.DEFAULT_AMPLITUDE)
|
||||
HapticFeedbackType.Heavy -> VibrationEffect.createOneShot(50, VibrationEffect.DEFAULT_AMPLITUDE)
|
||||
HapticFeedbackType.Selection -> VibrationEffect.createOneShot(5, VibrationEffect.DEFAULT_AMPLITUDE)
|
||||
HapticFeedbackType.Success -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
VibrationEffect.createPredefined(VibrationEffect.EFFECT_TICK)
|
||||
} else {
|
||||
VibrationEffect.createOneShot(30, VibrationEffect.DEFAULT_AMPLITUDE)
|
||||
}
|
||||
HapticFeedbackType.Warning -> VibrationEffect.createOneShot(40, VibrationEffect.DEFAULT_AMPLITUDE)
|
||||
HapticFeedbackType.Error -> VibrationEffect.createOneShot(60, VibrationEffect.DEFAULT_AMPLITUDE)
|
||||
}
|
||||
vibrator.vibrate(effect)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
val duration = when (type) {
|
||||
HapticFeedbackType.Light -> 10L
|
||||
HapticFeedbackType.Medium -> 20L
|
||||
HapticFeedbackType.Heavy -> 50L
|
||||
HapticFeedbackType.Selection -> 5L
|
||||
HapticFeedbackType.Success -> 30L
|
||||
HapticFeedbackType.Warning -> 40L
|
||||
HapticFeedbackType.Error -> 60L
|
||||
}
|
||||
@Suppress("DEPRECATION")
|
||||
vibrator.vibrate(duration)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
actual fun rememberHapticFeedback(): HapticFeedbackPerformer {
|
||||
val context = LocalContext.current
|
||||
val view = LocalView.current
|
||||
|
||||
return remember {
|
||||
val vibrator = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
val vibratorManager = context.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as? VibratorManager
|
||||
vibratorManager?.defaultVibrator
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
context.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator
|
||||
}
|
||||
AndroidHapticFeedbackPerformer(view, vibrator)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.tt.honeyDue.platform
|
||||
|
||||
import android.graphics.BitmapFactory
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
|
||||
@Composable
|
||||
actual fun rememberImageBitmap(imageData: ImageData): ImageBitmap? {
|
||||
return remember(imageData) {
|
||||
try {
|
||||
val bitmap = BitmapFactory.decodeByteArray(imageData.bytes, 0, imageData.bytes.size)
|
||||
bitmap?.asImageBitmap()
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.tt.honeyDue.platform
|
||||
|
||||
import android.content.Context
|
||||
import coil3.ImageLoader
|
||||
import coil3.PlatformContext
|
||||
import coil3.disk.DiskCache
|
||||
import coil3.memory.MemoryCache
|
||||
import coil3.network.ktor3.KtorNetworkFetcherFactory
|
||||
import coil3.request.crossfade
|
||||
import coil3.util.DebugLogger
|
||||
import okio.FileSystem
|
||||
|
||||
fun getAsyncImageLoader(context: PlatformContext): ImageLoader {
|
||||
return ImageLoader.Builder(context)
|
||||
.components {
|
||||
add(KtorNetworkFetcherFactory())
|
||||
}
|
||||
.memoryCache {
|
||||
MemoryCache.Builder()
|
||||
.maxSizePercent(context, 0.25)
|
||||
.build()
|
||||
}
|
||||
.diskCache {
|
||||
DiskCache.Builder()
|
||||
.directory(FileSystem.SYSTEM_TEMPORARY_DIRECTORY / "image_cache")
|
||||
.maxSizeBytes(512L * 1024 * 1024) // 512MB
|
||||
.build()
|
||||
}
|
||||
.crossfade(true)
|
||||
.logger(DebugLogger())
|
||||
.build()
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package com.tt.honeyDue.platform
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.PickVisualMediaRequest
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.core.content.FileProvider
|
||||
import java.io.File
|
||||
|
||||
@Composable
|
||||
actual fun rememberImagePicker(
|
||||
onImagesPicked: (List<ImageData>) -> Unit
|
||||
): () -> Unit {
|
||||
val context = LocalContext.current
|
||||
|
||||
val launcher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.PickMultipleVisualMedia(5)
|
||||
) { uris: List<Uri> ->
|
||||
if (uris.isNotEmpty()) {
|
||||
val images = uris.mapNotNull { uri ->
|
||||
try {
|
||||
val inputStream = context.contentResolver.openInputStream(uri)
|
||||
val bytes = inputStream?.readBytes()
|
||||
inputStream?.close()
|
||||
|
||||
val fileName = getFileNameFromUri(context, uri)
|
||||
|
||||
if (bytes != null) {
|
||||
ImageData(bytes, fileName)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
null
|
||||
}
|
||||
}
|
||||
if (images.isNotEmpty()) {
|
||||
onImagesPicked(images)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
launcher.launch(
|
||||
PickVisualMediaRequest(
|
||||
ActivityResultContracts.PickVisualMedia.ImageOnly
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
actual fun rememberCameraPicker(
|
||||
onImageCaptured: (ImageData) -> Unit
|
||||
): () -> Unit {
|
||||
val context = LocalContext.current
|
||||
|
||||
// Create a temp file URI for the camera to save to
|
||||
val photoUri = remember {
|
||||
val photoFile = File(context.cacheDir, "camera_photo_${System.currentTimeMillis()}.jpg")
|
||||
FileProvider.getUriForFile(
|
||||
context,
|
||||
"${context.packageName}.fileprovider",
|
||||
photoFile
|
||||
)
|
||||
}
|
||||
|
||||
val launcher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.TakePicture()
|
||||
) { success: Boolean ->
|
||||
if (success) {
|
||||
try {
|
||||
val inputStream = context.contentResolver.openInputStream(photoUri)
|
||||
val bytes = inputStream?.readBytes()
|
||||
inputStream?.close()
|
||||
|
||||
if (bytes != null) {
|
||||
val fileName = "camera_${System.currentTimeMillis()}.jpg"
|
||||
onImageCaptured(ImageData(bytes, fileName))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
launcher.launch(photoUri)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getFileNameFromUri(context: android.content.Context, uri: Uri): String {
|
||||
var fileName = "image_${System.currentTimeMillis()}.jpg"
|
||||
|
||||
try {
|
||||
context.contentResolver.query(uri, null, null, null, null)?.use { cursor ->
|
||||
if (cursor.moveToFirst()) {
|
||||
val nameIndex = cursor.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME)
|
||||
if (nameIndex >= 0) {
|
||||
val name = cursor.getString(nameIndex)
|
||||
if (name != null) {
|
||||
fileName = name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
|
||||
return fileName
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package com.tt.honeyDue.platform
|
||||
|
||||
import android.app.Activity
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import com.tt.honeyDue.ui.subscription.UpgradeScreen
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
actual fun PlatformUpgradeScreen(
|
||||
onNavigateBack: () -> Unit,
|
||||
onSubscriptionChanged: () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val activity = context as? Activity
|
||||
val billingManager = remember { BillingManager.getInstance(context) }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
// Load products on launch
|
||||
LaunchedEffect(Unit) {
|
||||
billingManager.startConnection(
|
||||
onSuccess = {
|
||||
scope.launch { billingManager.loadProducts() }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Watch for successful purchase
|
||||
val purchasedProductIDs by billingManager.purchasedProductIDs.collectAsState()
|
||||
var initialPurchaseCount by remember { mutableStateOf(purchasedProductIDs.size) }
|
||||
|
||||
LaunchedEffect(purchasedProductIDs) {
|
||||
if (purchasedProductIDs.size > initialPurchaseCount) {
|
||||
onSubscriptionChanged()
|
||||
onNavigateBack()
|
||||
}
|
||||
}
|
||||
|
||||
UpgradeScreen(
|
||||
onNavigateBack = onNavigateBack,
|
||||
onPurchase = { planId ->
|
||||
val product = billingManager.getProduct(planId)
|
||||
if (product != null && activity != null) {
|
||||
billingManager.launchPurchaseFlow(activity, product)
|
||||
}
|
||||
},
|
||||
onRestorePurchases = {
|
||||
scope.launch {
|
||||
val restored = billingManager.restorePurchases()
|
||||
if (restored) {
|
||||
onSubscriptionChanged()
|
||||
onNavigateBack()
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.tt.honeyDue.platform
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.compose.runtime.Composable
|
||||
import com.tt.honeyDue.models.JoinResidenceResponse
|
||||
import com.tt.honeyDue.ui.components.ResidenceImportHandler as ResidenceImportHandlerImpl
|
||||
|
||||
@Composable
|
||||
actual fun ResidenceImportHandler(
|
||||
pendingResidenceImportUri: Any?,
|
||||
onClearResidenceImport: () -> Unit,
|
||||
onImportSuccess: (JoinResidenceResponse) -> Unit
|
||||
) {
|
||||
// Cast to Android Uri
|
||||
val uri = pendingResidenceImportUri as? Uri
|
||||
|
||||
ResidenceImportHandlerImpl(
|
||||
pendingImportUri = uri,
|
||||
onClearImport = onClearResidenceImport,
|
||||
onImportSuccess = onImportSuccess
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.tt.honeyDue.platform
|
||||
|
||||
import android.content.Intent
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import com.tt.honeyDue.models.Residence
|
||||
import com.tt.honeyDue.sharing.ResidenceSharingManager
|
||||
import com.tt.honeyDue.analytics.PostHogAnalytics
|
||||
import com.tt.honeyDue.analytics.AnalyticsEvents
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
actual fun rememberShareResidence(): Pair<ResidenceSharingState, (Residence) -> Unit> {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
var state by remember { mutableStateOf(ResidenceSharingState()) }
|
||||
|
||||
val shareFunction: (Residence) -> Unit = { residence ->
|
||||
scope.launch {
|
||||
state = ResidenceSharingState(isLoading = true)
|
||||
|
||||
val intent = ResidenceSharingManager.createShareIntent(context, residence)
|
||||
if (intent != null) {
|
||||
state = ResidenceSharingState(isLoading = false)
|
||||
// Track residence shared event
|
||||
PostHogAnalytics.capture(
|
||||
AnalyticsEvents.RESIDENCE_SHARED,
|
||||
mapOf("method" to "file")
|
||||
)
|
||||
context.startActivity(Intent.createChooser(intent, "Share Residence"))
|
||||
} else {
|
||||
state = ResidenceSharingState(
|
||||
isLoading = false,
|
||||
error = "Failed to generate share package"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Pair(state, shareFunction)
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package com.tt.honeyDue.sharing
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.core.content.FileProvider
|
||||
import com.tt.honeyDue.data.DataManager
|
||||
import com.tt.honeyDue.models.honeyDueShareCodec
|
||||
import com.tt.honeyDue.models.Contractor
|
||||
import com.tt.honeyDue.network.APILayer
|
||||
import com.tt.honeyDue.network.ApiResult
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Manages contractor export and import via .honeydue files on Android.
|
||||
*/
|
||||
object ContractorSharingManager {
|
||||
|
||||
/**
|
||||
* Creates a share Intent for a contractor.
|
||||
* The contractor data is written to a temporary .honeydue file and shared via FileProvider.
|
||||
*
|
||||
* @param context Android context
|
||||
* @param contractor The contractor to share
|
||||
* @return Share Intent or null if creation failed
|
||||
*/
|
||||
fun createShareIntent(context: Context, contractor: Contractor): Intent? {
|
||||
return try {
|
||||
val currentUsername = DataManager.currentUser.value?.username ?: "Unknown"
|
||||
val jsonString = honeyDueShareCodec.encodeContractorPackage(contractor, currentUsername)
|
||||
val fileName = honeyDueShareCodec.safeShareFileName(contractor.name)
|
||||
|
||||
// Create shared directory
|
||||
val shareDir = File(context.cacheDir, "shared")
|
||||
shareDir.mkdirs()
|
||||
|
||||
val file = File(shareDir, fileName)
|
||||
file.writeText(jsonString)
|
||||
|
||||
val uri = FileProvider.getUriForFile(
|
||||
context,
|
||||
"${context.packageName}.fileprovider",
|
||||
file
|
||||
)
|
||||
|
||||
Intent(Intent.ACTION_SEND).apply {
|
||||
type = "application/json"
|
||||
putExtra(Intent.EXTRA_STREAM, uri)
|
||||
putExtra(Intent.EXTRA_SUBJECT, "Contractor: ${contractor.name}")
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Imports a contractor from a content URI.
|
||||
*
|
||||
* @param context Android context
|
||||
* @param uri The content URI of the .honeydue file
|
||||
* @return ApiResult with the created Contractor on success, or error on failure
|
||||
*/
|
||||
suspend fun importContractor(context: Context, uri: Uri): ApiResult<Contractor> {
|
||||
return withContext(Dispatchers.IO) {
|
||||
try {
|
||||
// Check authentication
|
||||
if (DataManager.authToken.value == null) {
|
||||
return@withContext ApiResult.Error("You must be logged in to import a contractor", 401)
|
||||
}
|
||||
|
||||
// Read file content
|
||||
val inputStream = context.contentResolver.openInputStream(uri)
|
||||
?: return@withContext ApiResult.Error("Could not open file")
|
||||
|
||||
val jsonString = inputStream.bufferedReader().use { it.readText() }
|
||||
inputStream.close()
|
||||
|
||||
val createRequest = honeyDueShareCodec.createContractorImportRequestOrNull(
|
||||
jsonContent = jsonString,
|
||||
availableSpecialties = DataManager.contractorSpecialties.value
|
||||
) ?: return@withContext ApiResult.Error("Invalid contractor share package")
|
||||
|
||||
// Call API
|
||||
APILayer.createContractor(createRequest)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
ApiResult.Error("Failed to import contractor: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the given URI appears to be a .honeydue file.
|
||||
*/
|
||||
fun ishoneyDueFile(context: Context, uri: Uri): Boolean {
|
||||
// Check file extension from URI path
|
||||
val path = uri.path ?: uri.toString()
|
||||
if (path.endsWith(".honeydue", ignoreCase = true)) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Try to get display name from content resolver
|
||||
try {
|
||||
context.contentResolver.query(uri, null, null, null, null)?.use { cursor ->
|
||||
if (cursor.moveToFirst()) {
|
||||
val nameIndex = cursor.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME)
|
||||
if (nameIndex >= 0) {
|
||||
val name = cursor.getString(nameIndex)
|
||||
if (name?.endsWith(".honeydue", ignoreCase = true) == true) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// Ignore errors, fall through to false
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package com.tt.honeyDue.sharing
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.core.content.FileProvider
|
||||
import com.tt.honeyDue.data.DataManager
|
||||
import com.tt.honeyDue.models.honeyDueShareCodec
|
||||
import com.tt.honeyDue.models.JoinResidenceResponse
|
||||
import com.tt.honeyDue.models.Residence
|
||||
import com.tt.honeyDue.network.APILayer
|
||||
import com.tt.honeyDue.network.ApiResult
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Manages residence share package creation and import via .honeydue files on Android.
|
||||
* Unlike contractors (which are exported client-side), residence sharing uses
|
||||
* server-generated share codes.
|
||||
*/
|
||||
object ResidenceSharingManager {
|
||||
|
||||
/**
|
||||
* Creates a share Intent for a residence.
|
||||
* This first calls the backend to generate a share code, then creates the file.
|
||||
*
|
||||
* @param context Android context
|
||||
* @param residence The residence to share
|
||||
* @return Share Intent or null if creation failed
|
||||
*/
|
||||
suspend fun createShareIntent(context: Context, residence: Residence): Intent? {
|
||||
return withContext(Dispatchers.IO) {
|
||||
try {
|
||||
// Generate share package from backend
|
||||
val result = APILayer.generateSharePackage(residence.id)
|
||||
|
||||
when (result) {
|
||||
is ApiResult.Success -> {
|
||||
val sharedResidence = result.data
|
||||
val jsonString = honeyDueShareCodec.encodeSharedResidence(sharedResidence)
|
||||
val fileName = honeyDueShareCodec.safeShareFileName(residence.name)
|
||||
|
||||
// Create shared directory
|
||||
val shareDir = File(context.cacheDir, "shared")
|
||||
shareDir.mkdirs()
|
||||
|
||||
val file = File(shareDir, fileName)
|
||||
file.writeText(jsonString)
|
||||
|
||||
val uri = FileProvider.getUriForFile(
|
||||
context,
|
||||
"${context.packageName}.fileprovider",
|
||||
file
|
||||
)
|
||||
|
||||
Intent(Intent.ACTION_SEND).apply {
|
||||
type = "application/json"
|
||||
putExtra(Intent.EXTRA_STREAM, uri)
|
||||
putExtra(Intent.EXTRA_SUBJECT, "Join my residence: ${residence.name}")
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
}
|
||||
is ApiResult.Error -> {
|
||||
null
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Imports (joins) a residence from a content URI containing a share code.
|
||||
*
|
||||
* @param context Android context
|
||||
* @param uri The content URI of the .honeydue file
|
||||
* @return ApiResult with the JoinResidenceResponse on success, or error on failure
|
||||
*/
|
||||
suspend fun importResidence(context: Context, uri: Uri): ApiResult<JoinResidenceResponse> {
|
||||
return withContext(Dispatchers.IO) {
|
||||
try {
|
||||
// Check authentication
|
||||
if (DataManager.authToken.value == null) {
|
||||
return@withContext ApiResult.Error("You must be logged in to join a residence", 401)
|
||||
}
|
||||
|
||||
// Read file content
|
||||
val inputStream = context.contentResolver.openInputStream(uri)
|
||||
?: return@withContext ApiResult.Error("Could not open file")
|
||||
|
||||
val jsonString = inputStream.bufferedReader().use { it.readText() }
|
||||
inputStream.close()
|
||||
|
||||
val shareCode = honeyDueShareCodec.extractResidenceShareCodeOrNull(jsonString)
|
||||
?: return@withContext ApiResult.Error("Invalid residence share package")
|
||||
|
||||
// Call API with share code
|
||||
APILayer.joinWithCode(shareCode)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
ApiResult.Error("Failed to join residence: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.tt.honeyDue.storage
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
|
||||
/**
|
||||
* Android implementation of TaskCacheManager using SharedPreferences.
|
||||
*/
|
||||
actual class TaskCacheManager(private val context: Context) {
|
||||
private val prefs: SharedPreferences = context.getSharedPreferences(
|
||||
PREFS_NAME,
|
||||
Context.MODE_PRIVATE
|
||||
)
|
||||
|
||||
actual fun saveTasks(tasksJson: String) {
|
||||
prefs.edit().putString(KEY_TASKS, tasksJson).apply()
|
||||
}
|
||||
|
||||
actual fun getTasks(): String? {
|
||||
return prefs.getString(KEY_TASKS, null)
|
||||
}
|
||||
|
||||
actual fun clearTasks() {
|
||||
prefs.edit().remove(KEY_TASKS).apply()
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if tasks need refresh due to widget interactions.
|
||||
*/
|
||||
actual fun areTasksDirty(): Boolean {
|
||||
return prefs.getBoolean(KEY_DIRTY_FLAG, false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark tasks as dirty (needs refresh).
|
||||
* Called when widget modifies task data.
|
||||
*/
|
||||
actual fun markTasksDirty() {
|
||||
prefs.edit().putBoolean(KEY_DIRTY_FLAG, true).apply()
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the dirty flag after tasks have been refreshed.
|
||||
*/
|
||||
actual fun clearDirtyFlag() {
|
||||
prefs.edit().putBoolean(KEY_DIRTY_FLAG, false).apply()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val PREFS_NAME = "honeydue_cache"
|
||||
private const val KEY_TASKS = "cached_tasks"
|
||||
private const val KEY_DIRTY_FLAG = "tasks_dirty"
|
||||
|
||||
@Volatile
|
||||
private var instance: TaskCacheManager? = null
|
||||
|
||||
fun getInstance(context: Context): TaskCacheManager {
|
||||
return instance ?: synchronized(this) {
|
||||
instance ?: TaskCacheManager(context.applicationContext).also { instance = it }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.tt.honeyDue.storage
|
||||
|
||||
internal actual fun getPlatformTaskCacheManager(): TaskCacheManager? {
|
||||
// Android requires context, so must use initialize() method
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.tt.honeyDue.storage
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
|
||||
/**
|
||||
* Android implementation of theme storage using SharedPreferences.
|
||||
*/
|
||||
actual class ThemeStorageManager(context: Context) {
|
||||
private val prefs: SharedPreferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
|
||||
actual fun saveThemeId(themeId: String) {
|
||||
prefs.edit().putString(KEY_THEME_ID, themeId).apply()
|
||||
}
|
||||
|
||||
actual fun getThemeId(): String? {
|
||||
return prefs.getString(KEY_THEME_ID, null)
|
||||
}
|
||||
|
||||
actual fun clearThemeId() {
|
||||
prefs.edit().remove(KEY_THEME_ID).apply()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val PREFS_NAME = "honeydue_theme_prefs"
|
||||
private const val KEY_THEME_ID = "theme_id"
|
||||
|
||||
@Volatile
|
||||
private var instance: ThemeStorageManager? = null
|
||||
|
||||
fun getInstance(context: Context): ThemeStorageManager {
|
||||
return instance ?: synchronized(this) {
|
||||
instance ?: ThemeStorageManager(context.applicationContext).also { instance = it }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package com.tt.honeyDue.storage
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import android.util.Log
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import androidx.security.crypto.MasterKey
|
||||
|
||||
/**
|
||||
* Android implementation of TokenManager using EncryptedSharedPreferences.
|
||||
*
|
||||
* Uses AndroidX Security Crypto library with AES256-GCM master key
|
||||
* to encrypt the auth token at rest. Falls back to regular SharedPreferences
|
||||
* if EncryptedSharedPreferences initialization fails (e.g., on very old devices
|
||||
* or when the Keystore is in a broken state).
|
||||
*/
|
||||
actual class TokenManager(private val context: Context) {
|
||||
private val prefs: SharedPreferences = createEncryptedPrefs(context)
|
||||
|
||||
actual fun saveToken(token: String) {
|
||||
prefs.edit().putString(KEY_TOKEN, token).apply()
|
||||
}
|
||||
|
||||
actual fun getToken(): String? {
|
||||
return prefs.getString(KEY_TOKEN, null)
|
||||
}
|
||||
|
||||
actual fun clearToken() {
|
||||
prefs.edit().remove(KEY_TOKEN).apply()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "TokenManager"
|
||||
private const val ENCRYPTED_PREFS_NAME = "honeydue_secure_prefs"
|
||||
private const val FALLBACK_PREFS_NAME = "honeydue_prefs"
|
||||
private const val KEY_TOKEN = "auth_token"
|
||||
|
||||
@Volatile
|
||||
private var instance: TokenManager? = null
|
||||
|
||||
fun getInstance(context: Context): TokenManager {
|
||||
return instance ?: synchronized(this) {
|
||||
instance ?: TokenManager(context.applicationContext).also { instance = it }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates EncryptedSharedPreferences backed by an AES256-GCM master key.
|
||||
* If initialization fails (broken Keystore, unsupported device, etc.),
|
||||
* falls back to plain SharedPreferences with a warning log.
|
||||
*/
|
||||
private fun createEncryptedPrefs(context: Context): SharedPreferences {
|
||||
return try {
|
||||
val masterKey = MasterKey.Builder(context)
|
||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||
.build()
|
||||
|
||||
EncryptedSharedPreferences.create(
|
||||
context,
|
||||
ENCRYPTED_PREFS_NAME,
|
||||
masterKey,
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.w(
|
||||
TAG,
|
||||
"Failed to create EncryptedSharedPreferences, falling back to plain SharedPreferences. " +
|
||||
"Auth tokens will NOT be encrypted at rest on this device.",
|
||||
e
|
||||
)
|
||||
context.getSharedPreferences(FALLBACK_PREFS_NAME, Context.MODE_PRIVATE)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.tt.honeyDue.storage
|
||||
|
||||
internal actual fun getPlatformTokenManager(): TokenManager? {
|
||||
// Android requires context, so must use initialize() method
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
package com.tt.honeyDue.ui.components
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import com.tt.honeyDue.models.Contractor
|
||||
import com.tt.honeyDue.models.SharedContractor
|
||||
import com.tt.honeyDue.network.ApiResult
|
||||
import com.tt.honeyDue.sharing.ContractorSharingManager
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
/**
|
||||
* Represents the current state of the contractor import flow.
|
||||
*/
|
||||
sealed class ImportState {
|
||||
data object Idle : ImportState()
|
||||
data class Confirmation(val sharedContractor: SharedContractor) : ImportState()
|
||||
data class Importing(val sharedContractor: SharedContractor) : ImportState()
|
||||
data class Success(val contractorName: String) : ImportState()
|
||||
data class Error(val message: String) : ImportState()
|
||||
}
|
||||
|
||||
/**
|
||||
* Android-specific composable that handles the contractor import flow.
|
||||
* Shows confirmation dialog, performs import, and displays result.
|
||||
*
|
||||
* @param pendingImportUri The URI of the .honeydue file to import (or null if none)
|
||||
* @param onClearImport Called when import flow is complete and URI should be cleared
|
||||
* @param onImportSuccess Called when import succeeds, with the imported contractor
|
||||
*/
|
||||
@Composable
|
||||
fun ContractorImportHandler(
|
||||
pendingImportUri: Uri?,
|
||||
onClearImport: () -> Unit,
|
||||
onImportSuccess: (Contractor) -> Unit = {}
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
var importState by remember { mutableStateOf<ImportState>(ImportState.Idle) }
|
||||
var pendingUri by remember { mutableStateOf<Uri?>(null) }
|
||||
var importedContractor by remember { mutableStateOf<Contractor?>(null) }
|
||||
|
||||
val json = remember {
|
||||
Json {
|
||||
ignoreUnknownKeys = true
|
||||
encodeDefaults = true
|
||||
}
|
||||
}
|
||||
|
||||
// Parse the .honeydue file when a new URI is received
|
||||
LaunchedEffect(pendingImportUri) {
|
||||
if (pendingImportUri != null && importState is ImportState.Idle) {
|
||||
pendingUri = pendingImportUri
|
||||
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val inputStream = context.contentResolver.openInputStream(pendingImportUri)
|
||||
if (inputStream != null) {
|
||||
val jsonString = inputStream.bufferedReader().use { it.readText() }
|
||||
inputStream.close()
|
||||
|
||||
val sharedContractor = json.decodeFromString(
|
||||
SharedContractor.serializer(),
|
||||
jsonString
|
||||
)
|
||||
withContext(Dispatchers.Main) {
|
||||
importState = ImportState.Confirmation(sharedContractor)
|
||||
}
|
||||
} else {
|
||||
withContext(Dispatchers.Main) {
|
||||
importState = ImportState.Error("Could not open file")
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
withContext(Dispatchers.Main) {
|
||||
importState = ImportState.Error("Invalid contractor file: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Show appropriate dialog based on state
|
||||
when (val state = importState) {
|
||||
is ImportState.Idle -> {
|
||||
// No dialog
|
||||
}
|
||||
|
||||
is ImportState.Confirmation -> {
|
||||
ContractorImportConfirmDialog(
|
||||
sharedContractor = state.sharedContractor,
|
||||
isImporting = false,
|
||||
onConfirm = {
|
||||
importState = ImportState.Importing(state.sharedContractor)
|
||||
scope.launch {
|
||||
pendingUri?.let { uri ->
|
||||
when (val result = ContractorSharingManager.importContractor(context, uri)) {
|
||||
is ApiResult.Success -> {
|
||||
importedContractor = result.data
|
||||
importState = ImportState.Success(result.data.name)
|
||||
}
|
||||
is ApiResult.Error -> {
|
||||
importState = ImportState.Error(result.message)
|
||||
}
|
||||
else -> {
|
||||
importState = ImportState.Error("Import failed unexpectedly")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
onDismiss = {
|
||||
importState = ImportState.Idle
|
||||
pendingUri = null
|
||||
onClearImport()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
is ImportState.Importing -> {
|
||||
// Show the confirmation dialog with loading state
|
||||
ContractorImportConfirmDialog(
|
||||
sharedContractor = state.sharedContractor,
|
||||
isImporting = true,
|
||||
onConfirm = {},
|
||||
onDismiss = {}
|
||||
)
|
||||
}
|
||||
|
||||
is ImportState.Success -> {
|
||||
ContractorImportSuccessDialog(
|
||||
contractorName = state.contractorName,
|
||||
onDismiss = {
|
||||
importedContractor?.let { onImportSuccess(it) }
|
||||
importState = ImportState.Idle
|
||||
pendingUri = null
|
||||
importedContractor = null
|
||||
onClearImport()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
is ImportState.Error -> {
|
||||
ContractorImportErrorDialog(
|
||||
errorMessage = state.message,
|
||||
onRetry = pendingUri?.let { uri ->
|
||||
{
|
||||
// Retry by re-parsing the file
|
||||
scope.launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val inputStream = context.contentResolver.openInputStream(uri)
|
||||
if (inputStream != null) {
|
||||
val jsonString = inputStream.bufferedReader().use { it.readText() }
|
||||
inputStream.close()
|
||||
val sharedContractor = json.decodeFromString(
|
||||
SharedContractor.serializer(),
|
||||
jsonString
|
||||
)
|
||||
withContext(Dispatchers.Main) {
|
||||
importState = ImportState.Confirmation(sharedContractor)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// Keep showing error
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
onDismiss = {
|
||||
importState = ImportState.Idle
|
||||
pendingUri = null
|
||||
onClearImport()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
package com.tt.honeyDue.ui.components
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import com.tt.honeyDue.models.JoinResidenceResponse
|
||||
import com.tt.honeyDue.models.SharedResidence
|
||||
import com.tt.honeyDue.network.ApiResult
|
||||
import com.tt.honeyDue.sharing.ResidenceSharingManager
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
/**
|
||||
* Represents the current state of the residence import flow.
|
||||
*/
|
||||
sealed class ResidenceImportState {
|
||||
data object Idle : ResidenceImportState()
|
||||
data class Confirmation(val sharedResidence: SharedResidence) : ResidenceImportState()
|
||||
data class Importing(val sharedResidence: SharedResidence) : ResidenceImportState()
|
||||
data class Success(val residenceName: String) : ResidenceImportState()
|
||||
data class Error(val message: String) : ResidenceImportState()
|
||||
}
|
||||
|
||||
/**
|
||||
* Android-specific composable that handles the residence import flow.
|
||||
* Shows confirmation dialog, performs import, and displays result.
|
||||
*
|
||||
* @param pendingImportUri The URI of the .honeydue file to import (or null if none)
|
||||
* @param onClearImport Called when import flow is complete and URI should be cleared
|
||||
* @param onImportSuccess Called when import succeeds, with the join response
|
||||
*/
|
||||
@Composable
|
||||
fun ResidenceImportHandler(
|
||||
pendingImportUri: Uri?,
|
||||
onClearImport: () -> Unit,
|
||||
onImportSuccess: (JoinResidenceResponse) -> Unit = {}
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
var importState by remember { mutableStateOf<ResidenceImportState>(ResidenceImportState.Idle) }
|
||||
var pendingUri by remember { mutableStateOf<Uri?>(null) }
|
||||
var importedResponse by remember { mutableStateOf<JoinResidenceResponse?>(null) }
|
||||
|
||||
val json = remember {
|
||||
Json {
|
||||
ignoreUnknownKeys = true
|
||||
encodeDefaults = true
|
||||
}
|
||||
}
|
||||
|
||||
// Parse the .honeydue file when a new URI is received
|
||||
LaunchedEffect(pendingImportUri) {
|
||||
if (pendingImportUri != null && importState is ResidenceImportState.Idle) {
|
||||
pendingUri = pendingImportUri
|
||||
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val inputStream = context.contentResolver.openInputStream(pendingImportUri)
|
||||
if (inputStream != null) {
|
||||
val jsonString = inputStream.bufferedReader().use { it.readText() }
|
||||
inputStream.close()
|
||||
|
||||
val sharedResidence = json.decodeFromString(
|
||||
SharedResidence.serializer(),
|
||||
jsonString
|
||||
)
|
||||
withContext(Dispatchers.Main) {
|
||||
importState = ResidenceImportState.Confirmation(sharedResidence)
|
||||
}
|
||||
} else {
|
||||
withContext(Dispatchers.Main) {
|
||||
importState = ResidenceImportState.Error("Could not open file")
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
withContext(Dispatchers.Main) {
|
||||
importState = ResidenceImportState.Error("Invalid residence file: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Show appropriate dialog based on state
|
||||
when (val state = importState) {
|
||||
is ResidenceImportState.Idle -> {
|
||||
// No dialog
|
||||
}
|
||||
|
||||
is ResidenceImportState.Confirmation -> {
|
||||
ResidenceImportConfirmDialog(
|
||||
sharedResidence = state.sharedResidence,
|
||||
isImporting = false,
|
||||
onConfirm = {
|
||||
importState = ResidenceImportState.Importing(state.sharedResidence)
|
||||
scope.launch {
|
||||
pendingUri?.let { uri ->
|
||||
when (val result = ResidenceSharingManager.importResidence(context, uri)) {
|
||||
is ApiResult.Success -> {
|
||||
importedResponse = result.data
|
||||
importState = ResidenceImportState.Success(result.data.residence.name)
|
||||
}
|
||||
is ApiResult.Error -> {
|
||||
importState = ResidenceImportState.Error(result.message)
|
||||
}
|
||||
else -> {
|
||||
importState = ResidenceImportState.Error("Import failed unexpectedly")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
onDismiss = {
|
||||
importState = ResidenceImportState.Idle
|
||||
pendingUri = null
|
||||
onClearImport()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
is ResidenceImportState.Importing -> {
|
||||
// Show the confirmation dialog with loading state
|
||||
ResidenceImportConfirmDialog(
|
||||
sharedResidence = state.sharedResidence,
|
||||
isImporting = true,
|
||||
onConfirm = {},
|
||||
onDismiss = {}
|
||||
)
|
||||
}
|
||||
|
||||
is ResidenceImportState.Success -> {
|
||||
ResidenceImportSuccessDialog(
|
||||
residenceName = state.residenceName,
|
||||
onDismiss = {
|
||||
importedResponse?.let { onImportSuccess(it) }
|
||||
importState = ResidenceImportState.Idle
|
||||
pendingUri = null
|
||||
importedResponse = null
|
||||
onClearImport()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
is ResidenceImportState.Error -> {
|
||||
ResidenceImportErrorDialog(
|
||||
errorMessage = state.message,
|
||||
onRetry = pendingUri?.let { uri ->
|
||||
{
|
||||
// Retry by re-parsing the file
|
||||
scope.launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val inputStream = context.contentResolver.openInputStream(uri)
|
||||
if (inputStream != null) {
|
||||
val jsonString = inputStream.bufferedReader().use { it.readText() }
|
||||
inputStream.close()
|
||||
val sharedResidence = json.decodeFromString(
|
||||
SharedResidence.serializer(),
|
||||
jsonString
|
||||
)
|
||||
withContext(Dispatchers.Main) {
|
||||
importState = ResidenceImportState.Confirmation(sharedResidence)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// Keep showing error
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
onDismiss = {
|
||||
importState = ResidenceImportState.Idle
|
||||
pendingUri = null
|
||||
onClearImport()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package com.tt.honeyDue.ui.components.auth
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
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.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.tt.honeyDue.auth.GoogleSignInManager
|
||||
import com.tt.honeyDue.auth.GoogleSignInResult
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
actual fun GoogleSignInButton(
|
||||
onSignInStarted: () -> Unit,
|
||||
onSignInSuccess: (idToken: String) -> Unit,
|
||||
onSignInError: (message: String) -> Unit,
|
||||
enabled: Boolean
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
var isLoading by remember { mutableStateOf(false) }
|
||||
|
||||
val googleSignInManager = remember { GoogleSignInManager(context) }
|
||||
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
if (!isLoading && enabled) {
|
||||
isLoading = true
|
||||
onSignInStarted()
|
||||
|
||||
scope.launch {
|
||||
when (val result = googleSignInManager.signIn()) {
|
||||
is GoogleSignInResult.Success -> {
|
||||
isLoading = false
|
||||
onSignInSuccess(result.idToken)
|
||||
}
|
||||
is GoogleSignInResult.Error -> {
|
||||
isLoading = false
|
||||
onSignInError(result.message)
|
||||
}
|
||||
GoogleSignInResult.Cancelled -> {
|
||||
isLoading = false
|
||||
// User cancelled, no error needed
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(56.dp),
|
||||
enabled = enabled && !isLoading,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline),
|
||||
colors = ButtonDefaults.outlinedButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface
|
||||
)
|
||||
) {
|
||||
if (isLoading) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(24.dp),
|
||||
strokeWidth = 2.dp
|
||||
)
|
||||
} else {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
// Google "G" logo using Material colors
|
||||
Box(
|
||||
modifier = Modifier.size(24.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = "G",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Color(0xFF4285F4) // Google Blue
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
|
||||
Text(
|
||||
text = "Continue with Google",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,422 @@
|
||||
package com.tt.honeyDue.ui.subscription
|
||||
|
||||
import android.app.Activity
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
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.*
|
||||
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.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.android.billingclient.api.ProductDetails
|
||||
import com.tt.honeyDue.data.DataManager
|
||||
import com.tt.honeyDue.platform.BillingManager
|
||||
import com.tt.honeyDue.ui.theme.AppSpacing
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Android-specific upgrade screen that connects to Google Play Billing.
|
||||
* This version shows real product pricing from Google Play Console.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun UpgradeFeatureScreenAndroid(
|
||||
triggerKey: String,
|
||||
icon: ImageVector,
|
||||
billingManager: BillingManager,
|
||||
onNavigateBack: () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val activity = context as? Activity
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
var showFeatureComparison by remember { mutableStateOf(false) }
|
||||
var selectedProductId by remember { mutableStateOf<String?>(null) }
|
||||
var showSuccessAlert by remember { mutableStateOf(false) }
|
||||
|
||||
// Observe billing manager state
|
||||
val isLoading by billingManager.isLoading.collectAsState()
|
||||
val products by billingManager.products.collectAsState()
|
||||
val purchaseError by billingManager.purchaseError.collectAsState()
|
||||
val purchasedProductIDs by billingManager.purchasedProductIDs.collectAsState()
|
||||
|
||||
// Look up trigger data from cache
|
||||
val triggerData by remember { derivedStateOf {
|
||||
DataManager.upgradeTriggers.value[triggerKey]
|
||||
} }
|
||||
|
||||
// Fallback values if trigger not found
|
||||
val title = triggerData?.title ?: "Upgrade Required"
|
||||
val message = triggerData?.message ?: "This feature is available with a Pro subscription."
|
||||
|
||||
// Load products on launch
|
||||
LaunchedEffect(Unit) {
|
||||
billingManager.loadProducts()
|
||||
}
|
||||
|
||||
// Check for successful purchase
|
||||
LaunchedEffect(purchasedProductIDs) {
|
||||
if (purchasedProductIDs.isNotEmpty()) {
|
||||
showSuccessAlert = true
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(title, fontWeight = FontWeight.SemiBold) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onNavigateBack) {
|
||||
Icon(Icons.Default.ArrowBack, "Back")
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface
|
||||
)
|
||||
)
|
||||
}
|
||||
) { paddingValues ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Spacer(Modifier.height(AppSpacing.xl))
|
||||
|
||||
// Feature Icon
|
||||
Icon(
|
||||
imageVector = Icons.Default.Stars,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(80.dp),
|
||||
tint = MaterialTheme.colorScheme.tertiary
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(AppSpacing.lg))
|
||||
|
||||
// Title
|
||||
Text(
|
||||
title,
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(horizontal = AppSpacing.lg)
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(AppSpacing.md))
|
||||
|
||||
// Description
|
||||
Text(
|
||||
message,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(horizontal = AppSpacing.lg)
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(AppSpacing.xl))
|
||||
|
||||
// Pro Features Preview Card
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = AppSpacing.lg),
|
||||
shape = MaterialTheme.shapes.large,
|
||||
color = MaterialTheme.colorScheme.surfaceVariant
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(AppSpacing.lg),
|
||||
verticalArrangement = Arrangement.spacedBy(AppSpacing.md)
|
||||
) {
|
||||
FeatureRowAndroid(Icons.Default.Home, "Unlimited properties")
|
||||
FeatureRowAndroid(Icons.Default.CheckCircle, "Unlimited tasks")
|
||||
FeatureRowAndroid(Icons.Default.People, "Contractor management")
|
||||
FeatureRowAndroid(Icons.Default.Description, "Document & warranty storage")
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(AppSpacing.xl))
|
||||
|
||||
// Subscription Products Section
|
||||
if (isLoading && products.isEmpty()) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.padding(AppSpacing.lg),
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
} else if (products.isNotEmpty()) {
|
||||
// Calculate savings for annual
|
||||
val monthlyProduct = billingManager.getMonthlyProduct()
|
||||
val annualProduct = billingManager.getAnnualProduct()
|
||||
val annualSavings = billingManager.calculateAnnualSavings(monthlyProduct, annualProduct)
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = AppSpacing.lg),
|
||||
verticalArrangement = Arrangement.spacedBy(AppSpacing.md)
|
||||
) {
|
||||
products.forEach { product ->
|
||||
val isAnnual = product.productId.contains("annual")
|
||||
val savingsBadge = if (isAnnual && annualSavings != null) {
|
||||
"Save $annualSavings%"
|
||||
} else null
|
||||
|
||||
SubscriptionProductCardAndroid(
|
||||
productDetails = product,
|
||||
formattedPrice = billingManager.getFormattedPrice(product) ?: "Loading...",
|
||||
savingsBadge = savingsBadge,
|
||||
isSelected = selectedProductId == product.productId,
|
||||
isProcessing = isLoading && selectedProductId == product.productId,
|
||||
onSelect = {
|
||||
selectedProductId = product.productId
|
||||
activity?.let { act ->
|
||||
billingManager.launchPurchaseFlow(
|
||||
activity = act,
|
||||
productDetails = product,
|
||||
onSuccess = { showSuccessAlert = true },
|
||||
onError = { /* Error shown via purchaseError flow */ }
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Fallback if no products loaded
|
||||
Button(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
billingManager.loadProducts()
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = AppSpacing.lg)
|
||||
) {
|
||||
Text("Retry Loading Products")
|
||||
}
|
||||
}
|
||||
|
||||
// Error Message
|
||||
purchaseError?.let { error ->
|
||||
Spacer(Modifier.height(AppSpacing.md))
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = AppSpacing.lg),
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
color = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.3f)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(AppSpacing.md),
|
||||
horizontalArrangement = Arrangement.spacedBy(AppSpacing.sm),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Warning,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.error
|
||||
)
|
||||
Text(
|
||||
error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
IconButton(
|
||||
onClick = { billingManager.clearError() },
|
||||
modifier = Modifier.size(24.dp)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Close,
|
||||
contentDescription = "Dismiss",
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(AppSpacing.lg))
|
||||
|
||||
// Compare Plans
|
||||
TextButton(onClick = { showFeatureComparison = true }) {
|
||||
Text("Compare Free vs Pro")
|
||||
}
|
||||
|
||||
// Restore Purchases
|
||||
TextButton(onClick = {
|
||||
scope.launch {
|
||||
val restored = billingManager.restorePurchases()
|
||||
if (restored) {
|
||||
showSuccessAlert = true
|
||||
}
|
||||
}
|
||||
}) {
|
||||
Text(
|
||||
"Restore Purchases",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(AppSpacing.xl * 2))
|
||||
}
|
||||
|
||||
if (showFeatureComparison) {
|
||||
FeatureComparisonDialog(
|
||||
onDismiss = { showFeatureComparison = false },
|
||||
onUpgrade = {
|
||||
showFeatureComparison = false
|
||||
// Select first product if available
|
||||
products.firstOrNull()?.let { product ->
|
||||
selectedProductId = product.productId
|
||||
activity?.let { act ->
|
||||
billingManager.launchPurchaseFlow(
|
||||
activity = act,
|
||||
productDetails = product,
|
||||
onSuccess = { showSuccessAlert = true },
|
||||
onError = { }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (showSuccessAlert) {
|
||||
AlertDialog(
|
||||
onDismissRequest = {
|
||||
showSuccessAlert = false
|
||||
onNavigateBack()
|
||||
},
|
||||
title = { Text("Subscription Active") },
|
||||
text = { Text("You now have full access to all Pro features!") },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
showSuccessAlert = false
|
||||
onNavigateBack()
|
||||
}) {
|
||||
Text("Done")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FeatureRowAndroid(icon: ImageVector, text: String) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(AppSpacing.md),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(24.dp),
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Text(
|
||||
text,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SubscriptionProductCardAndroid(
|
||||
productDetails: ProductDetails,
|
||||
formattedPrice: String,
|
||||
savingsBadge: String?,
|
||||
isSelected: Boolean,
|
||||
isProcessing: Boolean,
|
||||
onSelect: () -> Unit
|
||||
) {
|
||||
val isAnnual = productDetails.productId.contains("annual")
|
||||
val productName = if (isAnnual) "honeyDue Pro Annual" else "honeyDue Pro Monthly"
|
||||
val billingPeriod = if (isAnnual) "Billed annually" else "Billed monthly"
|
||||
|
||||
Card(
|
||||
onClick = onSelect,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = if (isSelected)
|
||||
MaterialTheme.colorScheme.primaryContainer
|
||||
else
|
||||
MaterialTheme.colorScheme.surface
|
||||
),
|
||||
border = if (isSelected)
|
||||
BorderStroke(2.dp, MaterialTheme.colorScheme.primary)
|
||||
else
|
||||
null
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(AppSpacing.lg),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(AppSpacing.sm),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
productName,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
savingsBadge?.let { badge ->
|
||||
Surface(
|
||||
shape = MaterialTheme.shapes.small,
|
||||
color = MaterialTheme.colorScheme.tertiaryContainer
|
||||
) {
|
||||
Text(
|
||||
badge,
|
||||
modifier = Modifier.padding(
|
||||
horizontal = AppSpacing.sm,
|
||||
vertical = 2.dp
|
||||
),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.tertiary,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Text(
|
||||
billingPeriod,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
if (isProcessing) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(24.dp),
|
||||
strokeWidth = 2.dp
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
formattedPrice,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.tt.honeyDue.util
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import com.tt.honeyDue.platform.ImageData
|
||||
import java.io.ByteArrayOutputStream
|
||||
|
||||
/**
|
||||
* Android implementation of image compression
|
||||
* Compresses images to JPEG format and ensures they don't exceed MAX_IMAGE_SIZE_BYTES
|
||||
*/
|
||||
actual object ImageCompressor {
|
||||
/**
|
||||
* Compress an ImageData to JPEG format with size limit
|
||||
* @param imageData The image to compress
|
||||
* @return Compressed image data as ByteArray
|
||||
*/
|
||||
actual fun compressImage(imageData: ImageData): ByteArray {
|
||||
// Decode the original image
|
||||
val originalBitmap = BitmapFactory.decodeByteArray(
|
||||
imageData.bytes,
|
||||
0,
|
||||
imageData.bytes.size
|
||||
)
|
||||
|
||||
// Compress with iterative quality reduction
|
||||
return compressBitmapToTarget(originalBitmap, ImageConfig.MAX_IMAGE_SIZE_BYTES)
|
||||
}
|
||||
|
||||
/**
|
||||
* Compress a bitmap to target size
|
||||
*/
|
||||
private fun compressBitmapToTarget(bitmap: Bitmap, targetSizeBytes: Int): ByteArray {
|
||||
var quality = ImageConfig.INITIAL_JPEG_QUALITY
|
||||
var compressedData: ByteArray
|
||||
|
||||
do {
|
||||
val outputStream = ByteArrayOutputStream()
|
||||
bitmap.compress(Bitmap.CompressFormat.JPEG, quality, outputStream)
|
||||
compressedData = outputStream.toByteArray()
|
||||
|
||||
// If size is acceptable or quality is too low, stop
|
||||
if (compressedData.size <= targetSizeBytes || quality <= ImageConfig.MIN_JPEG_QUALITY) {
|
||||
break
|
||||
}
|
||||
|
||||
// Reduce quality for next iteration
|
||||
quality -= 5
|
||||
} while (true)
|
||||
|
||||
return compressedData
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
package com.tt.honeyDue.widget
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.glance.GlanceId
|
||||
import androidx.glance.GlanceModifier
|
||||
import androidx.glance.GlanceTheme
|
||||
import androidx.glance.action.ActionParameters
|
||||
import androidx.glance.action.actionParametersOf
|
||||
import androidx.glance.action.clickable
|
||||
import androidx.glance.appwidget.GlanceAppWidget
|
||||
import androidx.glance.appwidget.GlanceAppWidgetReceiver
|
||||
import androidx.glance.appwidget.action.ActionCallback
|
||||
import androidx.glance.appwidget.action.actionRunCallback
|
||||
import androidx.glance.appwidget.cornerRadius
|
||||
import androidx.glance.appwidget.lazy.LazyColumn
|
||||
import androidx.glance.appwidget.lazy.items
|
||||
import androidx.glance.appwidget.provideContent
|
||||
import androidx.glance.background
|
||||
import androidx.glance.currentState
|
||||
import androidx.glance.layout.Alignment
|
||||
import androidx.glance.layout.Box
|
||||
import androidx.glance.layout.Column
|
||||
import androidx.glance.layout.Row
|
||||
import androidx.glance.layout.Spacer
|
||||
import androidx.glance.layout.fillMaxSize
|
||||
import androidx.glance.layout.fillMaxWidth
|
||||
import androidx.glance.layout.height
|
||||
import androidx.glance.layout.padding
|
||||
import androidx.glance.layout.size
|
||||
import androidx.glance.layout.width
|
||||
import androidx.glance.state.GlanceStateDefinition
|
||||
import androidx.glance.state.PreferencesGlanceStateDefinition
|
||||
import androidx.glance.text.FontWeight
|
||||
import androidx.glance.text.Text
|
||||
import androidx.glance.text.TextStyle
|
||||
import androidx.glance.unit.ColorProvider
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.intPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
/**
|
||||
* Large widget showing task list with stats and interactive actions (Pro only)
|
||||
* Size: 4x4
|
||||
*/
|
||||
class HoneyDueLargeWidget : GlanceAppWidget() {
|
||||
|
||||
override val stateDefinition: GlanceStateDefinition<*> = PreferencesGlanceStateDefinition
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
||||
provideContent {
|
||||
GlanceTheme {
|
||||
LargeWidgetContent()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LargeWidgetContent() {
|
||||
val prefs = currentState<Preferences>()
|
||||
val overdueCount = prefs[intPreferencesKey("overdue_count")] ?: 0
|
||||
val dueSoonCount = prefs[intPreferencesKey("due_soon_count")] ?: 0
|
||||
val inProgressCount = prefs[intPreferencesKey("in_progress_count")] ?: 0
|
||||
val totalCount = prefs[intPreferencesKey("total_tasks_count")] ?: 0
|
||||
val tasksJson = prefs[stringPreferencesKey("tasks_json")] ?: "[]"
|
||||
val isProUser = prefs[stringPreferencesKey("is_pro_user")] == "true"
|
||||
|
||||
val tasks = try {
|
||||
json.decodeFromString<List<WidgetTask>>(tasksJson).take(8)
|
||||
} catch (e: Exception) {
|
||||
emptyList()
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = GlanceModifier
|
||||
.fillMaxSize()
|
||||
.background(Color(0xFFFFF8E7)) // Cream background
|
||||
.padding(16.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = GlanceModifier.fillMaxSize()
|
||||
) {
|
||||
// Header with logo
|
||||
Row(
|
||||
modifier = GlanceModifier
|
||||
.fillMaxWidth()
|
||||
.clickable(actionRunCallback<OpenAppAction>()),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = "honeyDue",
|
||||
style = TextStyle(
|
||||
color = ColorProvider(Color(0xFF07A0C3)),
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
)
|
||||
|
||||
Spacer(modifier = GlanceModifier.width(8.dp))
|
||||
|
||||
Text(
|
||||
text = "Tasks",
|
||||
style = TextStyle(
|
||||
color = ColorProvider(Color(0xFF666666)),
|
||||
fontSize = 14.sp
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = GlanceModifier.height(12.dp))
|
||||
|
||||
// Stats row
|
||||
Row(
|
||||
modifier = GlanceModifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
StatBox(
|
||||
count = overdueCount,
|
||||
label = "Overdue",
|
||||
color = Color(0xFFDD1C1A),
|
||||
bgColor = Color(0xFFFFEBEB)
|
||||
)
|
||||
|
||||
Spacer(modifier = GlanceModifier.width(8.dp))
|
||||
|
||||
StatBox(
|
||||
count = dueSoonCount,
|
||||
label = "Due Soon",
|
||||
color = Color(0xFFF5A623),
|
||||
bgColor = Color(0xFFFFF4E0)
|
||||
)
|
||||
|
||||
Spacer(modifier = GlanceModifier.width(8.dp))
|
||||
|
||||
StatBox(
|
||||
count = inProgressCount,
|
||||
label = "Active",
|
||||
color = Color(0xFF07A0C3),
|
||||
bgColor = Color(0xFFE0F4F8)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = GlanceModifier.height(12.dp))
|
||||
|
||||
// Divider
|
||||
Box(
|
||||
modifier = GlanceModifier
|
||||
.fillMaxWidth()
|
||||
.height(1.dp)
|
||||
.background(Color(0xFFE0E0E0))
|
||||
) {}
|
||||
|
||||
Spacer(modifier = GlanceModifier.height(8.dp))
|
||||
|
||||
// Task list
|
||||
if (tasks.isEmpty()) {
|
||||
Box(
|
||||
modifier = GlanceModifier
|
||||
.fillMaxSize()
|
||||
.clickable(actionRunCallback<OpenAppAction>()),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
text = "All caught up!",
|
||||
style = TextStyle(
|
||||
color = ColorProvider(Color(0xFF07A0C3)),
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
)
|
||||
Text(
|
||||
text = "No tasks need attention",
|
||||
style = TextStyle(
|
||||
color = ColorProvider(Color(0xFF888888)),
|
||||
fontSize = 12.sp
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = GlanceModifier.fillMaxSize()
|
||||
) {
|
||||
items(tasks) { task ->
|
||||
InteractiveTaskItem(
|
||||
task = task,
|
||||
isProUser = isProUser
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StatBox(count: Int, label: String, color: Color, bgColor: Color) {
|
||||
Box(
|
||||
modifier = GlanceModifier
|
||||
.background(bgColor)
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp)
|
||||
.cornerRadius(8.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
text = count.toString(),
|
||||
style = TextStyle(
|
||||
color = ColorProvider(color),
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
)
|
||||
Text(
|
||||
text = label,
|
||||
style = TextStyle(
|
||||
color = ColorProvider(color),
|
||||
fontSize = 10.sp
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InteractiveTaskItem(task: WidgetTask, isProUser: Boolean) {
|
||||
val taskIdKey = ActionParameters.Key<Int>("task_id")
|
||||
|
||||
Row(
|
||||
modifier = GlanceModifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 6.dp)
|
||||
.clickable(
|
||||
actionRunCallback<OpenTaskAction>(
|
||||
actionParametersOf(taskIdKey to task.id)
|
||||
)
|
||||
),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// Priority indicator
|
||||
Box(
|
||||
modifier = GlanceModifier
|
||||
.width(4.dp)
|
||||
.height(40.dp)
|
||||
.background(getPriorityColor(task.priorityLevel))
|
||||
) {}
|
||||
|
||||
Spacer(modifier = GlanceModifier.width(8.dp))
|
||||
|
||||
// Task details
|
||||
Column(
|
||||
modifier = GlanceModifier.defaultWeight()
|
||||
) {
|
||||
Text(
|
||||
text = task.title,
|
||||
style = TextStyle(
|
||||
color = ColorProvider(Color(0xFF1A1A1A)),
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium
|
||||
),
|
||||
maxLines = 1
|
||||
)
|
||||
Row {
|
||||
Text(
|
||||
text = task.residenceName,
|
||||
style = TextStyle(
|
||||
color = ColorProvider(Color(0xFF666666)),
|
||||
fontSize = 11.sp
|
||||
),
|
||||
maxLines = 1
|
||||
)
|
||||
if (task.dueDate != null) {
|
||||
Text(
|
||||
text = " • ${task.dueDate}",
|
||||
style = TextStyle(
|
||||
color = ColorProvider(
|
||||
if (task.isOverdue) Color(0xFFDD1C1A) else Color(0xFF666666)
|
||||
),
|
||||
fontSize = 11.sp
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Action button (Pro only)
|
||||
if (isProUser) {
|
||||
Box(
|
||||
modifier = GlanceModifier
|
||||
.size(32.dp)
|
||||
.background(Color(0xFF07A0C3))
|
||||
.cornerRadius(16.dp)
|
||||
.clickable(
|
||||
actionRunCallback<CompleteTaskAction>(
|
||||
actionParametersOf(taskIdKey to task.id)
|
||||
)
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = "✓",
|
||||
style = TextStyle(
|
||||
color = ColorProvider(Color.White),
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getPriorityColor(level: Int): Color {
|
||||
return when (level) {
|
||||
4 -> Color(0xFFDD1C1A) // Urgent - Red
|
||||
3 -> Color(0xFFF5A623) // High - Amber
|
||||
2 -> Color(0xFF07A0C3) // Medium - Primary
|
||||
else -> Color(0xFF888888) // Low - Gray
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Action to complete a task from the widget (Pro only)
|
||||
*/
|
||||
class CompleteTaskAction : ActionCallback {
|
||||
override suspend fun onAction(
|
||||
context: Context,
|
||||
glanceId: GlanceId,
|
||||
parameters: ActionParameters
|
||||
) {
|
||||
val taskId = parameters[ActionParameters.Key<Int>("task_id")] ?: return
|
||||
|
||||
// Send broadcast to app to complete the task
|
||||
val intent = Intent("com.tt.honeyDue.COMPLETE_TASK").apply {
|
||||
putExtra("task_id", taskId)
|
||||
setPackage(context.packageName)
|
||||
}
|
||||
context.sendBroadcast(intent)
|
||||
|
||||
// Update widget after action
|
||||
withContext(Dispatchers.Main) {
|
||||
HoneyDueLargeWidget().update(context, glanceId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Receiver for the large widget
|
||||
*/
|
||||
class HoneyDueLargeWidgetReceiver : GlanceAppWidgetReceiver() {
|
||||
override val glanceAppWidget: GlanceAppWidget = HoneyDueLargeWidget()
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
package com.tt.honeyDue.widget
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.glance.GlanceId
|
||||
import androidx.glance.GlanceModifier
|
||||
import androidx.glance.GlanceTheme
|
||||
import androidx.glance.action.ActionParameters
|
||||
import androidx.glance.action.actionParametersOf
|
||||
import androidx.glance.action.clickable
|
||||
import androidx.glance.appwidget.GlanceAppWidget
|
||||
import androidx.glance.appwidget.GlanceAppWidgetReceiver
|
||||
import androidx.glance.appwidget.action.ActionCallback
|
||||
import androidx.glance.appwidget.action.actionRunCallback
|
||||
import androidx.glance.appwidget.lazy.LazyColumn
|
||||
import androidx.glance.appwidget.lazy.items
|
||||
import androidx.glance.appwidget.provideContent
|
||||
import androidx.glance.background
|
||||
import androidx.glance.currentState
|
||||
import androidx.glance.layout.Alignment
|
||||
import androidx.glance.layout.Box
|
||||
import androidx.glance.layout.Column
|
||||
import androidx.glance.layout.Row
|
||||
import androidx.glance.layout.Spacer
|
||||
import androidx.glance.layout.fillMaxSize
|
||||
import androidx.glance.layout.fillMaxWidth
|
||||
import androidx.glance.layout.height
|
||||
import androidx.glance.layout.padding
|
||||
import androidx.glance.layout.width
|
||||
import androidx.glance.state.GlanceStateDefinition
|
||||
import androidx.glance.state.PreferencesGlanceStateDefinition
|
||||
import androidx.glance.text.FontWeight
|
||||
import androidx.glance.text.Text
|
||||
import androidx.glance.text.TextStyle
|
||||
import androidx.glance.unit.ColorProvider
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.intPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
/**
|
||||
* Medium widget showing a list of upcoming tasks
|
||||
* Size: 4x2
|
||||
*/
|
||||
class HoneyDueMediumWidget : GlanceAppWidget() {
|
||||
|
||||
override val stateDefinition: GlanceStateDefinition<*> = PreferencesGlanceStateDefinition
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
||||
provideContent {
|
||||
GlanceTheme {
|
||||
MediumWidgetContent()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MediumWidgetContent() {
|
||||
val prefs = currentState<Preferences>()
|
||||
val overdueCount = prefs[intPreferencesKey("overdue_count")] ?: 0
|
||||
val dueSoonCount = prefs[intPreferencesKey("due_soon_count")] ?: 0
|
||||
val tasksJson = prefs[stringPreferencesKey("tasks_json")] ?: "[]"
|
||||
|
||||
val tasks = try {
|
||||
json.decodeFromString<List<WidgetTask>>(tasksJson).take(5)
|
||||
} catch (e: Exception) {
|
||||
emptyList()
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = GlanceModifier
|
||||
.fillMaxSize()
|
||||
.background(Color(0xFFFFF8E7)) // Cream background
|
||||
.padding(12.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = GlanceModifier.fillMaxSize()
|
||||
) {
|
||||
// Header
|
||||
Row(
|
||||
modifier = GlanceModifier
|
||||
.fillMaxWidth()
|
||||
.clickable(actionRunCallback<OpenAppAction>()),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = "honeyDue",
|
||||
style = TextStyle(
|
||||
color = ColorProvider(Color(0xFF07A0C3)),
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
)
|
||||
|
||||
Spacer(modifier = GlanceModifier.width(8.dp))
|
||||
|
||||
// Badge for overdue
|
||||
if (overdueCount > 0) {
|
||||
Box(
|
||||
modifier = GlanceModifier
|
||||
.background(Color(0xFFDD1C1A))
|
||||
.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = "$overdueCount overdue",
|
||||
style = TextStyle(
|
||||
color = ColorProvider(Color.White),
|
||||
fontSize = 10.sp,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = GlanceModifier.height(8.dp))
|
||||
|
||||
// Task list
|
||||
if (tasks.isEmpty()) {
|
||||
Box(
|
||||
modifier = GlanceModifier
|
||||
.fillMaxSize()
|
||||
.clickable(actionRunCallback<OpenAppAction>()),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = "No upcoming tasks",
|
||||
style = TextStyle(
|
||||
color = ColorProvider(Color(0xFF888888)),
|
||||
fontSize = 14.sp
|
||||
)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = GlanceModifier.fillMaxSize()
|
||||
) {
|
||||
items(tasks) { task ->
|
||||
TaskListItem(task = task)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TaskListItem(task: WidgetTask) {
|
||||
val taskIdKey = ActionParameters.Key<Int>("task_id")
|
||||
|
||||
Row(
|
||||
modifier = GlanceModifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp)
|
||||
.clickable(
|
||||
actionRunCallback<OpenTaskAction>(
|
||||
actionParametersOf(taskIdKey to task.id)
|
||||
)
|
||||
),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// Priority indicator
|
||||
Box(
|
||||
modifier = GlanceModifier
|
||||
.width(4.dp)
|
||||
.height(32.dp)
|
||||
.background(getPriorityColor(task.priorityLevel))
|
||||
) {}
|
||||
|
||||
Spacer(modifier = GlanceModifier.width(8.dp))
|
||||
|
||||
Column(
|
||||
modifier = GlanceModifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
text = task.title,
|
||||
style = TextStyle(
|
||||
color = ColorProvider(Color(0xFF1A1A1A)),
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.Medium
|
||||
),
|
||||
maxLines = 1
|
||||
)
|
||||
Row {
|
||||
Text(
|
||||
text = task.residenceName,
|
||||
style = TextStyle(
|
||||
color = ColorProvider(Color(0xFF666666)),
|
||||
fontSize = 11.sp
|
||||
),
|
||||
maxLines = 1
|
||||
)
|
||||
if (task.dueDate != null) {
|
||||
Text(
|
||||
text = " • ${task.dueDate}",
|
||||
style = TextStyle(
|
||||
color = ColorProvider(
|
||||
if (task.isOverdue) Color(0xFFDD1C1A) else Color(0xFF666666)
|
||||
),
|
||||
fontSize = 11.sp
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getPriorityColor(level: Int): Color {
|
||||
return when (level) {
|
||||
4 -> Color(0xFFDD1C1A) // Urgent - Red
|
||||
3 -> Color(0xFFF5A623) // High - Amber
|
||||
2 -> Color(0xFF07A0C3) // Medium - Primary
|
||||
else -> Color(0xFF888888) // Low - Gray
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Action to open a specific task
|
||||
*/
|
||||
class OpenTaskAction : ActionCallback {
|
||||
override suspend fun onAction(
|
||||
context: Context,
|
||||
glanceId: GlanceId,
|
||||
parameters: ActionParameters
|
||||
) {
|
||||
val taskId = parameters[ActionParameters.Key<Int>("task_id")]
|
||||
val intent = context.packageManager.getLaunchIntentForPackage(context.packageName)
|
||||
intent?.let {
|
||||
it.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
if (taskId != null) {
|
||||
it.putExtra("navigate_to_task", taskId)
|
||||
}
|
||||
context.startActivity(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Receiver for the medium widget
|
||||
*/
|
||||
class HoneyDueMediumWidgetReceiver : GlanceAppWidgetReceiver() {
|
||||
override val glanceAppWidget: GlanceAppWidget = HoneyDueMediumWidget()
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
package com.tt.honeyDue.widget
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.glance.GlanceId
|
||||
import androidx.glance.GlanceModifier
|
||||
import androidx.glance.GlanceTheme
|
||||
import androidx.glance.Image
|
||||
import androidx.glance.ImageProvider
|
||||
import androidx.glance.action.ActionParameters
|
||||
import androidx.glance.action.clickable
|
||||
import androidx.glance.appwidget.GlanceAppWidget
|
||||
import androidx.glance.appwidget.GlanceAppWidgetReceiver
|
||||
import androidx.glance.appwidget.action.ActionCallback
|
||||
import androidx.glance.appwidget.action.actionRunCallback
|
||||
import androidx.glance.appwidget.provideContent
|
||||
import androidx.glance.background
|
||||
import androidx.glance.currentState
|
||||
import androidx.glance.layout.Alignment
|
||||
import androidx.glance.layout.Box
|
||||
import androidx.glance.layout.Column
|
||||
import androidx.glance.layout.Row
|
||||
import androidx.glance.layout.Spacer
|
||||
import androidx.glance.layout.fillMaxSize
|
||||
import androidx.glance.layout.fillMaxWidth
|
||||
import androidx.glance.layout.height
|
||||
import androidx.glance.layout.padding
|
||||
import androidx.glance.layout.size
|
||||
import androidx.glance.layout.width
|
||||
import androidx.glance.state.GlanceStateDefinition
|
||||
import androidx.glance.state.PreferencesGlanceStateDefinition
|
||||
import androidx.glance.text.FontWeight
|
||||
import androidx.glance.text.Text
|
||||
import androidx.glance.text.TextStyle
|
||||
import androidx.glance.unit.ColorProvider
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.intPreferencesKey
|
||||
import com.tt.honeyDue.R
|
||||
|
||||
/**
|
||||
* Small widget showing task count summary
|
||||
* Size: 2x1 or 2x2
|
||||
*/
|
||||
class HoneyDueSmallWidget : GlanceAppWidget() {
|
||||
|
||||
override val stateDefinition: GlanceStateDefinition<*> = PreferencesGlanceStateDefinition
|
||||
|
||||
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
||||
provideContent {
|
||||
GlanceTheme {
|
||||
SmallWidgetContent()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SmallWidgetContent() {
|
||||
val prefs = currentState<Preferences>()
|
||||
val overdueCount = prefs[intPreferencesKey("overdue_count")] ?: 0
|
||||
val dueSoonCount = prefs[intPreferencesKey("due_soon_count")] ?: 0
|
||||
val inProgressCount = prefs[intPreferencesKey("in_progress_count")] ?: 0
|
||||
|
||||
Box(
|
||||
modifier = GlanceModifier
|
||||
.fillMaxSize()
|
||||
.background(Color(0xFFFFF8E7)) // Cream background
|
||||
.clickable(actionRunCallback<OpenAppAction>())
|
||||
.padding(12.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(
|
||||
modifier = GlanceModifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
// App name/logo
|
||||
Text(
|
||||
text = "honeyDue",
|
||||
style = TextStyle(
|
||||
color = ColorProvider(Color(0xFF07A0C3)),
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
)
|
||||
|
||||
Spacer(modifier = GlanceModifier.height(8.dp))
|
||||
|
||||
// Task counts row
|
||||
Row(
|
||||
modifier = GlanceModifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
// Overdue
|
||||
TaskCountItem(
|
||||
count = overdueCount,
|
||||
label = "Overdue",
|
||||
color = Color(0xFFDD1C1A) // Red
|
||||
)
|
||||
|
||||
Spacer(modifier = GlanceModifier.width(16.dp))
|
||||
|
||||
// Due Soon
|
||||
TaskCountItem(
|
||||
count = dueSoonCount,
|
||||
label = "Due Soon",
|
||||
color = Color(0xFFF5A623) // Amber
|
||||
)
|
||||
|
||||
Spacer(modifier = GlanceModifier.width(16.dp))
|
||||
|
||||
// In Progress
|
||||
TaskCountItem(
|
||||
count = inProgressCount,
|
||||
label = "Active",
|
||||
color = Color(0xFF07A0C3) // Primary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TaskCountItem(count: Int, label: String, color: Color) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
text = count.toString(),
|
||||
style = TextStyle(
|
||||
color = ColorProvider(color),
|
||||
fontSize = 24.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
)
|
||||
Text(
|
||||
text = label,
|
||||
style = TextStyle(
|
||||
color = ColorProvider(Color(0xFF666666)),
|
||||
fontSize = 10.sp
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Action to open the main app
|
||||
*/
|
||||
class OpenAppAction : ActionCallback {
|
||||
override suspend fun onAction(
|
||||
context: Context,
|
||||
glanceId: GlanceId,
|
||||
parameters: ActionParameters
|
||||
) {
|
||||
val intent = context.packageManager.getLaunchIntentForPackage(context.packageName)
|
||||
intent?.let {
|
||||
it.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
context.startActivity(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Receiver for the small widget
|
||||
*/
|
||||
class HoneyDueSmallWidgetReceiver : GlanceAppWidgetReceiver() {
|
||||
override val glanceAppWidget: GlanceAppWidget = HoneyDueSmallWidget()
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package com.tt.honeyDue.widget
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.intPreferencesKey
|
||||
import androidx.datastore.preferences.core.longPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
// DataStore instance
|
||||
private val Context.widgetDataStore: DataStore<Preferences> by preferencesDataStore(name = "widget_data")
|
||||
|
||||
/**
|
||||
* Data class representing a task for the widget
|
||||
*/
|
||||
@Serializable
|
||||
data class WidgetTask(
|
||||
val id: Int,
|
||||
val title: String,
|
||||
val residenceName: String,
|
||||
val dueDate: String?,
|
||||
val isOverdue: Boolean,
|
||||
val categoryName: String,
|
||||
val priorityLevel: Int
|
||||
)
|
||||
|
||||
/**
|
||||
* Data class representing widget summary data
|
||||
*/
|
||||
@Serializable
|
||||
data class WidgetSummary(
|
||||
val overdueCount: Int = 0,
|
||||
val dueSoonCount: Int = 0,
|
||||
val inProgressCount: Int = 0,
|
||||
val totalTasksCount: Int = 0,
|
||||
val tasks: List<WidgetTask> = emptyList(),
|
||||
val lastUpdated: Long = System.currentTimeMillis()
|
||||
)
|
||||
|
||||
/**
|
||||
* Repository for managing widget data persistence
|
||||
*/
|
||||
class WidgetDataRepository(private val context: Context) {
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
companion object {
|
||||
private val OVERDUE_COUNT = intPreferencesKey("overdue_count")
|
||||
private val DUE_SOON_COUNT = intPreferencesKey("due_soon_count")
|
||||
private val IN_PROGRESS_COUNT = intPreferencesKey("in_progress_count")
|
||||
private val TOTAL_TASKS_COUNT = intPreferencesKey("total_tasks_count")
|
||||
private val TASKS_JSON = stringPreferencesKey("tasks_json")
|
||||
private val LAST_UPDATED = longPreferencesKey("last_updated")
|
||||
private val IS_PRO_USER = stringPreferencesKey("is_pro_user")
|
||||
private val USER_NAME = stringPreferencesKey("user_name")
|
||||
|
||||
@Volatile
|
||||
private var INSTANCE: WidgetDataRepository? = null
|
||||
|
||||
fun getInstance(context: Context): WidgetDataRepository {
|
||||
return INSTANCE ?: synchronized(this) {
|
||||
INSTANCE ?: WidgetDataRepository(context.applicationContext).also { INSTANCE = it }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the widget summary as a Flow
|
||||
*/
|
||||
val widgetSummary: Flow<WidgetSummary> = context.widgetDataStore.data.map { preferences ->
|
||||
val tasksJson = preferences[TASKS_JSON] ?: "[]"
|
||||
val tasks = try {
|
||||
json.decodeFromString<List<WidgetTask>>(tasksJson)
|
||||
} catch (e: Exception) {
|
||||
emptyList()
|
||||
}
|
||||
|
||||
WidgetSummary(
|
||||
overdueCount = preferences[OVERDUE_COUNT] ?: 0,
|
||||
dueSoonCount = preferences[DUE_SOON_COUNT] ?: 0,
|
||||
inProgressCount = preferences[IN_PROGRESS_COUNT] ?: 0,
|
||||
totalTasksCount = preferences[TOTAL_TASKS_COUNT] ?: 0,
|
||||
tasks = tasks,
|
||||
lastUpdated = preferences[LAST_UPDATED] ?: 0L
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user is a Pro subscriber
|
||||
*/
|
||||
val isProUser: Flow<Boolean> = context.widgetDataStore.data.map { preferences ->
|
||||
preferences[IS_PRO_USER] == "true"
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the user's display name
|
||||
*/
|
||||
val userName: Flow<String> = context.widgetDataStore.data.map { preferences ->
|
||||
preferences[USER_NAME] ?: ""
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the widget data
|
||||
*/
|
||||
suspend fun updateWidgetData(summary: WidgetSummary) {
|
||||
context.widgetDataStore.edit { preferences ->
|
||||
preferences[OVERDUE_COUNT] = summary.overdueCount
|
||||
preferences[DUE_SOON_COUNT] = summary.dueSoonCount
|
||||
preferences[IN_PROGRESS_COUNT] = summary.inProgressCount
|
||||
preferences[TOTAL_TASKS_COUNT] = summary.totalTasksCount
|
||||
preferences[TASKS_JSON] = json.encodeToString(summary.tasks)
|
||||
preferences[LAST_UPDATED] = System.currentTimeMillis()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update user subscription status
|
||||
*/
|
||||
suspend fun updateProStatus(isPro: Boolean) {
|
||||
context.widgetDataStore.edit { preferences ->
|
||||
preferences[IS_PRO_USER] = if (isPro) "true" else "false"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update user name
|
||||
*/
|
||||
suspend fun updateUserName(name: String) {
|
||||
context.widgetDataStore.edit { preferences ->
|
||||
preferences[USER_NAME] = name
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all widget data (called on logout)
|
||||
*/
|
||||
suspend fun clearData() {
|
||||
context.widgetDataStore.edit { preferences ->
|
||||
preferences.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package com.tt.honeyDue.widget
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import com.tt.honeyDue.data.DataManager
|
||||
import com.tt.honeyDue.models.TaskCompletionCreateRequest
|
||||
import com.tt.honeyDue.network.APILayer
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* BroadcastReceiver for handling task actions from widgets
|
||||
*/
|
||||
class WidgetTaskActionReceiver : BroadcastReceiver() {
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
when (intent.action) {
|
||||
"com.tt.honeyDue.COMPLETE_TASK" -> {
|
||||
val taskId = intent.getIntExtra("task_id", -1)
|
||||
if (taskId != -1) {
|
||||
completeTask(context, taskId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun completeTask(context: Context, taskId: Int) {
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
try {
|
||||
// Check if user is authenticated
|
||||
val token = DataManager.authToken.value
|
||||
if (token.isNullOrEmpty()) {
|
||||
return@launch
|
||||
}
|
||||
|
||||
// Create completion request
|
||||
val request = TaskCompletionCreateRequest(
|
||||
taskId = taskId,
|
||||
notes = "Completed from widget"
|
||||
)
|
||||
|
||||
// Complete the task via API
|
||||
val result = APILayer.createTaskCompletion(request)
|
||||
|
||||
// Update widgets after completion
|
||||
if (result is com.tt.honeyDue.network.ApiResult.Success) {
|
||||
WidgetUpdateManager.updateAllWidgets(context)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package com.tt.honeyDue.widget
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.preferences.core.intPreferencesKey
|
||||
import androidx.datastore.preferences.core.longPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.glance.appwidget.GlanceAppWidgetManager
|
||||
import androidx.glance.appwidget.state.updateAppWidgetState
|
||||
import androidx.glance.state.PreferencesGlanceStateDefinition
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
/**
|
||||
* Manager for updating all widgets with new data
|
||||
*/
|
||||
object WidgetUpdateManager {
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
/**
|
||||
* Update all honeyDue widgets with new data
|
||||
*/
|
||||
fun updateAllWidgets(context: Context) {
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
try {
|
||||
val repository = WidgetDataRepository.getInstance(context)
|
||||
val summary = repository.widgetSummary.first()
|
||||
val isProUser = repository.isProUser.first()
|
||||
|
||||
updateWidgetsWithData(context, summary, isProUser)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update widgets with the provided summary data
|
||||
*/
|
||||
suspend fun updateWidgetsWithData(
|
||||
context: Context,
|
||||
summary: WidgetSummary,
|
||||
isProUser: Boolean
|
||||
) {
|
||||
val glanceManager = GlanceAppWidgetManager(context)
|
||||
|
||||
// Update small widgets
|
||||
val smallWidgetIds = glanceManager.getGlanceIds(HoneyDueSmallWidget::class.java)
|
||||
smallWidgetIds.forEach { id ->
|
||||
updateAppWidgetState(context, PreferencesGlanceStateDefinition, id) { prefs ->
|
||||
prefs.toMutablePreferences().apply {
|
||||
this[intPreferencesKey("overdue_count")] = summary.overdueCount
|
||||
this[intPreferencesKey("due_soon_count")] = summary.dueSoonCount
|
||||
this[intPreferencesKey("in_progress_count")] = summary.inProgressCount
|
||||
}
|
||||
}
|
||||
HoneyDueSmallWidget().update(context, id)
|
||||
}
|
||||
|
||||
// Update medium widgets
|
||||
val mediumWidgetIds = glanceManager.getGlanceIds(HoneyDueMediumWidget::class.java)
|
||||
mediumWidgetIds.forEach { id ->
|
||||
updateAppWidgetState(context, PreferencesGlanceStateDefinition, id) { prefs ->
|
||||
prefs.toMutablePreferences().apply {
|
||||
this[intPreferencesKey("overdue_count")] = summary.overdueCount
|
||||
this[intPreferencesKey("due_soon_count")] = summary.dueSoonCount
|
||||
this[intPreferencesKey("in_progress_count")] = summary.inProgressCount
|
||||
this[stringPreferencesKey("tasks_json")] = json.encodeToString(summary.tasks)
|
||||
}
|
||||
}
|
||||
HoneyDueMediumWidget().update(context, id)
|
||||
}
|
||||
|
||||
// Update large widgets
|
||||
val largeWidgetIds = glanceManager.getGlanceIds(HoneyDueLargeWidget::class.java)
|
||||
largeWidgetIds.forEach { id ->
|
||||
updateAppWidgetState(context, PreferencesGlanceStateDefinition, id) { prefs ->
|
||||
prefs.toMutablePreferences().apply {
|
||||
this[intPreferencesKey("overdue_count")] = summary.overdueCount
|
||||
this[intPreferencesKey("due_soon_count")] = summary.dueSoonCount
|
||||
this[intPreferencesKey("in_progress_count")] = summary.inProgressCount
|
||||
this[intPreferencesKey("total_tasks_count")] = summary.totalTasksCount
|
||||
this[stringPreferencesKey("tasks_json")] = json.encodeToString(summary.tasks)
|
||||
this[stringPreferencesKey("is_pro_user")] = if (isProUser) "true" else "false"
|
||||
this[longPreferencesKey("last_updated")] = summary.lastUpdated
|
||||
}
|
||||
}
|
||||
HoneyDueLargeWidget().update(context, id)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all widget data (called on logout)
|
||||
*/
|
||||
fun clearAllWidgets(context: Context) {
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
try {
|
||||
val emptyData = WidgetSummary()
|
||||
updateWidgetsWithData(context, emptyData, false)
|
||||
|
||||
// Also clear the repository
|
||||
val repository = WidgetDataRepository.getInstance(context)
|
||||
repository.clearData()
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user