Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions composeApp/src/androidMain/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,16 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>

<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>

</manifest>
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
Original file line number Diff line number Diff line change
@@ -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
}
}
}
}
4 changes: 4 additions & 0 deletions composeApp/src/androidMain/res/xml/file_paths.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<cache-path name="shared_images" path="images/" />
</paths>
Original file line number Diff line number Diff line change
@@ -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?
18 changes: 18 additions & 0 deletions composeApp/src/commonMain/kotlin/core/platform/ShareImage.kt
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,29 @@ class StatisticsViewModel : BaseViewModel<StatisticsViewState, StatisticsAction,
}
}

/**
* Calculate current streak counting backwards from today.
* Streak is strict: if today is not checked, streak is 0.
*/
private fun calculateCurrentStreak(trackedDays: List<TrackedDay>, 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) {
Expand Down Expand Up @@ -90,7 +113,8 @@ class StatisticsViewModel : BaseViewModel<StatisticsViewState, StatisticsAction,
id = habit.id,
title = habit.title,
trackedDays = trackedDays,
completionRate = if (totalDays > 0) trackedCount.toFloat() / totalDays else 0f
completionRate = if (totalDays > 0) trackedCount.toFloat() / totalDays else 0f,
currentStreak = calculateCurrentStreak(trackedDays, today)
)
}
HabitType.TRACKER -> {
Expand Down Expand Up @@ -119,7 +143,8 @@ class StatisticsViewModel : BaseViewModel<StatisticsViewState, StatisticsAction,
id = habit.id,
title = habit.title,
trackedDays = trackedDays,
completionRate = trackedCount.toFloat() / 30f
completionRate = if (trackedDays.isNotEmpty()) trackedCount.toFloat() / trackedDays.size else 0f,
currentStreak = calculateCurrentStreak(trackedDays, today)
)
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package feature.statistics.presentation.models

/**
* Data class containing streak information for sharing.
*
* @param habitTitle The name of the habit
* @param streakCount The current consecutive-day streak count
* @param habitId The unique identifier for the habit
*/
data class StreakData(
val habitTitle: String,
val streakCount: Int,
val habitId: String
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
package feature.statistics.ui

import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import feature.statistics.presentation.models.StreakData
import ui.themes.JetHabitTheme

/**
* Composable card displaying habit streak information for sharing.
* Card dimensions: ~1080x566px (1.91:1 ratio, optimized for social media).
*
* @param streakData The streak information to display
* @param modifier Optional modifier for the card
*/
@Composable
fun ShareStreakCard(
streakData: StreakData,
modifier: Modifier = Modifier
) {
Box(
modifier = modifier
.size(width = 1080.dp, height = 566.dp)
.background(
color = JetHabitTheme.colors.primaryBackground,
shape = RoundedCornerShape(24.dp)
)
.padding(48.dp),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.fillMaxSize()
) {
Spacer(modifier = Modifier.weight(0.5f))

// Main content area
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.weight(2f)
) {
// Habit title
Text(
text = streakData.habitTitle,
style = JetHabitTheme.typography.heading.copy(
fontSize = 48.sp,
fontWeight = FontWeight.Bold
),
color = JetHabitTheme.colors.primaryText,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(bottom = 32.dp)
)

// Streak count
Text(
text = "${streakData.streakCount}",
style = JetHabitTheme.typography.heading.copy(
fontSize = 120.sp,
fontWeight = FontWeight.Bold
),
color = JetHabitTheme.colors.tintColor,
modifier = Modifier.padding(bottom = 16.dp)
)

// "days" label
Text(
text = if (streakData.streakCount == 1) "day" else "days",
style = JetHabitTheme.typography.body.copy(
fontSize = 36.sp,
fontWeight = FontWeight.Medium
),
color = JetHabitTheme.colors.secondaryText
)
}

Spacer(modifier = Modifier.weight(0.5f))

// Branding
Text(
text = "JetHabit",
style = JetHabitTheme.typography.caption.copy(
fontSize = 24.sp
),
color = JetHabitTheme.colors.secondaryText
)
}
}
}
Loading
Loading