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:
Trey t
2026-03-07 06:33:57 -06:00
parent 9c574c4343
commit 1e2adf7660
450 changed files with 1730 additions and 1788 deletions

View File

@@ -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)
}

View File

@@ -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)
}
}
}

View File

@@ -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"
}
}

View File

@@ -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()

View File

@@ -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()
}
}

View File

@@ -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")
}
}
}
}

View File

@@ -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 }
}
}
}
}

View File

@@ -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)
}
}
}

View File

@@ -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())
}
}
}

View File

@@ -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()
}
}

View File

@@ -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
)
}

View File

@@ -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"))
}
}
}

View File

@@ -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)
}
}

View File

@@ -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
}
}
}

View File

@@ -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()
}

View File

@@ -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
}

View File

@@ -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()
}
}
}
)
}

View File

@@ -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
)
}

View File

@@ -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)
}

View File

@@ -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
}
}

View File

@@ -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}")
}
}
}
}

View File

@@ -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 }
}
}
}
}

View File

@@ -0,0 +1,6 @@
package com.tt.honeyDue.storage
internal actual fun getPlatformTaskCacheManager(): TaskCacheManager? {
// Android requires context, so must use initialize() method
return null
}

View File

@@ -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 }
}
}
}
}

View File

@@ -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)
}
}
}
}

View File

@@ -0,0 +1,6 @@
package com.tt.honeyDue.storage
internal actual fun getPlatformTokenManager(): TokenManager? {
// Android requires context, so must use initialize() method
return null
}

View File

@@ -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()
}
)
}
}
}

View File

@@ -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()
}
)
}
}
}

View File

@@ -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
)
}
}
}
}

View File

@@ -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
)
}
}
}
}

View File

@@ -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
}
}

View File

@@ -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()
}

View File

@@ -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()
}

View File

@@ -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()
}

View File

@@ -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()
}
}
}

View File

@@ -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()
}
}
}
}

View File

@@ -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()
}
}
}
}