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>
94 changes: 94 additions & 0 deletions composeApp/src/androidMain/kotlin/core/platform/ShareService.kt
Original file line number Diff line number Diff line change
@@ -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()
}
}
}
9 changes: 8 additions & 1 deletion composeApp/src/androidMain/kotlin/di/Providers.kt
Original file line number Diff line number Diff line change
@@ -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<ImagePicker>() with singleton {
bind<ImagePicker>() with singleton {
instance<PlatformConfiguration>().imagePicker
}
}

actual fun DI.Builder.provideShareService() {
bind<ShareService>() with singleton {
ShareService(instance<PlatformConfiguration>().application)
}
}
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="share_images" path="share_images/" />
</paths>
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package core.platform

expect class ShareService {
suspend fun shareImage(imageBytes: ByteArray, text: String)
}
1 change: 1 addition & 0 deletions composeApp/src/commonMain/kotlin/di/PlatformSDK.kt
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ object PlatformSDK {

val platformModule = DI.Module("platform") {
provideImagePicker()
provideShareService()
}

_di = DI {
Expand Down
3 changes: 2 additions & 1 deletion composeApp/src/commonMain/kotlin/di/Providers.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@ package di

import org.kodein.di.DI

expect fun DI.Builder.provideImagePicker()
expect fun DI.Builder.provideImagePicker()
expect fun DI.Builder.provideShareService()
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -9,12 +10,16 @@ val detailModule = DI.Module("detailModule") {
bind<GetDetailInfoUseCase>() with provider {
GetDetailInfoUseCase(instance())
}

bind<DeleteHabitUseCase>() with provider {
DeleteHabitUseCase(instance())
}

bind<UpdateHabitUseCase>() with provider {
UpdateHabitUseCase(instance())
}

bind<CalculateStreakUseCase>() with provider {
CalculateStreakUseCase(dailyDao = instance(), habitDao = instance())
}
}
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading
Loading