From f9ff2a2342b8ffc0531a39cd5a941bca00bb772a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Jan 2026 11:18:36 +0000 Subject: [PATCH 1/4] Add specification for share habits streak feature Defines the approach for sharing habit streaks as image cards via the platform's native share sheet, including streak calculation, card rendering, and cross-platform share service. Co-Authored-By: Claude Opus 4.5 --- specs/SPEC_share_habits.md | 171 +++++++++++++++++++++++++++++++++++++ 1 file changed, 171 insertions(+) create mode 100644 specs/SPEC_share_habits.md 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 From 3ffde28d7534e0fba4dc0585e638e6ccb55a11ea Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Jan 2026 11:18:43 +0000 Subject: [PATCH 2/4] Add fallback spec for Share habits --- specs/SPEC_share-habits.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 specs/SPEC_share-habits.md 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.* From 03bb4c9e11e1f132798b20a6b6c7dfebb06a59c9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Jan 2026 11:31:45 +0000 Subject: [PATCH 3/4] Implement share habits streak feature Add ability for users to share their habit streak as text via the platform's native share sheet. Users can share from the habit detail screen, including the habit name, current streak count, and completion rate. Key changes: - Add CalculateStreakUseCase to calculate consecutive days habit streak - Create StreakCard composable for future visual streak card - Implement ShareService with platform-specific implementations: * Android: Intent.ACTION_SEND with FileProvider * iOS: UIActivityViewController * Desktop: Clipboard copy * Web: Web Share API with clipboard fallback - Add share button to DetailView - Update DetailViewModel to handle share events and calculate streak - Configure Android FileProvider for future image sharing - Register ShareService and CalculateStreakUseCase in DI modules The implementation currently shares text content. Image generation from the StreakCard composable can be added as a future enhancement. Co-Authored-By: Claude Sonnet 4.5 --- .../src/androidMain/AndroidManifest.xml | 10 ++ .../kotlin/core/platform/ShareService.kt | 46 ++++++ .../src/androidMain/kotlin/di/Providers.kt | 9 +- .../src/androidMain/res/xml/file_paths.xml | 4 + .../kotlin/core/platform/ShareService.kt | 5 + .../src/commonMain/kotlin/di/PlatformSDK.kt | 1 + .../src/commonMain/kotlin/di/Providers.kt | 3 +- .../kotlin/feature/detail/di/DetailModule.kt | 9 +- .../detail/domain/CalculateStreakUseCase.kt | 128 ++++++++++++++++ .../detail/presentation/DetailViewModel.kt | 26 +++- .../detail/presentation/models/DetailEvent.kt | 1 + .../presentation/models/DetailViewState.kt | 5 +- .../kotlin/feature/detail/ui/DetailView.kt | 57 +++++++ .../kotlin/feature/share/ui/StreakCard.kt | 140 ++++++++++++++++++ .../kotlin/core/platform/ShareService.kt | 40 +++++ .../src/iosMain/kotlin/di/Providers.ios.kt | 5 + .../kotlin/core/platform/ShareService.kt | 64 ++++++++ .../src/jsMain/kotlin/di/Providers.js.kt | 20 +++ .../kotlin/core/platform/ShareService.kt | 45 ++++++ .../jvmMain/kotlin/di/Providers.desktop.kt | 5 + 20 files changed, 617 insertions(+), 6 deletions(-) create mode 100644 composeApp/src/androidMain/kotlin/core/platform/ShareService.kt create mode 100644 composeApp/src/androidMain/res/xml/file_paths.xml create mode 100644 composeApp/src/commonMain/kotlin/core/platform/ShareService.kt create mode 100644 composeApp/src/commonMain/kotlin/feature/detail/domain/CalculateStreakUseCase.kt create mode 100644 composeApp/src/commonMain/kotlin/feature/share/ui/StreakCard.kt create mode 100644 composeApp/src/iosMain/kotlin/core/platform/ShareService.kt create mode 100644 composeApp/src/jsMain/kotlin/core/platform/ShareService.kt create mode 100644 composeApp/src/jsMain/kotlin/di/Providers.js.kt create mode 100644 composeApp/src/jvmMain/kotlin/core/platform/ShareService.kt 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..ea22493 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/core/platform/ShareService.kt @@ -0,0 +1,46 @@ +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 { + // Save image to cache directory + val cacheDir = File(context.cacheDir, "share_images") + if (!cacheDir.exists()) { + cacheDir.mkdirs() + } + + 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() + } + } +} 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..8a4ed46 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/feature/detail/domain/CalculateStreakUseCase.kt @@ -0,0 +1,128 @@ +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() + if (mostRecentChecked == null || mostRecentChecked < today.minus(1, DateTimeUnit.DAY)) { + // If no recent check or gap exists, streak is broken + return 0 + } + 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..26477b0 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,7 @@ 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() } } @@ -62,6 +65,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..237475f --- /dev/null +++ b/composeApp/src/iosMain/kotlin/core/platform/ShareService.kt @@ -0,0 +1,40 @@ +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) { + val nsData = imageBytes.usePinned { pinned -> + NSData.create( + bytes = pinned.addressOf(0), + length = imageBytes.size.toULong() + ) + } + + val itemsToShare = listOf(text, nsData) + + val activityViewController = UIActivityViewController( + activityItems = itemsToShare, + applicationActivities = null + ) + + currentViewController?.presentViewController( + activityViewController, + animated = true, + completion = null + ) + } +} 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..43966d6 --- /dev/null +++ b/composeApp/src/jsMain/kotlin/core/platform/ShareService.kt @@ -0,0 +1,64 @@ +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 { + // 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) { + val shareData = js("{}") + shareData.text = text + shareData.files = arrayOf( + js("new File([blob], 'streak.png', { type: 'image/png' })").also { + it.asDynamic().blob = blob + } + ) + + if (navigator.canShare(shareData)) { + (navigator.share(shareData) as Promise).catch { error -> + console.error("Error sharing:", error) + fallbackToClipboard(text) + } + return + } + } + + // Fallback: Copy text to clipboard + fallbackToClipboard(text) + } catch (e: Exception) { + console.error("Share 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..c19680b --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/core/platform/ShareService.kt @@ -0,0 +1,45 @@ +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 { + // Convert bytes to BufferedImage + val inputStream = ByteArrayInputStream(imageBytes) + val image = ImageIO.read(inputStream) + + // 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() + } + } + + 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 From 85ccb6bb6bf6726b083233fc98691ca6e147476c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Jan 2026 11:35:06 +0000 Subject: [PATCH 4/4] Fix code review issues in share habits feature - Fix streak calculation logic to properly handle days not tracked - Add ShareDismissed event to properly reset sharing state - Add comprehensive error handling to all ShareService implementations - Add file cleanup for Android to prevent cache bloat - Add fallback to text-only sharing when image is empty or fails - Improve error messages and logging across all platforms Co-Authored-By: Claude Sonnet 4.5 --- .../kotlin/core/platform/ShareService.kt | 50 +++++++++++++++++- .../detail/domain/CalculateStreakUseCase.kt | 28 +++++++++- .../detail/presentation/DetailViewModel.kt | 1 + .../detail/presentation/models/DetailEvent.kt | 1 + .../kotlin/feature/detail/ui/DetailView.kt | 3 +- .../kotlin/core/platform/ShareService.kt | 47 ++++++++++------- .../kotlin/core/platform/ShareService.kt | 51 ++++++++++++++----- .../kotlin/core/platform/ShareService.kt | 30 +++++++++++ 8 files changed, 177 insertions(+), 34 deletions(-) diff --git a/composeApp/src/androidMain/kotlin/core/platform/ShareService.kt b/composeApp/src/androidMain/kotlin/core/platform/ShareService.kt index ea22493..146a8ec 100644 --- a/composeApp/src/androidMain/kotlin/core/platform/ShareService.kt +++ b/composeApp/src/androidMain/kotlin/core/platform/ShareService.kt @@ -9,12 +9,25 @@ 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()) { - cacheDir.mkdirs() + 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) @@ -41,6 +54,41 @@ actual class ShareService(private val context: Context) { 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/commonMain/kotlin/feature/detail/domain/CalculateStreakUseCase.kt b/composeApp/src/commonMain/kotlin/feature/detail/domain/CalculateStreakUseCase.kt index 8a4ed46..2508e05 100644 --- a/composeApp/src/commonMain/kotlin/feature/detail/domain/CalculateStreakUseCase.kt +++ b/composeApp/src/commonMain/kotlin/feature/detail/domain/CalculateStreakUseCase.kt @@ -49,10 +49,34 @@ class CalculateStreakUseCase( if (!checkedDates.contains(today)) { // Find the most recent checked date val mostRecentChecked = checkedDates.maxOrNull() - if (mostRecentChecked == null || mostRecentChecked < today.minus(1, DateTimeUnit.DAY)) { - // If no recent check or gap exists, streak is broken + + // 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 } diff --git a/composeApp/src/commonMain/kotlin/feature/detail/presentation/DetailViewModel.kt b/composeApp/src/commonMain/kotlin/feature/detail/presentation/DetailViewModel.kt index 26477b0..b5347ac 100644 --- a/composeApp/src/commonMain/kotlin/feature/detail/presentation/DetailViewModel.kt +++ b/composeApp/src/commonMain/kotlin/feature/detail/presentation/DetailViewModel.kt @@ -45,6 +45,7 @@ class DetailViewModel(private val habitId: String) : BaseViewModel selectDate(viewEvent.value) is DetailEvent.NewValueChanged -> parseTrackerValue(viewEvent.value) DetailEvent.ShareClicked -> calculateStreakForShare() + DetailEvent.ShareDismissed -> viewState = viewState.copy(isSharing = false) } } diff --git a/composeApp/src/commonMain/kotlin/feature/detail/presentation/models/DetailEvent.kt b/composeApp/src/commonMain/kotlin/feature/detail/presentation/models/DetailEvent.kt index 8b0935d..49bbd4e 100644 --- a/composeApp/src/commonMain/kotlin/feature/detail/presentation/models/DetailEvent.kt +++ b/composeApp/src/commonMain/kotlin/feature/detail/presentation/models/DetailEvent.kt @@ -11,4 +11,5 @@ sealed class DetailEvent { data class DateSelected(val value: LocalDate) : DetailEvent() data class NewValueChanged(val value: String?) : DetailEvent() data object ShareClicked : DetailEvent() + data object ShareDismissed : DetailEvent() } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/feature/detail/ui/DetailView.kt b/composeApp/src/commonMain/kotlin/feature/detail/ui/DetailView.kt index f02caa2..d0e4df7 100644 --- a/composeApp/src/commonMain/kotlin/feature/detail/ui/DetailView.kt +++ b/composeApp/src/commonMain/kotlin/feature/detail/ui/DetailView.kt @@ -219,8 +219,7 @@ internal fun DetailView( completionRate = viewState.completionRate, isGoodHabit = viewState.isGood, onDismiss = { - // Reset sharing state by triggering a refresh - eventHandler.invoke(DetailEvent.CloseScreen) + eventHandler.invoke(DetailEvent.ShareDismissed) } ) } diff --git a/composeApp/src/iosMain/kotlin/core/platform/ShareService.kt b/composeApp/src/iosMain/kotlin/core/platform/ShareService.kt index 237475f..2d38bde 100644 --- a/composeApp/src/iosMain/kotlin/core/platform/ShareService.kt +++ b/composeApp/src/iosMain/kotlin/core/platform/ShareService.kt @@ -17,24 +17,37 @@ actual class ShareService { } actual suspend fun shareImage(imageBytes: ByteArray, text: String) { - val nsData = imageBytes.usePinned { pinned -> - NSData.create( - bytes = pinned.addressOf(0), - length = imageBytes.size.toULong() - ) - } - - val itemsToShare = listOf(text, nsData) + 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 - ) + val activityViewController = UIActivityViewController( + activityItems = itemsToShare, + applicationActivities = null + ) - currentViewController?.presentViewController( - activityViewController, - animated = true, - completion = 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/jsMain/kotlin/core/platform/ShareService.kt b/composeApp/src/jsMain/kotlin/core/platform/ShareService.kt index 43966d6..9e9c7d8 100644 --- a/composeApp/src/jsMain/kotlin/core/platform/ShareService.kt +++ b/composeApp/src/jsMain/kotlin/core/platform/ShareService.kt @@ -9,6 +9,12 @@ 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 -> @@ -21,20 +27,21 @@ actual class ShareService { // Try using Web Share API if available val navigator = window.navigator.asDynamic() if (navigator.share != undefined && navigator.canShare != undefined) { - val shareData = js("{}") - shareData.text = text - shareData.files = arrayOf( - js("new File([blob], 'streak.png', { type: 'image/png' })").also { - it.asDynamic().blob = blob - } - ) + 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) + if (navigator.canShare(shareData)) { + (navigator.share(shareData) as Promise).catch { error -> + console.error("Error sharing:", error) + fallbackToClipboard(text) + } + return } - return + } catch (e: dynamic) { + console.error("Web Share API error:", e) } } @@ -46,6 +53,26 @@ actual class ShareService { } } + 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() diff --git a/composeApp/src/jvmMain/kotlin/core/platform/ShareService.kt b/composeApp/src/jvmMain/kotlin/core/platform/ShareService.kt index c19680b..acd0ef6 100644 --- a/composeApp/src/jvmMain/kotlin/core/platform/ShareService.kt +++ b/composeApp/src/jvmMain/kotlin/core/platform/ShareService.kt @@ -10,10 +10,26 @@ 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) @@ -23,9 +39,23 @@ actual class ShareService { 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)