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/ComposableToBitmap.android.kt b/composeApp/src/androidMain/kotlin/core/platform/ComposableToBitmap.android.kt
new file mode 100644
index 0000000..e8db912
--- /dev/null
+++ b/composeApp/src/androidMain/kotlin/core/platform/ComposableToBitmap.android.kt
@@ -0,0 +1,52 @@
+package core.platform
+
+import android.graphics.Bitmap
+import android.view.View
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.graphics.ImageBitmap
+import androidx.compose.ui.graphics.asImageBitmap
+import androidx.compose.ui.platform.ComposeView
+import androidx.compose.ui.platform.ViewCompositionStrategy
+import di.Inject
+import di.PlatformConfiguration
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.suspendCancellableCoroutine
+import kotlinx.coroutines.withContext
+import kotlin.coroutines.resume
+
+actual suspend fun composableToBitmap(
+ width: Int,
+ height: Int,
+ content: @Composable () -> Unit
+): ImageBitmap? = withContext(Dispatchers.Main) {
+ suspendCancellableCoroutine { continuation ->
+ try {
+ val platformConfig: PlatformConfiguration = Inject.instance()
+ val context = platformConfig.application
+
+ // Create a ComposeView to render the composable
+ val composeView = ComposeView(context).apply {
+ setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnDetachedFromWindow)
+ setContent {
+ content()
+ }
+ }
+
+ // Measure and layout the view
+ val widthSpec = View.MeasureSpec.makeMeasureSpec(width, View.MeasureSpec.EXACTLY)
+ val heightSpec = View.MeasureSpec.makeMeasureSpec(height, View.MeasureSpec.EXACTLY)
+ composeView.measure(widthSpec, heightSpec)
+ composeView.layout(0, 0, width, height)
+
+ // Draw the view to a bitmap
+ val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
+ val canvas = android.graphics.Canvas(bitmap)
+ composeView.draw(canvas)
+
+ continuation.resume(bitmap.asImageBitmap())
+ } catch (e: Exception) {
+ e.printStackTrace()
+ continuation.resume(null)
+ }
+ }
+}
diff --git a/composeApp/src/androidMain/kotlin/core/platform/ShareImage.android.kt b/composeApp/src/androidMain/kotlin/core/platform/ShareImage.android.kt
new file mode 100644
index 0000000..a0b29a6
--- /dev/null
+++ b/composeApp/src/androidMain/kotlin/core/platform/ShareImage.android.kt
@@ -0,0 +1,73 @@
+package core.platform
+
+import android.content.Context
+import android.content.Intent
+import android.graphics.Bitmap
+import androidx.compose.ui.graphics.ImageBitmap
+import androidx.compose.ui.graphics.asAndroidBitmap
+import androidx.core.content.FileProvider
+import di.Inject
+import di.PlatformConfiguration
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import java.io.File
+import java.io.FileOutputStream
+
+actual class ShareImage {
+ private val platformConfig: PlatformConfiguration = Inject.instance()
+
+ actual suspend fun shareImage(bitmap: ImageBitmap, title: String): Boolean {
+ return withContext(Dispatchers.IO) {
+ try {
+ val context = platformConfig.application
+ val activity = platformConfig.activity
+
+ // Convert ImageBitmap to Android Bitmap
+ val androidBitmap = bitmap.asAndroidBitmap()
+
+ // Save bitmap to cache directory
+ val cachePath = File(context.cacheDir, "images")
+ cachePath.mkdirs()
+
+ // Clean up old streak images (keep only files from last 24 hours)
+ val oneDayAgo = System.currentTimeMillis() - 24 * 60 * 60 * 1000
+ cachePath.listFiles()?.forEach { oldFile ->
+ if (oldFile.name.startsWith("streak_") && oldFile.lastModified() < oneDayAgo) {
+ oldFile.delete()
+ }
+ }
+
+ val file = File(cachePath, "streak_${System.currentTimeMillis()}.png")
+ FileOutputStream(file).use { out ->
+ androidBitmap.compress(Bitmap.CompressFormat.PNG, 100, out)
+ }
+
+ // Get URI using FileProvider
+ val uri = FileProvider.getUriForFile(
+ context,
+ "${context.packageName}.fileprovider",
+ file
+ )
+
+ // Create share intent
+ val shareIntent = Intent().apply {
+ action = Intent.ACTION_SEND
+ putExtra(Intent.EXTRA_STREAM, uri)
+ putExtra(Intent.EXTRA_SUBJECT, title)
+ type = "image/png"
+ addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
+ }
+
+ // Launch share sheet
+ val chooserIntent = Intent.createChooser(shareIntent, "Share Streak")
+ chooserIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ activity.startActivity(chooserIntent)
+
+ true
+ } catch (e: Exception) {
+ e.printStackTrace()
+ false
+ }
+ }
+ }
+}
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..fd9746c
--- /dev/null
+++ b/composeApp/src/androidMain/res/xml/file_paths.xml
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/composeApp/src/commonMain/kotlin/core/platform/ComposableToBitmap.kt b/composeApp/src/commonMain/kotlin/core/platform/ComposableToBitmap.kt
new file mode 100644
index 0000000..3373904
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/core/platform/ComposableToBitmap.kt
@@ -0,0 +1,13 @@
+package core.platform
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.graphics.ImageBitmap
+
+/**
+ * Platform-specific implementation to render a Composable to an ImageBitmap.
+ */
+expect suspend fun composableToBitmap(
+ width: Int,
+ height: Int,
+ content: @Composable () -> Unit
+): ImageBitmap?
diff --git a/composeApp/src/commonMain/kotlin/core/platform/ShareImage.kt b/composeApp/src/commonMain/kotlin/core/platform/ShareImage.kt
new file mode 100644
index 0000000..d2298d5
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/core/platform/ShareImage.kt
@@ -0,0 +1,18 @@
+package core.platform
+
+import androidx.compose.ui.graphics.ImageBitmap
+
+/**
+ * Platform-specific image sharing functionality.
+ * Each platform implements sharing via its native share sheet/dialog.
+ */
+expect class ShareImage() {
+ /**
+ * Share an image bitmap via the platform's native sharing mechanism.
+ *
+ * @param bitmap The image to share
+ * @param title The title/subject for the share (used in some platforms)
+ * @return true if sharing was initiated successfully, false otherwise
+ */
+ suspend fun shareImage(bitmap: ImageBitmap, title: String): Boolean
+}
diff --git a/composeApp/src/commonMain/kotlin/feature/statistics/presentation/StatisticsViewModel.kt b/composeApp/src/commonMain/kotlin/feature/statistics/presentation/StatisticsViewModel.kt
index 4829573..aa1d734 100644
--- a/composeApp/src/commonMain/kotlin/feature/statistics/presentation/StatisticsViewModel.kt
+++ b/composeApp/src/commonMain/kotlin/feature/statistics/presentation/StatisticsViewModel.kt
@@ -33,6 +33,29 @@ class StatisticsViewModel : BaseViewModel, today: LocalDate): Int {
+ var streak = 0
+ var currentDate = today
+
+ while (true) {
+ val dateStr = currentDate.toString()
+ val trackedDay = trackedDays.find { it.date == dateStr }
+
+ if (trackedDay != null && trackedDay.isChecked) {
+ streak++
+ currentDate = currentDate.minus(1, DateTimeUnit.DAY)
+ } else {
+ break
+ }
+ }
+
+ return streak
+ }
+
private fun loadHabitsStatistics() {
viewModelScope.launch(Dispatchers.Default) {
withContext(Dispatchers.Main) {
@@ -90,7 +113,8 @@ class StatisticsViewModel : BaseViewModel 0) trackedCount.toFloat() / totalDays else 0f
+ completionRate = if (totalDays > 0) trackedCount.toFloat() / totalDays else 0f,
+ currentStreak = calculateCurrentStreak(trackedDays, today)
)
}
HabitType.TRACKER -> {
@@ -119,7 +143,8 @@ class StatisticsViewModel : BaseViewModel Unit,
+ onShare: () -> Unit
+) {
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ title = {
+ Text("Share Streak")
+ },
+ text = {
+ Box(
+ modifier = Modifier.fillMaxWidth(),
+ contentAlignment = Alignment.Center
+ ) {
+ // Display the share card at a smaller preview size
+ ShareStreakCard(
+ streakData = streakData,
+ modifier = Modifier
+ .width(360.dp)
+ .height(189.dp) // Maintains 1.91:1 ratio
+ )
+ }
+ },
+ confirmButton = {
+ TextButton(onClick = onShare) {
+ Text("Share")
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = onDismiss) {
+ Text("Cancel")
+ }
+ },
+ properties = DialogProperties(
+ dismissOnBackPress = true,
+ dismissOnClickOutside = true
+ )
+ )
+}
diff --git a/composeApp/src/commonMain/kotlin/feature/statistics/ui/StatisticsScreen.kt b/composeApp/src/commonMain/kotlin/feature/statistics/ui/StatisticsScreen.kt
index 0d43ffa..e494bf6 100644
--- a/composeApp/src/commonMain/kotlin/feature/statistics/ui/StatisticsScreen.kt
+++ b/composeApp/src/commonMain/kotlin/feature/statistics/ui/StatisticsScreen.kt
@@ -8,11 +8,17 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
+import core.platform.ShareImage
+import core.platform.composableToBitmap
+import di.Inject
import feature.statistics.presentation.StatisticsViewModel
+import feature.statistics.presentation.models.StreakData
import feature.statistics.ui.models.StatisticsEvent
import feature.statistics.ui.views.StatisticsItem
import feature.statistics.ui.views.StatisticsViewNoItems
+import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
+import screens.stats.models.HabitStatistics
import tech.mobiledeveloper.jethabit.resources.Res
import tech.mobiledeveloper.jethabit.resources.title_statistics
import ui.themes.JetHabitTheme
@@ -22,13 +28,17 @@ fun StatisticsScreen() {
val viewModel = remember { StatisticsViewModel() }
val viewState by viewModel.viewStates().collectAsState()
+ var shareDialogHabit by remember { mutableStateOf(null) }
+ val coroutineScope = rememberCoroutineScope()
+ val scaffoldState = rememberScaffoldState()
+
LaunchedEffect(Unit) {
viewModel.obtainEvent(StatisticsEvent.LoadStatistics)
}
- Surface(
- modifier = Modifier.fillMaxSize(),
- color = JetHabitTheme.colors.primaryBackground
+ Scaffold(
+ scaffoldState = scaffoldState,
+ backgroundColor = JetHabitTheme.colors.primaryBackground
) {
Column(
modifier = Modifier
@@ -57,12 +67,69 @@ fun StatisticsScreen() {
StatisticsItem(
title = stat.title,
completionRate = stat.completionRate,
- trackedDays = stat.trackedDays
+ trackedDays = stat.trackedDays,
+ currentStreak = stat.currentStreak,
+ onShareClick = if (stat.currentStreak > 0) {
+ { shareDialogHabit = stat }
+ } else null
)
Spacer(modifier = Modifier.height(8.dp))
}
}
}
}
+
+ // Share dialog
+ shareDialogHabit?.let { habit ->
+ val streakData = StreakData(
+ habitTitle = habit.title,
+ streakCount = habit.currentStreak,
+ habitId = habit.id
+ )
+
+ ShareStreakDialog(
+ streakData = streakData,
+ onDismiss = { shareDialogHabit = null },
+ onShare = {
+ coroutineScope.launch {
+ try {
+ // Render the streak card to a bitmap
+ val bitmap = composableToBitmap(
+ width = 1080,
+ height = 566
+ ) {
+ ShareStreakCard(streakData = streakData)
+ }
+
+ if (bitmap != null) {
+ // Share the bitmap
+ val shareImage = ShareImage()
+ val success = shareImage.shareImage(bitmap, "My ${streakData.habitTitle} Streak")
+
+ if (!success) {
+ scaffoldState.snackbarHostState.showSnackbar(
+ message = "Failed to share image",
+ duration = SnackbarDuration.Short
+ )
+ }
+ } else {
+ scaffoldState.snackbarHostState.showSnackbar(
+ message = "Failed to create share image",
+ duration = SnackbarDuration.Short
+ )
+ }
+ } catch (e: Exception) {
+ e.printStackTrace()
+ scaffoldState.snackbarHostState.showSnackbar(
+ message = "Error sharing: ${e.message}",
+ duration = SnackbarDuration.Short
+ )
+ } finally {
+ shareDialogHabit = null
+ }
+ }
+ }
+ )
+ }
}
}
\ No newline at end of file
diff --git a/composeApp/src/commonMain/kotlin/feature/statistics/ui/views/StatisticsItem.kt b/composeApp/src/commonMain/kotlin/feature/statistics/ui/views/StatisticsItem.kt
index 50619e2..e1f239b 100644
--- a/composeApp/src/commonMain/kotlin/feature/statistics/ui/views/StatisticsItem.kt
+++ b/composeApp/src/commonMain/kotlin/feature/statistics/ui/views/StatisticsItem.kt
@@ -3,8 +3,12 @@ package feature.statistics.ui.views
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.Icon
+import androidx.compose.material.IconButton
import androidx.compose.material.Surface
import androidx.compose.material.Text
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Share
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -19,6 +23,8 @@ fun StatisticsItem(
title: String,
completionRate: Float,
trackedDays: List,
+ currentStreak: Int = 0,
+ onShareClick: (() -> Unit)? = null,
modifier: Modifier = Modifier
) {
Surface(
@@ -29,12 +35,54 @@ fun StatisticsItem(
Column(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)
) {
- Text(
- text = title,
- style = JetHabitTheme.typography.toolbar,
- color = JetHabitTheme.colors.primaryText
- )
-
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = title,
+ style = JetHabitTheme.typography.toolbar,
+ color = JetHabitTheme.colors.primaryText,
+ modifier = Modifier.weight(1f)
+ )
+
+ if (currentStreak > 0 && onShareClick != null) {
+ IconButton(
+ onClick = onShareClick,
+ modifier = Modifier.size(32.dp)
+ ) {
+ Icon(
+ imageVector = Icons.Default.Share,
+ contentDescription = "Share streak",
+ tint = JetHabitTheme.colors.tintColor
+ )
+ }
+ }
+ }
+
+ if (currentStreak > 0) {
+ Spacer(modifier = Modifier.height(8.dp))
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = "Current Streak",
+ style = JetHabitTheme.typography.body,
+ color = JetHabitTheme.colors.secondaryText
+ )
+
+ Text(
+ text = "$currentStreak ${if (currentStreak == 1) "day" else "days"}",
+ style = JetHabitTheme.typography.toolbar,
+ color = JetHabitTheme.colors.tintColor
+ )
+ }
+ }
+
Spacer(modifier = Modifier.height(12.dp))
Row(
diff --git a/composeApp/src/commonMain/kotlin/screens/stats/StatisticsViewModel.kt b/composeApp/src/commonMain/kotlin/screens/stats/StatisticsViewModel.kt
index d834956..d2e07e0 100644
--- a/composeApp/src/commonMain/kotlin/screens/stats/StatisticsViewModel.kt
+++ b/composeApp/src/commonMain/kotlin/screens/stats/StatisticsViewModel.kt
@@ -33,6 +33,29 @@ class StatisticsViewModel : BaseViewModel, today: LocalDate): Int {
+ var streak = 0
+ var currentDate = today
+
+ while (true) {
+ val dateStr = currentDate.toString()
+ val trackedDay = trackedDays.find { it.date == dateStr }
+
+ if (trackedDay != null && trackedDay.isChecked) {
+ streak++
+ currentDate = currentDate.minus(1, DateTimeUnit.DAY)
+ } else {
+ break
+ }
+ }
+
+ return streak
+ }
+
private fun loadHabitsStatistics() {
viewModelScope.launch(Dispatchers.Default) {
withContext(Dispatchers.Main) {
@@ -90,7 +113,8 @@ class StatisticsViewModel : BaseViewModel 0) trackedCount.toFloat() / totalDays else 0f
+ completionRate = if (totalDays > 0) trackedCount.toFloat() / totalDays else 0f,
+ currentStreak = calculateCurrentStreak(trackedDays, today)
)
}
HabitType.TRACKER -> {
@@ -119,7 +143,8 @@ class StatisticsViewModel : BaseViewModel,
- val completionRate: Float // Percentage of days tracked
+ val completionRate: Float, // Percentage of days tracked
+ val currentStreak: Int = 0 // Number of consecutive days checked from today
)
data class TrackedDay(
diff --git a/composeApp/src/iosMain/kotlin/core/platform/ComposableToBitmap.ios.kt b/composeApp/src/iosMain/kotlin/core/platform/ComposableToBitmap.ios.kt
new file mode 100644
index 0000000..bbdaf2c
--- /dev/null
+++ b/composeApp/src/iosMain/kotlin/core/platform/ComposableToBitmap.ios.kt
@@ -0,0 +1,17 @@
+package core.platform
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.graphics.ImageBitmap
+import kotlinx.cinterop.*
+
+@OptIn(ExperimentalForeignApi::class)
+actual suspend fun composableToBitmap(
+ width: Int,
+ height: Int,
+ content: @Composable () -> Unit
+): ImageBitmap? {
+ // TODO: Implement iOS-specific composable-to-bitmap rendering
+ // This is complex on iOS and may require using UIGraphicsImageRenderer
+ // For now, return null and this will need platform-specific rendering
+ return null
+}
diff --git a/composeApp/src/iosMain/kotlin/core/platform/ShareImage.ios.kt b/composeApp/src/iosMain/kotlin/core/platform/ShareImage.ios.kt
new file mode 100644
index 0000000..03d40db
--- /dev/null
+++ b/composeApp/src/iosMain/kotlin/core/platform/ShareImage.ios.kt
@@ -0,0 +1,99 @@
+package core.platform
+
+import androidx.compose.ui.graphics.ImageBitmap
+import androidx.compose.ui.graphics.toComposeImageBitmap
+import kotlinx.cinterop.*
+import platform.CoreGraphics.*
+import platform.Foundation.*
+import platform.UIKit.*
+import platform.darwin.NSObject
+import kotlin.coroutines.resume
+import kotlin.coroutines.suspendCoroutine
+
+actual class ShareImage {
+ private var currentViewController: UIViewController? = null
+
+ fun setViewController(viewController: UIViewController) {
+ currentViewController = viewController
+ }
+
+ actual suspend fun shareImage(bitmap: ImageBitmap, title: String): Boolean = suspendCoroutine { continuation ->
+ try {
+ // Convert ImageBitmap to UIImage
+ val uiImage = bitmap.toUIImage()
+
+ if (uiImage == null) {
+ continuation.resume(false)
+ return@suspendCoroutine
+ }
+
+ // Create activity items
+ val itemsToShare = listOf(uiImage, title)
+
+ // Create UIActivityViewController
+ val activityViewController = UIActivityViewController(
+ activityItems = itemsToShare,
+ applicationActivities = null
+ )
+
+ // Present the share sheet
+ currentViewController?.presentViewController(
+ activityViewController,
+ animated = true,
+ completion = {
+ continuation.resume(true)
+ }
+ ) ?: run {
+ continuation.resume(false)
+ }
+ } catch (e: Exception) {
+ e.printStackTrace()
+ continuation.resume(false)
+ }
+ }
+
+ @OptIn(ExperimentalForeignApi::class)
+ private fun ImageBitmap.toUIImage(): UIImage? {
+ return try {
+ // TODO: This implementation is incomplete and will likely return null
+ // Need to properly extract pixel data from ImageBitmap and draw into CGContext
+ // Current line below doesn't convert - it just returns the same ImageBitmap type
+ val skikoImage = this.toComposeImageBitmap()
+
+ // Create a bitmap context and draw the image
+ val width = this.width
+ val height = this.height
+ val bytesPerPixel = 4
+ val bytesPerRow = bytesPerPixel * width
+ val bitsPerComponent = 8
+
+ memScoped {
+ val colorSpace = CGColorSpaceCreateDeviceRGB()
+ val bitmapInfo = CGImageAlphaInfo.kCGImageAlphaPremultipliedLast.value or
+ CGBitmapInfo.kCGBitmapByteOrder32Big.value
+
+ val context = CGBitmapContextCreate(
+ data = null,
+ width = width.toULong(),
+ height = height.toULong(),
+ bitsPerComponent = bitsPerComponent.toULong(),
+ bytesPerRow = bytesPerRow.toULong(),
+ space = colorSpace,
+ bitmapInfo = bitmapInfo
+ )
+
+ if (context != null) {
+ // TODO: Need to actually draw the bitmap data into the context here
+ // Currently creates an empty image
+ val cgImage = CGBitmapContextCreateImage(context)
+ if (cgImage != null) {
+ UIImage.imageWithCGImage(cgImage)
+ } else null
+ } else null
+ }
+ } catch (e: Exception) {
+ e.printStackTrace()
+ null
+ }
+ }
+}
diff --git a/composeApp/src/jvmMain/kotlin/core/platform/ComposableToBitmap.jvm.kt b/composeApp/src/jvmMain/kotlin/core/platform/ComposableToBitmap.jvm.kt
new file mode 100644
index 0000000..6a18eb3
--- /dev/null
+++ b/composeApp/src/jvmMain/kotlin/core/platform/ComposableToBitmap.jvm.kt
@@ -0,0 +1,31 @@
+package core.platform
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.graphics.ImageBitmap
+import androidx.compose.ui.graphics.asComposeImageBitmap
+import androidx.compose.ui.graphics.painter.BitmapPainter
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import org.jetbrains.skia.Surface
+import java.awt.image.BufferedImage
+
+actual suspend fun composableToBitmap(
+ width: Int,
+ height: Int,
+ content: @Composable () -> Unit
+): ImageBitmap? = withContext(Dispatchers.Default) {
+ try {
+ // Create a Skia surface for rendering
+ val surface = Surface.makeRasterN32Premul(width, height)
+ val canvas = surface.canvas
+
+ // TODO: Render the composable content to the canvas
+ // This is complex and requires access to Compose internals
+ // For now, return null indicating this needs implementation
+
+ null
+ } catch (e: Exception) {
+ e.printStackTrace()
+ null
+ }
+}
diff --git a/composeApp/src/jvmMain/kotlin/core/platform/ShareImage.jvm.kt b/composeApp/src/jvmMain/kotlin/core/platform/ShareImage.jvm.kt
new file mode 100644
index 0000000..38f1507
--- /dev/null
+++ b/composeApp/src/jvmMain/kotlin/core/platform/ShareImage.jvm.kt
@@ -0,0 +1,41 @@
+package core.platform
+
+import androidx.compose.ui.graphics.ImageBitmap
+import androidx.compose.ui.graphics.toAwtImage
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import java.awt.FileDialog
+import java.awt.Frame
+import java.io.File
+import javax.imageio.ImageIO
+
+actual class ShareImage {
+ actual suspend fun shareImage(bitmap: ImageBitmap, title: String): Boolean {
+ return withContext(Dispatchers.IO) {
+ try {
+ // Convert ImageBitmap to BufferedImage
+ val bufferedImage = bitmap.toAwtImage()
+
+ // Show save dialog
+ val fileDialog = FileDialog(null as Frame?, "Save Streak Image", FileDialog.SAVE).apply {
+ file = "streak_${System.currentTimeMillis()}.png"
+ isVisible = true
+ }
+
+ val directory = fileDialog.directory
+ val filename = fileDialog.file
+
+ if (directory != null && filename != null) {
+ val file = File(directory, filename)
+ ImageIO.write(bufferedImage, "png", file)
+ true
+ } else {
+ false
+ }
+ } catch (e: Exception) {
+ e.printStackTrace()
+ false
+ }
+ }
+ }
+}
diff --git a/composeApp/src/macosMain/kotlin/core/platform/ComposableToBitmap.macos.kt b/composeApp/src/macosMain/kotlin/core/platform/ComposableToBitmap.macos.kt
new file mode 100644
index 0000000..86acbc1
--- /dev/null
+++ b/composeApp/src/macosMain/kotlin/core/platform/ComposableToBitmap.macos.kt
@@ -0,0 +1,14 @@
+package core.platform
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.graphics.ImageBitmap
+
+actual suspend fun composableToBitmap(
+ width: Int,
+ height: Int,
+ content: @Composable () -> Unit
+): ImageBitmap? {
+ // TODO: Implement macOS-specific composable-to-bitmap rendering
+ // For now, return null
+ return null
+}
diff --git a/composeApp/src/macosMain/kotlin/core/platform/ShareImage.macos.kt b/composeApp/src/macosMain/kotlin/core/platform/ShareImage.macos.kt
new file mode 100644
index 0000000..ac0a293
--- /dev/null
+++ b/composeApp/src/macosMain/kotlin/core/platform/ShareImage.macos.kt
@@ -0,0 +1,15 @@
+package core.platform
+
+import androidx.compose.ui.graphics.ImageBitmap
+import kotlinx.cinterop.*
+
+@OptIn(ExperimentalForeignApi::class)
+actual class ShareImage {
+ actual suspend fun shareImage(bitmap: ImageBitmap, title: String): Boolean {
+ // TODO: Implement macOS sharing using NSSharingServicePicker
+ // This requires AppKit interop which is complex in Kotlin/Native
+ // For now, return false indicating sharing is not implemented
+ println("macOS sharing not yet implemented")
+ return false
+ }
+}
diff --git a/specs/SPEC_share-streak.md b/specs/SPEC_share-streak.md
new file mode 100644
index 0000000..5bf0cd9
--- /dev/null
+++ b/specs/SPEC_share-streak.md
@@ -0,0 +1,14 @@
+# Specification: Share streak
+
+## Summary
+As user i want to share habits streak (or other achievements)
+
+## 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_streak.md b/specs/SPEC_share_streak.md
new file mode 100644
index 0000000..6da5d67
--- /dev/null
+++ b/specs/SPEC_share_streak.md
@@ -0,0 +1,173 @@
+# Spec: Share Habit Streak
+
+## Summary
+
+Add the ability for users to share their current habit streak as a styled image card via the OS share sheet. The share button is accessible from the Statistics screen, generates a minimal branded card showing the habit name and consecutive-day streak count, and supports all platforms (Android, iOS, macOS, Desktop JVM).
+
+## Requirements
+
+### What is shared
+- A **shareable image card** containing:
+ - Habit title (e.g., "Morning Run")
+ - Current streak count (e.g., "14 days")
+ - App branding ("JetHabit")
+- The card follows the user's current theme (light or dark mode) and active color style.
+
+### Streak calculation
+- **Current streak** = number of consecutive days the habit has been checked, counting backwards from **today**.
+- The streak is **strict**: if today is not checked, the streak is 0. If today is checked but yesterday is not, the streak is 1.
+- Only days the habit exists (between startDate and endDate) are considered. Days before the habit's startDate do not count.
+- For habits with specific `daysToCheck`, all calendar days still count toward the streak (not just scheduled days). A missed non-scheduled day still breaks the streak.
+
+### Entry point
+- A **share button** on each habit's statistics item in the **Statistics screen** (`StatisticsScreen.kt`).
+- The share button is **hidden/disabled when the streak is 0**.
+
+### User flow
+1. User opens the Statistics screen.
+2. Each habit with a streak > 0 shows a share icon/button.
+3. User taps the share button.
+4. A **preview dialog** appears showing the rendered card.
+5. User taps "Share" in the dialog to open the OS share sheet with the card image.
+6. User can dismiss the dialog to cancel.
+
+### Platform support
+- **Android**: Render Compose card to `Bitmap`, share via `Intent.ACTION_SEND` with `image/png` MIME type using a `FileProvider`.
+- **iOS**: Render to `UIImage` via `CGContext`/`UIGraphicsImageRenderer`, share via `UIActivityViewController`.
+- **macOS**: Render to `NSImage`, share via `NSSharingServicePicker`.
+- **Desktop JVM (non-macOS)**: Render to `BufferedImage`, save to temp file, open system share or file-save dialog.
+
+### Visual design
+- Card dimensions: ~1080x566px (roughly 1.91:1 ratio, good for social media).
+- Background color: `JetHabitTheme.colors.primaryBackground` (theme-aware).
+- Habit title: `JetHabitTheme.colors.primaryText`, prominent size.
+- Streak count: Large, bold, accent color (`tintColor`).
+- Branding: Small "JetHabit" text at bottom, secondary text color.
+- Rounded corners on the card.
+
+## Files to Create
+
+| File | Purpose |
+|------|---------|
+| `feature/statistics/ui/ShareStreakCard.kt` | Composable for the share card layout |
+| `feature/statistics/ui/ShareStreakDialog.kt` | Preview dialog with Share/Cancel buttons |
+| `feature/statistics/presentation/models/StreakData.kt` | Data class for streak info passed to the card |
+| `core/platform/ShareImage.kt` | `expect` declaration for platform image sharing |
+| `androidMain/.../ShareImage.android.kt` | Android `actual` implementation (Intent + FileProvider) |
+| `iosMain/.../ShareImage.ios.kt` | iOS `actual` implementation (UIActivityViewController) |
+| `macosMain/.../ShareImage.macos.kt` | macOS `actual` implementation (NSSharingServicePicker) |
+| `jvmMain/.../ShareImage.jvm.kt` | Desktop JVM `actual` implementation (file save) |
+
+## Files to Modify
+
+| File | Change |
+|------|--------|
+| `feature/statistics/presentation/StatisticsViewModel.kt` | Add streak calculation logic; expose streak per habit in ViewState |
+| `feature/statistics/ui/models/StatisticsViewState.kt` | Add streak field to `HabitStatistics` or create wrapper |
+| `feature/statistics/ui/StatisticsItem.kt` | Add share button (visible when streak > 0) |
+| `feature/statistics/ui/StatisticsScreen.kt` | Wire up share dialog state and display |
+| `domain/HabitStatistics.kt` | Add `currentStreak: Int` field |
+| Platform `AndroidManifest.xml` or similar | Add FileProvider config for Android sharing if not present |
+
+## Implementation Approach
+
+### 1. Streak Calculation (ViewModel layer)
+
+Add a function in `StatisticsViewModel` that, given a habit's list of `DailyEntity` records, calculates the current streak:
+
+```kotlin
+fun calculateCurrentStreak(habitId: String, dailyRecords: List): Int {
+ val today = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date
+ var streak = 0
+ var date = today
+ while (true) {
+ val dateStr = date.toString() // ISO format
+ val record = dailyRecords.find { it.timestamp == dateStr && it.isChecked }
+ if (record != null) {
+ streak++
+ date = date.minus(1, DateTimeUnit.DAY)
+ } else {
+ break
+ }
+ }
+ return streak
+}
+```
+
+Add `currentStreak: Int` to `HabitStatistics` data class and populate it during the statistics loading flow.
+
+### 2. Share Card Composable
+
+Create `ShareStreakCard` as a pure Composable that renders the card using `JetHabitTheme` colors. This composable is used both for the preview dialog and for bitmap capture.
+
+### 3. Preview Dialog
+
+`ShareStreakDialog` wraps the card in an `AlertDialog` or custom dialog with "Share" and "Cancel" actions.
+
+### 4. Platform Image Sharing (`expect`/`actual`)
+
+Define a common interface:
+
+```kotlin
+// commonMain
+expect class ImageSharer {
+ fun shareImage(bitmap: ImageBitmap, title: String)
+}
+```
+
+Each platform implements this differently:
+- **Android**: Write `ImageBitmap` to a temp file via `FileProvider`, launch `Intent.ACTION_SEND`.
+- **iOS**: Convert to `UIImage`, present `UIActivityViewController`.
+- **macOS**: Convert to `NSImage`, present `NSSharingServicePicker`.
+- **Desktop JVM**: Convert to `BufferedImage`, use `JFileChooser` to save or copy to clipboard.
+
+### 5. Bitmap Capture
+
+Use Compose's `GraphicsLayer` or `Canvas`-based approach to render the `ShareStreakCard` composable to an `ImageBitmap` off-screen. This is the trickiest cross-platform piece:
+- On Android: `AndroidView` with `View.drawToBitmap()` or `Picture`-based recording.
+- On other platforms: Use Compose's `drawIntoCanvas` / `Canvas` APIs.
+
+Alternatively, render the composable into a `GraphicsLayer`, call `toImageBitmap()` (available in newer Compose Multiplatform versions).
+
+### 6. Statistics Screen Integration
+
+Add a share icon button to `StatisticsItem`. On click, set dialog state in the screen to show `ShareStreakDialog` for that habit.
+
+## Acceptance Criteria
+
+1. Statistics screen shows a share button on each habit with a streak > 0.
+2. Habits with streak = 0 do not show the share button.
+3. Tapping share opens a preview dialog with the correctly themed card.
+4. The card displays the correct habit name, streak count, and "JetHabit" branding.
+5. Tapping "Share" in the dialog opens the native OS share sheet with the card as a PNG image.
+6. Dismissing the dialog cancels the share flow.
+7. Works on Android, iOS, macOS, and Desktop JVM.
+8. Card respects current light/dark theme and color style.
+
+## Edge Cases and Risks
+
+| Edge Case | Handling |
+|-----------|----------|
+| Streak is 0 | Share button hidden/disabled |
+| Habit has no daily records at all | Streak is 0, button hidden |
+| Habit endDate is in the past | Still calculate streak (may be 0 if not checked recently) |
+| Very long habit title | Truncate with ellipsis on the card |
+| Very large streak number (e.g., 1000+) | Display as-is; card layout should handle large numbers |
+| Bitmap capture fails | Show error toast/snackbar, don't crash |
+| Share sheet dismissed by user | No action needed, dialog stays open for retry |
+| Platform share API unavailable | Fallback to clipboard copy or file save |
+
+### Risks
+- **Compose-to-bitmap capture** is the highest-risk area. Compose Multiplatform's `GraphicsLayer.toImageBitmap()` API availability varies across versions. May need platform-specific capture approaches.
+- **iOS/macOS interop**: Presenting `UIActivityViewController` / `NSSharingServicePicker` from Compose requires bridging to native view controllers.
+- **FileProvider setup on Android**: Needs XML configuration and manifest entry if not already present.
+
+## Out of Scope
+
+- Sharing completion rate, tracker progress, or calendar heatmaps (future enhancement).
+- Text-based sharing or clipboard copy (only image card for now).
+- Deep links or social media-specific integrations.
+- Custom card themes or user-selectable card designs.
+- Sharing from screens other than Statistics.
+- Milestone/badge system.
+- Scheduled days-only streak counting (all calendar days count).