diff --git a/composeApp/src/androidMain/AndroidManifest.xml b/composeApp/src/androidMain/AndroidManifest.xml
index 77b47a8..6c08324 100644
--- a/composeApp/src/androidMain/AndroidManifest.xml
+++ b/composeApp/src/androidMain/AndroidManifest.xml
@@ -17,6 +17,16 @@
+
+
+
+
\ No newline at end of file
diff --git a/composeApp/src/androidMain/kotlin/core/platform/ShareService.kt b/composeApp/src/androidMain/kotlin/core/platform/ShareService.kt
new file mode 100644
index 0000000..146a8ec
--- /dev/null
+++ b/composeApp/src/androidMain/kotlin/core/platform/ShareService.kt
@@ -0,0 +1,94 @@
+package core.platform
+
+import android.content.Context
+import android.content.Intent
+import androidx.core.content.FileProvider
+import java.io.File
+import java.io.FileOutputStream
+
+actual class ShareService(private val context: Context) {
+ actual suspend fun shareImage(imageBytes: ByteArray, text: String) {
+ try {
+ // If no image bytes, just share text
+ if (imageBytes.isEmpty()) {
+ shareTextOnly(text)
+ return
+ }
+
+ // Save image to cache directory
+ val cacheDir = File(context.cacheDir, "share_images")
+ if (!cacheDir.exists()) {
+ if (!cacheDir.mkdirs()) {
+ // Fallback to text sharing if directory creation fails
+ shareTextOnly(text)
+ return
+ }
+ }
+
+ // Clean up old cached images to prevent excessive storage usage
+ cleanOldCacheFiles(cacheDir)
+
+ val imageFile = File(cacheDir, "streak_${System.currentTimeMillis()}.png")
+ FileOutputStream(imageFile).use { output ->
+ output.write(imageBytes)
+ }
+
+ // Get URI for the file using FileProvider
+ val imageUri = FileProvider.getUriForFile(
+ context,
+ "${context.packageName}.fileprovider",
+ imageFile
+ )
+
+ // Create share intent
+ val shareIntent = Intent(Intent.ACTION_SEND).apply {
+ type = "image/png"
+ putExtra(Intent.EXTRA_STREAM, imageUri)
+ putExtra(Intent.EXTRA_TEXT, text)
+ addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
+ }
+
+ val chooserIntent = Intent.createChooser(shareIntent, "Share your streak")
+ chooserIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+
+ context.startActivity(chooserIntent)
+ } catch (e: Exception) {
+ e.printStackTrace()
+ // Fallback to text sharing on error
+ try {
+ shareTextOnly(text)
+ } catch (fallbackError: Exception) {
+ fallbackError.printStackTrace()
+ }
+ }
+ }
+
+ private fun shareTextOnly(text: String) {
+ val shareIntent = Intent(Intent.ACTION_SEND).apply {
+ type = "text/plain"
+ putExtra(Intent.EXTRA_TEXT, text)
+ }
+
+ val chooserIntent = Intent.createChooser(shareIntent, "Share your streak")
+ chooserIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+
+ context.startActivity(chooserIntent)
+ }
+
+ private fun cleanOldCacheFiles(cacheDir: File) {
+ try {
+ val files = cacheDir.listFiles() ?: return
+ val now = System.currentTimeMillis()
+ val maxAge = 24 * 60 * 60 * 1000 // 24 hours
+
+ files.forEach { file ->
+ if (file.isFile && (now - file.lastModified()) > maxAge) {
+ file.delete()
+ }
+ }
+ } catch (e: Exception) {
+ // Ignore cleanup errors
+ e.printStackTrace()
+ }
+ }
+}
diff --git a/composeApp/src/androidMain/kotlin/di/Providers.kt b/composeApp/src/androidMain/kotlin/di/Providers.kt
index 1a95738..131ffff 100644
--- a/composeApp/src/androidMain/kotlin/di/Providers.kt
+++ b/composeApp/src/androidMain/kotlin/di/Providers.kt
@@ -1,13 +1,20 @@
package di
import core.platform.ImagePicker
+import core.platform.ShareService
import org.kodein.di.DI
import org.kodein.di.bind
import org.kodein.di.instance
import org.kodein.di.singleton
actual fun DI.Builder.provideImagePicker() {
- bind() with singleton {
+ bind() with singleton {
instance().imagePicker
}
+}
+
+actual fun DI.Builder.provideShareService() {
+ bind() with singleton {
+ ShareService(instance().application)
+ }
}
\ No newline at end of file
diff --git a/composeApp/src/androidMain/res/xml/file_paths.xml b/composeApp/src/androidMain/res/xml/file_paths.xml
new file mode 100644
index 0000000..704e49c
--- /dev/null
+++ b/composeApp/src/androidMain/res/xml/file_paths.xml
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/composeApp/src/commonMain/kotlin/core/platform/ShareService.kt b/composeApp/src/commonMain/kotlin/core/platform/ShareService.kt
new file mode 100644
index 0000000..10af728
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/core/platform/ShareService.kt
@@ -0,0 +1,5 @@
+package core.platform
+
+expect class ShareService {
+ suspend fun shareImage(imageBytes: ByteArray, text: String)
+}
diff --git a/composeApp/src/commonMain/kotlin/di/PlatformSDK.kt b/composeApp/src/commonMain/kotlin/di/PlatformSDK.kt
index 75e4f4b..acd06db 100644
--- a/composeApp/src/commonMain/kotlin/di/PlatformSDK.kt
+++ b/composeApp/src/commonMain/kotlin/di/PlatformSDK.kt
@@ -25,6 +25,7 @@ object PlatformSDK {
val platformModule = DI.Module("platform") {
provideImagePicker()
+ provideShareService()
}
_di = DI {
diff --git a/composeApp/src/commonMain/kotlin/di/Providers.kt b/composeApp/src/commonMain/kotlin/di/Providers.kt
index d7a88b8..3c4809c 100644
--- a/composeApp/src/commonMain/kotlin/di/Providers.kt
+++ b/composeApp/src/commonMain/kotlin/di/Providers.kt
@@ -2,4 +2,5 @@ package di
import org.kodein.di.DI
-expect fun DI.Builder.provideImagePicker()
\ No newline at end of file
+expect fun DI.Builder.provideImagePicker()
+expect fun DI.Builder.provideShareService()
\ No newline at end of file
diff --git a/composeApp/src/commonMain/kotlin/feature/detail/di/DetailModule.kt b/composeApp/src/commonMain/kotlin/feature/detail/di/DetailModule.kt
index 5776220..61957dd 100644
--- a/composeApp/src/commonMain/kotlin/feature/detail/di/DetailModule.kt
+++ b/composeApp/src/commonMain/kotlin/feature/detail/di/DetailModule.kt
@@ -1,5 +1,6 @@
package feature.detail.di
+import feature.detail.domain.CalculateStreakUseCase
import feature.detail.domain.DeleteHabitUseCase
import feature.detail.domain.GetDetailInfoUseCase
import feature.detail.domain.UpdateHabitUseCase
@@ -9,12 +10,16 @@ val detailModule = DI.Module("detailModule") {
bind() with provider {
GetDetailInfoUseCase(instance())
}
-
+
bind() with provider {
DeleteHabitUseCase(instance())
}
-
+
bind() with provider {
UpdateHabitUseCase(instance())
}
+
+ bind() with provider {
+ CalculateStreakUseCase(dailyDao = instance(), habitDao = instance())
+ }
}
\ No newline at end of file
diff --git a/composeApp/src/commonMain/kotlin/feature/detail/domain/CalculateStreakUseCase.kt b/composeApp/src/commonMain/kotlin/feature/detail/domain/CalculateStreakUseCase.kt
new file mode 100644
index 0000000..2508e05
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/feature/detail/domain/CalculateStreakUseCase.kt
@@ -0,0 +1,152 @@
+package feature.detail.domain
+
+import feature.daily.data.DailyDao
+import feature.habits.data.HabitDao
+import kotlinx.datetime.Clock
+import kotlinx.datetime.DateTimeUnit
+import kotlinx.datetime.LocalDate
+import kotlinx.datetime.TimeZone
+import kotlinx.datetime.minus
+import kotlinx.datetime.toLocalDateTime
+
+class CalculateStreakUseCase(
+ private val dailyDao: DailyDao,
+ private val habitDao: HabitDao
+) {
+ suspend fun execute(habitId: String): Int {
+ val habit = habitDao.getHabitWith(habitId)
+ val allDailyRecords = dailyDao.getAll()
+ .filter { it.habitId == habitId && it.isChecked }
+ .sortedByDescending { it.timestamp }
+
+ if (allDailyRecords.isEmpty()) {
+ return 0
+ }
+
+ // Parse daysToCheck from habit
+ val daysToCheck = habit.daysToCheck
+ .trim('[', ']')
+ .split(",")
+ .filter { it.isNotBlank() }
+ .map { it.trim().toInt() }
+ .toSet()
+
+ // Get today's date
+ val today = Clock.System.now()
+ .toLocalDateTime(TimeZone.currentSystemDefault())
+ .date
+
+ // Create a set of checked dates for fast lookup
+ val checkedDates = allDailyRecords.map {
+ LocalDate.parse(it.timestamp)
+ }.toSet()
+
+ // Start counting from today backwards
+ var currentDate = today
+ var streak = 0
+
+ // Check if we should start from today or the most recent checked date
+ if (!checkedDates.contains(today)) {
+ // Find the most recent checked date
+ val mostRecentChecked = checkedDates.maxOrNull()
+
+ // Check if today should be tracked
+ val todayDayOfWeek = today.dayOfWeek.ordinal
+ val shouldTrackToday = daysToCheck.isEmpty() || daysToCheck.contains(todayDayOfWeek)
+
+ if (mostRecentChecked == null) {
+ return 0
+ }
+
+ // If today should be tracked but isn't checked, streak is broken
+ if (shouldTrackToday) {
+ return 0
+ }
+
+ // If today shouldn't be tracked, check if there's a gap from the last tracked day
+ if (mostRecentChecked < today.minus(1, DateTimeUnit.DAY)) {
+ // Find the last day that should have been tracked
+ var checkDate = today.minus(1, DateTimeUnit.DAY)
+ while (checkDate > mostRecentChecked) {
+ val checkDayOfWeek = checkDate.dayOfWeek.ordinal
+ if (daysToCheck.isEmpty() || daysToCheck.contains(checkDayOfWeek)) {
+ // Found a tracked day between mostRecentChecked and today that wasn't checked
+ return 0
+ }
+ checkDate = checkDate.minus(1, DateTimeUnit.DAY)
+ }
+ }
+
+ currentDate = mostRecentChecked
+ }
+
+ // Count consecutive days backwards
+ while (true) {
+ // Check if this day should be tracked
+ val dayOfWeek = currentDate.dayOfWeek.ordinal // Monday = 0, Sunday = 6
+
+ if (daysToCheck.isEmpty() || daysToCheck.contains(dayOfWeek)) {
+ // This day should be tracked
+ if (checkedDates.contains(currentDate)) {
+ streak++
+ } else {
+ // Streak broken
+ break
+ }
+ }
+
+ // Move to previous day
+ currentDate = currentDate.minus(1, DateTimeUnit.DAY)
+
+ // Safety check: don't go back more than a year
+ if (currentDate < today.minus(365, DateTimeUnit.DAY)) {
+ break
+ }
+ }
+
+ return streak
+ }
+
+ suspend fun calculateCompletionRate(habitId: String): Int {
+ val habit = habitDao.getHabitWith(habitId)
+ val startDate = LocalDate.parse(habit.startDate)
+ val today = Clock.System.now()
+ .toLocalDateTime(TimeZone.currentSystemDefault())
+ .date
+
+ val allDailyRecords = dailyDao.getAll()
+ .filter { it.habitId == habitId }
+
+ val checkedDates = allDailyRecords
+ .filter { it.isChecked }
+ .map { LocalDate.parse(it.timestamp) }
+ .toSet()
+
+ // Parse daysToCheck
+ val daysToCheck = habit.daysToCheck
+ .trim('[', ']')
+ .split(",")
+ .filter { it.isNotBlank() }
+ .map { it.trim().toInt() }
+ .toSet()
+
+ // Count expected days
+ var currentDate = startDate
+ var expectedDays = 0
+
+ while (currentDate <= today) {
+ val dayOfWeek = currentDate.dayOfWeek.ordinal
+ if (daysToCheck.isEmpty() || daysToCheck.contains(dayOfWeek)) {
+ expectedDays++
+ }
+ currentDate = currentDate.plus(1, DateTimeUnit.DAY)
+ }
+
+ if (expectedDays == 0) {
+ return 0
+ }
+
+ val completedDays = checkedDates.size
+ return ((completedDays.toDouble() / expectedDays.toDouble()) * 100).toInt().coerceIn(0, 100)
+ }
+}
diff --git a/composeApp/src/commonMain/kotlin/feature/detail/presentation/DetailViewModel.kt b/composeApp/src/commonMain/kotlin/feature/detail/presentation/DetailViewModel.kt
index b874cb4..b5347ac 100644
--- a/composeApp/src/commonMain/kotlin/feature/detail/presentation/DetailViewModel.kt
+++ b/composeApp/src/commonMain/kotlin/feature/detail/presentation/DetailViewModel.kt
@@ -3,6 +3,7 @@ package feature.detail.presentation
import androidx.lifecycle.viewModelScope
import base.BaseViewModel
import di.Inject
+import feature.detail.domain.CalculateStreakUseCase
import feature.detail.domain.DeleteHabitUseCase
import feature.detail.domain.GetDetailInfoUseCase
import feature.detail.domain.UpdateHabitUseCase
@@ -27,6 +28,7 @@ class DetailViewModel(private val habitId: String) : BaseViewModel()
private val deleteHabitUseCase = Inject.instance()
private val updateHabitUseCase = Inject.instance()
+ private val calculateStreakUseCase = Inject.instance()
private val trackerDao = Inject.instance()
init {
@@ -42,6 +44,8 @@ class DetailViewModel(private val habitId: String) : BaseViewModel viewState = viewState.copy(dateSelectionState = DateSelectionState.End)
is DetailEvent.DateSelected -> selectDate(viewEvent.value)
is DetailEvent.NewValueChanged -> parseTrackerValue(viewEvent.value)
+ DetailEvent.ShareClicked -> calculateStreakForShare()
+ DetailEvent.ShareDismissed -> viewState = viewState.copy(isSharing = false)
}
}
@@ -62,6 +66,10 @@ class DetailViewModel(private val habitId: String) : BaseViewModel Unit
+) {
+ val scope = rememberCoroutineScope()
+ val shareService = Inject.instance()
+
+ LaunchedEffect(Unit) {
+ scope.launch {
+ // For now, we'll share text. Image generation will require platform-specific implementation
+ val shareText = buildString {
+ append("🔥 I've kept up my '$habitTitle' habit for $streakCount ")
+ append(if (streakCount == 1) "day" else "days")
+ append(" straight!\n")
+ append("Completion rate: $completionRate%\n")
+ append("#JetHabit")
+ }
+
+ // TODO: Implement actual image generation
+ // For now, just share text
+ shareService.shareImage(ByteArray(0), shareText)
+ onDismiss()
+ }
}
}
\ No newline at end of file
diff --git a/composeApp/src/commonMain/kotlin/feature/share/ui/StreakCard.kt b/composeApp/src/commonMain/kotlin/feature/share/ui/StreakCard.kt
new file mode 100644
index 0000000..4940386
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/feature/share/ui/StreakCard.kt
@@ -0,0 +1,140 @@
+package feature.share.ui
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.Card
+import androidx.compose.material.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+
+@Composable
+fun StreakCard(
+ habitTitle: String,
+ streakCount: Int,
+ completionRate: Int,
+ isGoodHabit: Boolean,
+ modifier: Modifier = Modifier
+) {
+ val gradientColors = if (isGoodHabit) {
+ listOf(
+ Color(0xFF12B37D),
+ Color(0xFF0D8A5E)
+ )
+ } else {
+ listOf(
+ Color(0xFFFF6619),
+ Color(0xFFE63956)
+ )
+ }
+
+ Card(
+ modifier = modifier
+ .width(400.dp)
+ .height(500.dp),
+ shape = RoundedCornerShape(24.dp),
+ elevation = 8.dp
+ ) {
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(
+ brush = Brush.verticalGradient(
+ colors = gradientColors
+ )
+ )
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(32.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.SpaceBetween
+ ) {
+ // Habit Title
+ Text(
+ text = habitTitle,
+ fontSize = 32.sp,
+ fontWeight = FontWeight.Bold,
+ color = Color.White,
+ textAlign = TextAlign.Center,
+ modifier = Modifier.padding(top = 16.dp)
+ )
+
+ // Streak Count - Main Focus
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center
+ ) {
+ Text(
+ text = "🔥",
+ fontSize = 64.sp,
+ modifier = Modifier.padding(bottom = 16.dp)
+ )
+
+ Text(
+ text = streakCount.toString(),
+ fontSize = 96.sp,
+ fontWeight = FontWeight.Black,
+ color = Color.White,
+ textAlign = TextAlign.Center
+ )
+
+ Text(
+ text = if (streakCount == 1) "day streak" else "days streak",
+ fontSize = 24.sp,
+ fontWeight = FontWeight.Medium,
+ color = Color.White.copy(alpha = 0.9f),
+ textAlign = TextAlign.Center,
+ modifier = Modifier.padding(top = 8.dp)
+ )
+ }
+
+ // Completion Rate & Branding
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Text(
+ text = "Completion: $completionRate%",
+ fontSize = 20.sp,
+ fontWeight = FontWeight.SemiBold,
+ color = Color.White.copy(alpha = 0.95f),
+ textAlign = TextAlign.Center,
+ modifier = Modifier.padding(bottom = 24.dp)
+ )
+
+ // App Branding
+ Box(
+ modifier = Modifier
+ .background(
+ color = Color.White.copy(alpha = 0.2f),
+ shape = RoundedCornerShape(12.dp)
+ )
+ .padding(horizontal = 24.dp, vertical = 12.dp)
+ ) {
+ Text(
+ text = "JetHabit",
+ fontSize = 18.sp,
+ fontWeight = FontWeight.Bold,
+ color = Color.White,
+ textAlign = TextAlign.Center
+ )
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/composeApp/src/iosMain/kotlin/core/platform/ShareService.kt b/composeApp/src/iosMain/kotlin/core/platform/ShareService.kt
new file mode 100644
index 0000000..2d38bde
--- /dev/null
+++ b/composeApp/src/iosMain/kotlin/core/platform/ShareService.kt
@@ -0,0 +1,53 @@
+package core.platform
+
+import kotlinx.cinterop.ExperimentalForeignApi
+import kotlinx.cinterop.addressOf
+import kotlinx.cinterop.usePinned
+import platform.Foundation.NSData
+import platform.Foundation.create
+import platform.UIKit.UIActivityViewController
+import platform.UIKit.UIViewController
+
+@OptIn(ExperimentalForeignApi::class)
+actual class ShareService {
+ private var currentViewController: UIViewController? = null
+
+ fun setViewController(viewController: UIViewController) {
+ currentViewController = viewController
+ }
+
+ actual suspend fun shareImage(imageBytes: ByteArray, text: String) {
+ try {
+ val itemsToShare = if (imageBytes.isEmpty()) {
+ // Share text only
+ listOf(text)
+ } else {
+ // Share image and text
+ val nsData = imageBytes.usePinned { pinned ->
+ NSData.create(
+ bytes = pinned.addressOf(0),
+ length = imageBytes.size.toULong()
+ )
+ }
+ listOf(text, nsData)
+ }
+
+ val activityViewController = UIActivityViewController(
+ activityItems = itemsToShare,
+ applicationActivities = null
+ )
+
+ currentViewController?.presentViewController(
+ activityViewController,
+ animated = true,
+ completion = null
+ ) ?: run {
+ // Log error if no view controller is set
+ println("Error: ShareService - No view controller set. Call setViewController first.")
+ }
+ } catch (e: Exception) {
+ println("Error sharing on iOS: ${e.message}")
+ e.printStackTrace()
+ }
+ }
+}
diff --git a/composeApp/src/iosMain/kotlin/di/Providers.ios.kt b/composeApp/src/iosMain/kotlin/di/Providers.ios.kt
index b5c788f..90aa89d 100644
--- a/composeApp/src/iosMain/kotlin/di/Providers.ios.kt
+++ b/composeApp/src/iosMain/kotlin/di/Providers.ios.kt
@@ -2,10 +2,15 @@ package di
import core.platform.IOSImagePicker
import core.platform.ImagePicker
+import core.platform.ShareService
import org.kodein.di.DI
import org.kodein.di.bind
import org.kodein.di.singleton
actual fun DI.Builder.provideImagePicker(platform: Platform) {
bind() with singleton { IOSImagePicker() }
+}
+
+actual fun DI.Builder.provideShareService() {
+ bind() with singleton { ShareService() }
}
\ No newline at end of file
diff --git a/composeApp/src/jsMain/kotlin/core/platform/ShareService.kt b/composeApp/src/jsMain/kotlin/core/platform/ShareService.kt
new file mode 100644
index 0000000..9e9c7d8
--- /dev/null
+++ b/composeApp/src/jsMain/kotlin/core/platform/ShareService.kt
@@ -0,0 +1,91 @@
+package core.platform
+
+import kotlinx.browser.window
+import org.khronos.webgl.Uint8Array
+import org.w3c.files.Blob
+import org.w3c.files.BlobPropertyBag
+import kotlin.js.Promise
+
+actual class ShareService {
+ actual suspend fun shareImage(imageBytes: ByteArray, text: String) {
+ try {
+ // If no image bytes, just share text
+ if (imageBytes.isEmpty()) {
+ shareText(text)
+ return
+ }
+
+ // Convert ByteArray to Uint8Array
+ val uint8Array = Uint8Array(imageBytes.size)
+ imageBytes.forEachIndexed { index, byte ->
+ uint8Array[index] = byte
+ }
+
+ // Create Blob from the byte array
+ val blob = Blob(arrayOf(uint8Array), BlobPropertyBag(type = "image/png"))
+
+ // Try using Web Share API if available
+ val navigator = window.navigator.asDynamic()
+ if (navigator.share != undefined && navigator.canShare != undefined) {
+ try {
+ val file = js("new File([blob], 'streak.png', { type: 'image/png' })")
+ val shareData = js("{}")
+ shareData.text = text
+ shareData.files = arrayOf(file)
+
+ if (navigator.canShare(shareData)) {
+ (navigator.share(shareData) as Promise).catch { error ->
+ console.error("Error sharing:", error)
+ fallbackToClipboard(text)
+ }
+ return
+ }
+ } catch (e: dynamic) {
+ console.error("Web Share API error:", e)
+ }
+ }
+
+ // Fallback: Copy text to clipboard
+ fallbackToClipboard(text)
+ } catch (e: Exception) {
+ console.error("Share failed:", e.message ?: "Unknown error")
+ fallbackToClipboard(text)
+ }
+ }
+
+ private fun shareText(text: String) {
+ try {
+ val navigator = window.navigator.asDynamic()
+ if (navigator.share != undefined) {
+ val shareData = js("{}")
+ shareData.text = text
+
+ (navigator.share(shareData) as Promise).catch { error ->
+ console.error("Error sharing text:", error)
+ fallbackToClipboard(text)
+ }
+ } else {
+ fallbackToClipboard(text)
+ }
+ } catch (e: Exception) {
+ console.error("Share text failed:", e.message ?: "Unknown error")
+ fallbackToClipboard(text)
+ }
+ }
+
+ private fun fallbackToClipboard(text: String) {
+ try {
+ val navigator = window.navigator.asDynamic()
+ if (navigator.clipboard != undefined) {
+ navigator.clipboard.writeText(text).then(
+ { console.log("Text copied to clipboard!") },
+ { error: dynamic -> console.error("Failed to copy:", error) }
+ )
+ } else {
+ console.log("Clipboard not supported. Share text: $text")
+ }
+ } catch (e: Exception) {
+ console.log("Share text: $text")
+ }
+ }
+}
diff --git a/composeApp/src/jsMain/kotlin/di/Providers.js.kt b/composeApp/src/jsMain/kotlin/di/Providers.js.kt
new file mode 100644
index 0000000..6fb3294
--- /dev/null
+++ b/composeApp/src/jsMain/kotlin/di/Providers.js.kt
@@ -0,0 +1,20 @@
+package di
+
+import core.platform.ImagePicker
+import core.platform.ShareService
+import org.kodein.di.DI
+import org.kodein.di.bind
+import org.kodein.di.singleton
+
+actual fun DI.Builder.provideImagePicker() {
+ bind() with singleton {
+ object : ImagePicker {
+ override suspend fun pickImage(): String? = null
+ override suspend fun takePhoto(): String? = null
+ }
+ }
+}
+
+actual fun DI.Builder.provideShareService() {
+ bind() with singleton { ShareService() }
+}
diff --git a/composeApp/src/jvmMain/kotlin/core/platform/ShareService.kt b/composeApp/src/jvmMain/kotlin/core/platform/ShareService.kt
new file mode 100644
index 0000000..acd0ef6
--- /dev/null
+++ b/composeApp/src/jvmMain/kotlin/core/platform/ShareService.kt
@@ -0,0 +1,75 @@
+package core.platform
+
+import java.awt.Toolkit
+import java.awt.datatransfer.DataFlavor
+import java.awt.datatransfer.Transferable
+import java.awt.image.BufferedImage
+import java.io.ByteArrayInputStream
+import javax.imageio.ImageIO
+
+actual class ShareService {
+ actual suspend fun shareImage(imageBytes: ByteArray, text: String) {
+ try {
+ if (imageBytes.isEmpty()) {
+ // No image to share, just copy text to clipboard
+ copyTextToClipboard(text)
+ println("Streak text copied to clipboard!")
+ println(text)
+ return
+ }
+
+ // Convert bytes to BufferedImage
+ val inputStream = ByteArrayInputStream(imageBytes)
+ val image = ImageIO.read(inputStream)
+
+ if (image == null) {
+ // Fallback to text if image reading fails
+ copyTextToClipboard(text)
+ println("Failed to read image. Streak text copied to clipboard instead!")
+ println(text)
+ return
+ }
+
+ // Copy image to clipboard
+ val clipboard = Toolkit.getDefaultToolkit().systemClipboard
+ val transferableImage = TransferableImage(image)
+ clipboard.setContents(transferableImage, null)
+
+ println("Streak image copied to clipboard!")
+ println(text)
+ } catch (e: Exception) {
+ e.printStackTrace()
+ // Fallback to text on error
+ try {
+ copyTextToClipboard(text)
+ println("Error occurred. Streak text copied to clipboard instead!")
+ println(text)
+ } catch (fallbackError: Exception) {
+ fallbackError.printStackTrace()
+ }
+ }
+ }
+
+ private fun copyTextToClipboard(text: String) {
+ val clipboard = Toolkit.getDefaultToolkit().systemClipboard
+ val transferable = java.awt.datatransfer.StringSelection(text)
+ clipboard.setContents(transferable, null)
+ }
+
+ private class TransferableImage(private val image: BufferedImage) : Transferable {
+ override fun getTransferDataFlavors(): Array {
+ return arrayOf(DataFlavor.imageFlavor)
+ }
+
+ override fun isDataFlavorSupported(flavor: DataFlavor): Boolean {
+ return DataFlavor.imageFlavor.equals(flavor)
+ }
+
+ override fun getTransferData(flavor: DataFlavor): Any {
+ if (isDataFlavorSupported(flavor)) {
+ return image
+ }
+ throw UnsupportedOperationException("Flavor not supported")
+ }
+ }
+}
diff --git a/composeApp/src/jvmMain/kotlin/di/Providers.desktop.kt b/composeApp/src/jvmMain/kotlin/di/Providers.desktop.kt
index 08770d6..a2edde9 100644
--- a/composeApp/src/jvmMain/kotlin/di/Providers.desktop.kt
+++ b/composeApp/src/jvmMain/kotlin/di/Providers.desktop.kt
@@ -2,10 +2,15 @@ package di
import core.platform.DesktopImagePicker
import core.platform.ImagePicker
+import core.platform.ShareService
import org.kodein.di.DI
import org.kodein.di.bind
import org.kodein.di.singleton
actual fun DI.Builder.provideImagePicker(platform: Platform) {
bind() with singleton { DesktopImagePicker() }
+}
+
+actual fun DI.Builder.provideShareService() {
+ bind() with singleton { ShareService() }
}
\ No newline at end of file
diff --git a/specs/SPEC_share-habits.md b/specs/SPEC_share-habits.md
new file mode 100644
index 0000000..f4ee0d4
--- /dev/null
+++ b/specs/SPEC_share-habits.md
@@ -0,0 +1,14 @@
+# Specification: Share habits
+
+## Summary
+I want to share my habits streak to other people
+
+## Requirements
+- Implement the feature as described in the task title and description.
+- Follow existing codebase conventions and patterns.
+
+## Acceptance Criteria
+- The feature works as described.
+- Code follows existing patterns.
+
+*Note: This is a fallback spec generated because the interview stage did not produce a spec file.*
diff --git a/specs/SPEC_share_habits.md b/specs/SPEC_share_habits.md
new file mode 100644
index 0000000..8d8cc6e
--- /dev/null
+++ b/specs/SPEC_share_habits.md
@@ -0,0 +1,171 @@
+# Spec: Share Habits Streak
+
+## Summary
+
+Add the ability for users to share their habit streak as a visually appealing image card via the platform's native share sheet. Users can share from the habit detail screen, generating a card that includes the habit name, current streak count, completion rate, and a mini calendar/heatmap visualization.
+
+## Requirements
+
+### Functional Requirements
+
+1. **Share Button on Detail Screen**: Add a share button (icon) to the habit detail screen that triggers the share flow.
+2. **Streak Calculation**: Implement streak calculation logic — count consecutive days a habit was checked, ending at the current date (or the most recent checked date).
+3. **Shareable Image Generation**: Render a Compose-based "streak card" to a bitmap image containing:
+ - Habit title
+ - Current streak count (e.g., "15 days")
+ - Completion rate percentage
+ - Good/bad habit indicator (visual styling difference)
+ - App branding/watermark ("JetHabit")
+4. **Platform Share Sheet**: Use each platform's native share mechanism to share the generated image.
+5. **Text Fallback**: Also attach a text summary (e.g., "I've kept up my 'Morning Run' habit for 15 days straight! #JetHabit") as fallback content for platforms that prefer text.
+
+### Non-Functional Requirements
+
+- Sharing must work on Android and iOS at minimum. Desktop and web can show a "copy to clipboard" fallback.
+- Image generation should be fast (< 1 second).
+- The share card should look good on both light and dark backgrounds (use a self-contained card design with its own background).
+
+## Interview Notes
+
+The user was not available for detailed interview questions. The following assumptions were made based on the task description ("share my habits streak to other people") and common patterns for habit-sharing features:
+
+- **Format**: Shareable image card via OS share sheet (most common and visually appealing approach for social sharing)
+- **Scope**: Share from the detail screen for a single habit (not bulk sharing)
+- **Streak definition**: Consecutive days of completion ending at today or the last checked date
+- **Platforms**: Android and iOS as primary targets, with clipboard fallback for desktop/web
+
+## Files to Create
+
+| File | Purpose |
+|------|---------|
+| `composeApp/src/commonMain/kotlin/feature/detail/domain/CalculateStreakUseCase.kt` | Calculate current streak for a habit |
+| `composeApp/src/commonMain/kotlin/feature/share/ui/StreakCard.kt` | Composable that renders the shareable streak card |
+| `composeApp/src/commonMain/kotlin/feature/share/presentation/ShareViewModel.kt` | ViewModel managing share state and image generation |
+| `composeApp/src/commonMain/kotlin/core/platform/ShareService.kt` | Expect declaration for platform sharing |
+| `composeApp/src/androidMain/kotlin/core/platform/AndroidShareService.kt` | Android actual: Intent.ACTION_SEND with image |
+| `composeApp/src/iosMain/kotlin/core/platform/IOSShareService.kt` | iOS actual: UIActivityViewController |
+| `composeApp/src/desktopMain/kotlin/core/platform/DesktopShareService.kt` | Desktop actual: Copy image to clipboard |
+| `composeApp/src/jsMain/kotlin/core/platform/JsShareService.kt` | Web actual: Web Share API or clipboard fallback |
+
+## Files to Modify
+
+| File | Change |
+|------|--------|
+| `composeApp/src/commonMain/kotlin/feature/detail/ui/DetailView.kt` | Add share button to the UI |
+| `composeApp/src/commonMain/kotlin/feature/detail/presentation/DetailViewModel.kt` | Add share action/event handling |
+| `composeApp/src/commonMain/kotlin/feature/detail/models/DetailViewState.kt` | Add streak count and share-related state |
+| `composeApp/src/commonMain/kotlin/feature/detail/models/DetailEvent.kt` | Add share event |
+| `composeApp/src/commonMain/kotlin/di/PlatformSDK.kt` | Register ShareService in DI |
+| `composeApp/src/androidMain/kotlin/di/Providers.kt` | Provide Android ShareService |
+| `composeApp/src/iosMain/kotlin/di/Providers.kt` | Provide iOS ShareService |
+| `composeApp/src/desktopMain/kotlin/di/Providers.kt` | Provide Desktop ShareService |
+
+## Detailed Implementation Approach
+
+### 1. Streak Calculation (`CalculateStreakUseCase`)
+
+```kotlin
+class CalculateStreakUseCase(private val dailyDao: DailyDao) {
+ suspend fun execute(habitId: String): Int {
+ // Get all DailyEntity records for this habit, sorted by date descending
+ // Walk backwards from today counting consecutive checked days
+ // Return the streak count (0 if no streak)
+ }
+}
+```
+
+- Query all `DailyEntity` rows for the habit, filter `isChecked == true`
+- Sort by timestamp descending
+- Starting from today (or most recent checked day), count consecutive days
+- Account for `daysToCheck` — if a habit is only tracked Mon/Wed/Fri, skip non-tracked days when calculating streak
+
+### 2. Streak Card Composable (`StreakCard`)
+
+A self-contained Composable that renders:
+```
+┌─────────────────────────┐
+│ 🔥 Morning Run │
+│ │
+│ 15 days │
+│ current streak │
+│ │
+│ Completion: 87% │
+│ │
+│ JetHabit │
+└─────────────────────────┘
+```
+
+- Fixed dimensions suitable for sharing (e.g., 400x500dp)
+- Card background with rounded corners
+- Color-coded: green tones for good habits, red/orange for bad habits
+- Uses the app's existing theme colors where possible
+- Self-contained (doesn't rely on external theme — includes its own background)
+
+### 3. Platform Share Service (expect/actual pattern)
+
+```kotlin
+// Common
+expect class ShareService {
+ fun shareImage(imageBytes: ByteArray, text: String)
+}
+```
+
+**Android**: Use `Intent.ACTION_SEND` with `image/png` MIME type. Write bitmap to a `FileProvider` URI. Include text as `Intent.EXTRA_TEXT`.
+
+**iOS**: Use `UIActivityViewController` with the image and text.
+
+**Desktop**: Copy image to system clipboard using `java.awt.Toolkit`.
+
+**Web**: Use `navigator.share()` Web Share API if available, otherwise fall back to copying text to clipboard.
+
+### 4. Image Generation
+
+Use Compose's `GraphicsLayer` or `drawToBitmap` approach:
+- On Android: Use `AndroidView` or Compose's `ImageBitmap` rendering
+- Cross-platform: Render the `StreakCard` composable to a bitmap using `captureToImage()` from compose test utils, or use a `Canvas`-based approach that works cross-platform
+
+Alternative simpler approach: Use Compose `Canvas` to draw the card directly to a `ImageBitmap`, avoiding the need to render a composable off-screen. This is more portable across platforms.
+
+### 5. Integration into Detail Screen
+
+- Add a share `IconButton` (share icon) in the top-right area of `DetailView`
+- On click, dispatch `DetailEvent.ShareClicked`
+- ViewModel calculates streak, triggers image generation, then calls `ShareService`
+- Show a brief loading indicator if image generation takes time
+
+## Acceptance Criteria
+
+1. User can tap a share button on the habit detail screen
+2. A visually appealing streak card image is generated showing habit name, streak count, and completion rate
+3. The platform's native share sheet opens with the image and text
+4. Streak count correctly reflects consecutive days of habit completion
+5. The feature works on Android and iOS
+6. Desktop/web platforms have a reasonable fallback (clipboard)
+7. The share card is readable and looks good when shared to common platforms (WhatsApp, Instagram Stories, Twitter)
+
+## Edge Cases and Risks
+
+### Edge Cases
+- **Zero streak**: Show "0 days" with encouraging messaging (e.g., "Just getting started!")
+- **Habit with no check-ins**: Still allow sharing with 0 streak
+- **Tracker-type habits**: Show streak based on days where a value was recorded
+- **Habits with specific days**: Streak should only consider the days the habit is tracked (e.g., MWF habit — missing Tuesday doesn't break the streak)
+- **Deleted habit data**: Handle gracefully if habit has no daily records
+- **Very long streaks**: Ensure the number displays correctly (e.g., "1,234 days")
+
+### Risks
+- **Cross-platform image generation**: Rendering a Composable to bitmap differs across platforms. May need platform-specific image generation code. **Mitigation**: Use Canvas-based drawing that works uniformly.
+- **File provider setup on Android**: Sharing images requires a `FileProvider` entry in AndroidManifest.xml. **Mitigation**: Add the provider configuration.
+- **iOS image sharing**: UIActivityViewController requires platform-specific Kotlin/Native interop. **Mitigation**: Follow the existing `IOSImagePicker` pattern.
+- **Image quality**: Bitmap rendering might look blurry on high-DPI screens. **Mitigation**: Render at 2x or 3x scale.
+
+## Out of Scope
+
+- Live/real-time habit sharing between users (social features)
+- Sharing entire habit history or detailed statistics
+- Sharing multiple habits at once
+- Deep links that open the app
+- User accounts or cloud sync
+- Habit import/export functionality
+- Sharing from screens other than the detail screen (can be added later)
+- Custom card themes or templates