diff --git a/.daem0nmcp/storage/daem0nmcp.db b/.daem0nmcp/storage/daem0nmcp.db
index cf89c8df6..f1bde6215 100644
Binary files a/.daem0nmcp/storage/daem0nmcp.db and b/.daem0nmcp/storage/daem0nmcp.db differ
diff --git a/androidApp/src/main/AndroidManifest.xml b/androidApp/src/main/AndroidManifest.xml
index c5f626d21..0f76cd4bd 100644
--- a/androidApp/src/main/AndroidManifest.xml
+++ b/androidApp/src/main/AndroidManifest.xml
@@ -63,6 +63,17 @@
android:name=".service.WorkoutForegroundService"
android:foregroundServiceType="connectedDevice"
android:exported="false" />
+
+
+
+
+
diff --git a/androidApp/src/main/res/xml/file_paths.xml b/androidApp/src/main/res/xml/file_paths.xml
new file mode 100644
index 000000000..2dee767ad
--- /dev/null
+++ b/androidApp/src/main/res/xml/file_paths.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/shared/src/androidMain/kotlin/com/devil/phoenixproject/data/local/DriverFactory.android.kt b/shared/src/androidMain/kotlin/com/devil/phoenixproject/data/local/DriverFactory.android.kt
index bc8652284..df39d2fa7 100644
--- a/shared/src/androidMain/kotlin/com/devil/phoenixproject/data/local/DriverFactory.android.kt
+++ b/shared/src/androidMain/kotlin/com/devil/phoenixproject/data/local/DriverFactory.android.kt
@@ -1,6 +1,7 @@
package com.devil.phoenixproject.data.local
import android.content.Context
+import android.database.sqlite.SQLiteException
import android.util.Log
import androidx.sqlite.db.SupportSQLiteDatabase
import app.cash.sqldelight.db.SqlDriver
@@ -25,9 +26,208 @@ actual class DriverFactory(private val context: Context) {
db.execSQL("PRAGMA foreign_keys = ON;")
}
- // SQLDelight handles migrations automatically via .sqm files.
- // The parent Callback class applies migrations 1.sqm, 2.sqm, 3.sqm, 4.sqm etc.
- // We do NOT override onUpgrade - let SQLDelight handle it properly.
+ override fun onUpgrade(db: SupportSQLiteDatabase, oldVersion: Int, newVersion: Int) {
+ // Custom migration handler that's resilient to partial migrations.
+ // SQLite ALTER TABLE ADD COLUMN fails if column exists - we catch and continue.
+ Log.i(TAG, "Upgrading database from version $oldVersion to $newVersion")
+
+ for (version in oldVersion until newVersion) {
+ try {
+ // Let SQLDelight apply the migration
+ VitruvianDatabase.Schema.migrate(
+ driver = createTempDriver(db),
+ oldVersion = version.toLong(),
+ newVersion = (version + 1).toLong()
+ )
+ Log.i(TAG, "Successfully applied migration ${version + 1}")
+ } catch (e: SQLiteException) {
+ // Handle "duplicate column" errors from partially applied migrations
+ if (e.message?.contains("duplicate column") == true) {
+ Log.w(TAG, "Migration ${version + 1} partially applied (duplicate column), continuing...")
+ // Force run remaining statements in migration by executing directly
+ applyMigrationResilient(db, version + 1)
+ } else if (e.message?.contains("no such table") == true) {
+ // Table doesn't exist - try to create it
+ Log.w(TAG, "Migration ${version + 1} failed (no such table), attempting resilient apply...")
+ applyMigrationResilient(db, version + 1)
+ } else {
+ // Re-throw other errors
+ throw e
+ }
+ }
+ }
+ }
+
+ /**
+ * Apply migration statements one by one, ignoring duplicate column/table errors.
+ * This handles cases where a migration was partially applied.
+ */
+ private fun applyMigrationResilient(db: SupportSQLiteDatabase, version: Int) {
+ val statements = getMigrationStatements(version)
+ for (sql in statements) {
+ try {
+ db.execSQL(sql)
+ } catch (e: SQLiteException) {
+ val msg = e.message ?: ""
+ if (msg.contains("duplicate column") ||
+ msg.contains("already exists") ||
+ msg.contains("table .* already exists".toRegex())) {
+ Log.w(TAG, "Skipping (already exists): ${sql.take(60)}...")
+ } else {
+ Log.e(TAG, "Migration statement failed: $sql", e)
+ // Continue with other statements
+ }
+ }
+ }
+ }
+
+ /**
+ * Get SQL statements for a specific migration version.
+ * Only includes migrations that need resilient handling.
+ */
+ private fun getMigrationStatements(version: Int): List {
+ return when (version) {
+ 5 -> listOf(
+ "ALTER TABLE WorkoutSession ADD COLUMN peakForceConcentricA REAL",
+ "ALTER TABLE WorkoutSession ADD COLUMN peakForceConcentricB REAL",
+ "ALTER TABLE WorkoutSession ADD COLUMN peakForceEccentricA REAL",
+ "ALTER TABLE WorkoutSession ADD COLUMN peakForceEccentricB REAL",
+ "ALTER TABLE WorkoutSession ADD COLUMN avgForceConcentricA REAL",
+ "ALTER TABLE WorkoutSession ADD COLUMN avgForceConcentricB REAL",
+ "ALTER TABLE WorkoutSession ADD COLUMN avgForceEccentricA REAL",
+ "ALTER TABLE WorkoutSession ADD COLUMN avgForceEccentricB REAL",
+ "ALTER TABLE WorkoutSession ADD COLUMN heaviestLiftKg REAL",
+ "ALTER TABLE WorkoutSession ADD COLUMN totalVolumeKg REAL",
+ "ALTER TABLE WorkoutSession ADD COLUMN estimatedCalories REAL",
+ "ALTER TABLE WorkoutSession ADD COLUMN warmupAvgWeightKg REAL",
+ "ALTER TABLE WorkoutSession ADD COLUMN workingAvgWeightKg REAL",
+ "ALTER TABLE WorkoutSession ADD COLUMN burnoutAvgWeightKg REAL",
+ "ALTER TABLE WorkoutSession ADD COLUMN peakWeightKg REAL",
+ "ALTER TABLE WorkoutSession ADD COLUMN rpe INTEGER"
+ )
+ 6 -> listOf(
+ """CREATE TABLE IF NOT EXISTS TrainingCycle (
+ id TEXT PRIMARY KEY NOT NULL,
+ name TEXT NOT NULL,
+ description TEXT,
+ created_at INTEGER NOT NULL,
+ is_active INTEGER NOT NULL DEFAULT 0
+ )""",
+ """CREATE TABLE IF NOT EXISTS CycleDay (
+ id TEXT PRIMARY KEY NOT NULL,
+ cycle_id TEXT NOT NULL,
+ day_number INTEGER NOT NULL,
+ name TEXT,
+ routine_id TEXT,
+ is_rest_day INTEGER NOT NULL DEFAULT 0,
+ echo_level TEXT,
+ eccentric_load_percent INTEGER,
+ weight_progression_percent REAL,
+ rep_modifier INTEGER,
+ rest_time_override_seconds INTEGER,
+ FOREIGN KEY (cycle_id) REFERENCES TrainingCycle(id) ON DELETE CASCADE,
+ FOREIGN KEY (routine_id) REFERENCES Routine(id) ON DELETE SET NULL
+ )""",
+ """CREATE TABLE IF NOT EXISTS CycleProgress (
+ id TEXT PRIMARY KEY NOT NULL,
+ cycle_id TEXT NOT NULL UNIQUE,
+ current_day_number INTEGER NOT NULL DEFAULT 1,
+ last_completed_date INTEGER,
+ cycle_start_date INTEGER NOT NULL,
+ last_advanced_at INTEGER,
+ completed_days TEXT,
+ missed_days TEXT,
+ rotation_count INTEGER NOT NULL DEFAULT 0,
+ FOREIGN KEY (cycle_id) REFERENCES TrainingCycle(id) ON DELETE CASCADE
+ )""",
+ """CREATE TABLE IF NOT EXISTS CycleProgression (
+ cycle_id TEXT PRIMARY KEY NOT NULL,
+ frequency_cycles INTEGER NOT NULL DEFAULT 2,
+ weight_increase_percent REAL,
+ echo_level_increase INTEGER NOT NULL DEFAULT 0,
+ eccentric_load_increase_percent INTEGER,
+ FOREIGN KEY (cycle_id) REFERENCES TrainingCycle(id) ON DELETE CASCADE
+ )""",
+ """CREATE TABLE IF NOT EXISTS PlannedSet (
+ id TEXT PRIMARY KEY NOT NULL,
+ routine_exercise_id TEXT NOT NULL,
+ set_number INTEGER NOT NULL,
+ set_type TEXT NOT NULL DEFAULT 'STANDARD',
+ target_reps INTEGER,
+ target_weight_kg REAL,
+ target_rpe INTEGER,
+ rest_seconds INTEGER,
+ FOREIGN KEY (routine_exercise_id) REFERENCES RoutineExercise(id) ON DELETE CASCADE
+ )""",
+ """CREATE TABLE IF NOT EXISTS CompletedSet (
+ id TEXT PRIMARY KEY NOT NULL,
+ session_id TEXT NOT NULL,
+ planned_set_id TEXT,
+ set_number INTEGER NOT NULL,
+ set_type TEXT NOT NULL DEFAULT 'STANDARD',
+ actual_reps INTEGER NOT NULL,
+ actual_weight_kg REAL NOT NULL,
+ logged_rpe INTEGER,
+ is_pr INTEGER NOT NULL DEFAULT 0,
+ completed_at INTEGER NOT NULL,
+ FOREIGN KEY (session_id) REFERENCES WorkoutSession(id) ON DELETE CASCADE,
+ FOREIGN KEY (planned_set_id) REFERENCES PlannedSet(id) ON DELETE SET NULL
+ )""",
+ """CREATE TABLE IF NOT EXISTS ProgressionEvent (
+ id TEXT PRIMARY KEY NOT NULL,
+ exercise_id TEXT NOT NULL,
+ suggested_weight_kg REAL NOT NULL,
+ previous_weight_kg REAL NOT NULL,
+ reason TEXT NOT NULL,
+ user_response TEXT,
+ actual_weight_kg REAL,
+ timestamp INTEGER NOT NULL,
+ FOREIGN KEY (exercise_id) REFERENCES Exercise(id) ON DELETE CASCADE
+ )""",
+ "CREATE INDEX IF NOT EXISTS idx_cycle_day_cycle ON CycleDay(cycle_id)",
+ "CREATE INDEX IF NOT EXISTS idx_cycle_progress_cycle ON CycleProgress(cycle_id)",
+ "CREATE INDEX IF NOT EXISTS idx_planned_set_exercise ON PlannedSet(routine_exercise_id)",
+ "CREATE INDEX IF NOT EXISTS idx_completed_set_session ON CompletedSet(session_id)",
+ "CREATE INDEX IF NOT EXISTS idx_progression_event_exercise ON ProgressionEvent(exercise_id)"
+ )
+ else -> emptyList()
+ }
+ }
+
+ /**
+ * Create a temporary SqlDriver wrapper for the SupportSQLiteDatabase.
+ * This allows SQLDelight's migrate() to work with the open database.
+ */
+ private fun createTempDriver(db: SupportSQLiteDatabase): SqlDriver {
+ return object : SqlDriver {
+ override fun close() {}
+ override fun addListener(vararg queryKeys: String, listener: app.cash.sqldelight.Query.Listener) {}
+ override fun removeListener(vararg queryKeys: String, listener: app.cash.sqldelight.Query.Listener) {}
+ override fun notifyListeners(vararg queryKeys: String) {}
+ override fun currentTransaction(): app.cash.sqldelight.Transacter.Transaction? = null
+ override fun newTransaction(): app.cash.sqldelight.db.QueryResult {
+ throw UnsupportedOperationException()
+ }
+ override fun execute(
+ identifier: Int?,
+ sql: String,
+ parameters: Int,
+ binders: (app.cash.sqldelight.db.SqlPreparedStatement.() -> Unit)?
+ ): app.cash.sqldelight.db.QueryResult {
+ db.execSQL(sql)
+ return app.cash.sqldelight.db.QueryResult.Value(0L)
+ }
+ override fun executeQuery(
+ identifier: Int?,
+ sql: String,
+ mapper: (app.cash.sqldelight.db.SqlCursor) -> app.cash.sqldelight.db.QueryResult,
+ parameters: Int,
+ binders: (app.cash.sqldelight.db.SqlPreparedStatement.() -> Unit)?
+ ): app.cash.sqldelight.db.QueryResult {
+ throw UnsupportedOperationException()
+ }
+ }
+ }
override fun onCorruption(db: SupportSQLiteDatabase) {
Log.e(TAG, "Database corruption detected")
diff --git a/shared/src/androidMain/kotlin/com/devil/phoenixproject/util/CsvExporter.android.kt b/shared/src/androidMain/kotlin/com/devil/phoenixproject/util/CsvExporter.android.kt
index 92d195f5a..02e7661e2 100644
--- a/shared/src/androidMain/kotlin/com/devil/phoenixproject/util/CsvExporter.android.kt
+++ b/shared/src/androidMain/kotlin/com/devil/phoenixproject/util/CsvExporter.android.kt
@@ -150,7 +150,10 @@ class AndroidCsvExporter(private val context: Context) : CsvExporter {
override fun shareCSV(fileUri: String, fileName: String) {
try {
val file = File(fileUri)
- if (!file.exists()) return
+ if (!file.exists()) {
+ android.util.Log.e("CsvExporter", "File does not exist: $fileUri")
+ return
+ }
val uri: Uri = FileProvider.getUriForFile(
context,
@@ -171,7 +174,7 @@ class AndroidCsvExporter(private val context: Context) : CsvExporter {
}
context.startActivity(chooser)
} catch (e: Exception) {
- // Log error
+ android.util.Log.e("CsvExporter", "Failed to share CSV: ${e.message}", e)
}
}
diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/AnimatedActionButton.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/AnimatedActionButton.kt
index 61d77c8aa..ebb7313bf 100644
--- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/AnimatedActionButton.kt
+++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/AnimatedActionButton.kt
@@ -25,6 +25,8 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
+import com.devil.phoenixproject.presentation.util.LocalWindowSizeClass
+import com.devil.phoenixproject.presentation.util.WindowWidthSizeClass
import com.devil.phoenixproject.ui.theme.HomeButtonColors
import kotlinx.coroutines.isActive
import kotlin.math.sin
@@ -165,6 +167,14 @@ fun AnimatedActionButton(
val interactionSource = remember { MutableInteractionSource() }
val isPressed by interactionSource.collectIsPressedAsState()
+ // Responsive button height based on device size
+ val windowSizeClass = LocalWindowSizeClass.current
+ val buttonHeight = when (windowSizeClass.widthSizeClass) {
+ WindowWidthSizeClass.Expanded -> 80.dp
+ WindowWidthSizeClass.Medium -> 72.dp
+ WindowWidthSizeClass.Compact -> 64.dp
+ }
+
// Press feedback animation
val scale by animateFloatAsState(
targetValue = if (isPressed) 0.95f else 1f,
@@ -254,7 +264,7 @@ fun AnimatedActionButton(
Box(
modifier = modifier
.fillMaxWidth()
- .height(64.dp)
+ .height(buttonHeight)
.scale(scale * pulseScale)
.clip(RoundedCornerShape(28.dp))
.onFire()
@@ -305,7 +315,7 @@ fun AnimatedActionButton(
interactionSource = interactionSource,
modifier = modifier
.fillMaxWidth()
- .height(64.dp)
+ .height(buttonHeight)
.scale(scale * pulseScale),
icon = {
Icon(
@@ -324,7 +334,7 @@ fun AnimatedActionButton(
interactionSource = interactionSource,
modifier = modifier
.fillMaxWidth()
- .height(64.dp)
+ .height(buttonHeight)
.scale(scale * pulseScale),
content = { Text(label, style = MaterialTheme.typography.titleMedium) }
)
diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/AutoStopOverlay.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/AutoStopOverlay.kt
index 1ae87f16c..b29c2cd26 100644
--- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/AutoStopOverlay.kt
+++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/AutoStopOverlay.kt
@@ -20,6 +20,8 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.devil.phoenixproject.data.repository.AutoStopUiState
+import com.devil.phoenixproject.presentation.util.LocalWindowSizeClass
+import com.devil.phoenixproject.presentation.util.WindowWidthSizeClass
/**
* Pop-over overlay that appears when auto-stop is active (handles are down).
@@ -42,6 +44,14 @@ fun AutoStopOverlay(
scaleOut(targetScale = 0.8f, animationSpec = tween(150)),
modifier = modifier
) {
+ // Responsive dialog width based on screen size
+ val windowSizeClass = LocalWindowSizeClass.current
+ val dialogMaxWidth = when (windowSizeClass.widthSizeClass) {
+ WindowWidthSizeClass.Expanded -> 400.dp
+ WindowWidthSizeClass.Medium -> 340.dp
+ WindowWidthSizeClass.Compact -> 280.dp
+ }
+
// Pulsing animation for urgency
val infiniteTransition = rememberInfiniteTransition(label = "autostop-pulse")
val pulse by infiniteTransition.animateFloat(
@@ -57,7 +67,7 @@ fun AutoStopOverlay(
Card(
modifier = Modifier
.scale(pulse)
- .widthIn(max = 280.dp),
+ .widthIn(max = dialogMaxWidth),
shape = RoundedCornerShape(24.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer
@@ -195,8 +205,16 @@ fun AutoStartOverlay(
scaleOut(targetScale = 0.8f, animationSpec = tween(150)),
modifier = modifier
) {
+ // Responsive dialog width based on screen size
+ val windowSizeClass = LocalWindowSizeClass.current
+ val dialogMaxWidth = when (windowSizeClass.widthSizeClass) {
+ WindowWidthSizeClass.Expanded -> 400.dp
+ WindowWidthSizeClass.Medium -> 340.dp
+ WindowWidthSizeClass.Compact -> 280.dp
+ }
+
Card(
- modifier = Modifier.widthIn(max = 280.dp),
+ modifier = Modifier.widthIn(max = dialogMaxWidth),
shape = RoundedCornerShape(24.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer
diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/ProfileSidePanel.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/ProfileSidePanel.kt
index 280dc003f..0a4fa0afe 100644
--- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/ProfileSidePanel.kt
+++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/ProfileSidePanel.kt
@@ -27,11 +27,11 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import com.devil.phoenixproject.data.repository.UserProfile
import com.devil.phoenixproject.data.repository.UserProfileRepository
+import com.devil.phoenixproject.presentation.util.LocalWindowSizeClass
+import com.devil.phoenixproject.presentation.util.WindowWidthSizeClass
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import kotlin.math.roundToInt
-
-private val PANEL_WIDTH = 200.dp
private val HANDLE_WIDTH = 24.dp
private val HANDLE_HEIGHT = 48.dp
private val EDGE_SWIPE_THRESHOLD = 20.dp
@@ -54,12 +54,20 @@ fun ProfileSidePanel(
var showEditDialog by remember { mutableStateOf(null) }
var showDeleteDialog by remember { mutableStateOf(null) }
+ // Responsive panel width based on screen size
+ val windowSizeClass = LocalWindowSizeClass.current
+ val panelWidth = when (windowSizeClass.widthSizeClass) {
+ WindowWidthSizeClass.Expanded -> 280.dp
+ WindowWidthSizeClass.Medium -> 240.dp
+ WindowWidthSizeClass.Compact -> 200.dp
+ }
+
val density = LocalDensity.current
- val panelWidthPx = with(density) { PANEL_WIDTH.toPx() }
+ val panelWidthPx = with(density) { panelWidth.toPx() }
// Panel offset animation
val panelOffset by animateDpAsState(
- targetValue = if (isOpen) 0.dp else PANEL_WIDTH,
+ targetValue = if (isOpen) 0.dp else panelWidth,
animationSpec = tween(durationMillis = 300),
label = "panelOffset"
)
@@ -122,7 +130,7 @@ fun ProfileSidePanel(
// Main panel - wraps content height
Surface(
modifier = Modifier
- .width(PANEL_WIDTH)
+ .width(panelWidth)
.pointerInput(Unit) {
detectHorizontalDragGestures { _, dragAmount ->
if (dragAmount > 20) {
diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/RoutinePickerDialog.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/RoutinePickerDialog.kt
index b64450fc3..a30464948 100644
--- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/RoutinePickerDialog.kt
+++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/RoutinePickerDialog.kt
@@ -1,7 +1,16 @@
package com.devil.phoenixproject.presentation.components
import androidx.compose.foundation.clickable
-import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxWithConstraints
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
@@ -25,18 +34,21 @@ fun RoutinePickerDialog(
onDismiss: () -> Unit
) {
Dialog(onDismissRequest = onDismiss) {
- Card(
- modifier = Modifier
- .fillMaxWidth()
- .heightIn(max = 600.dp),
- shape = RoundedCornerShape(24.dp),
- colors = CardDefaults.cardColors(
- containerColor = MaterialTheme.colorScheme.surface
- )
- ) {
- Column(
- modifier = Modifier.padding(24.dp)
+ BoxWithConstraints {
+ val maxDialogHeight = (maxHeight * 0.8f).coerceIn(400.dp, 800.dp)
+
+ Card(
+ modifier = Modifier
+ .fillMaxWidth()
+ .heightIn(max = maxDialogHeight),
+ shape = RoundedCornerShape(24.dp),
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surface
+ )
) {
+ Column(
+ modifier = Modifier.padding(24.dp)
+ ) {
// Header
Row(
modifier = Modifier.fillMaxWidth(),
@@ -120,6 +132,7 @@ fun RoutinePickerDialog(
}
}
}
+ }
}
}
}
diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/ShimmerEffect.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/ShimmerEffect.kt
index 836e10110..1d7524b35 100644
--- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/ShimmerEffect.kt
+++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/ShimmerEffect.kt
@@ -13,9 +13,29 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
+import com.devil.phoenixproject.presentation.util.LocalWindowSizeClass
+import com.devil.phoenixproject.presentation.util.WindowWidthSizeClass
import com.devil.phoenixproject.ui.theme.Spacing
+/**
+ * Calculate responsive dimension based on window size class.
+ * Scales the base dimension up for larger screens.
+ */
+@Composable
+private fun responsiveDimension(base: Dp): Dp {
+ val windowSizeClass = LocalWindowSizeClass.current
+ return when (windowSizeClass.widthSizeClass) {
+ WindowWidthSizeClass.Expanded -> base * 1.4f
+ WindowWidthSizeClass.Medium -> base * 1.2f
+ WindowWidthSizeClass.Compact -> base
+ }
+}
+
+// Extension for Dp multiplication
+private operator fun Dp.times(factor: Float): Dp = (this.value * factor).dp
+
/**
* Shimmer effect for skeleton loading screens.
* Creates an animated gradient that sweeps across placeholder content.
@@ -88,7 +108,7 @@ fun WorkoutHistoryCardSkeleton(
Card(
modifier = modifier
.fillMaxWidth()
- .height(160.dp),
+ .height(responsiveDimension(160.dp)),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
shape = RoundedCornerShape(20.dp), // Material 3 Expressive: More rounded (was 16dp)
elevation = CardDefaults.cardElevation(defaultElevation = 8.dp) // Material 3 Expressive: Higher elevation (was 4dp)
@@ -107,7 +127,7 @@ fun WorkoutHistoryCardSkeleton(
Row(verticalAlignment = Alignment.CenterVertically) {
// Icon placeholder
ShimmerBox(
- modifier = Modifier.size(48.dp)
+ modifier = Modifier.size(responsiveDimension(48.dp))
)
Spacer(modifier = Modifier.width(Spacing.medium))
@@ -116,15 +136,15 @@ fun WorkoutHistoryCardSkeleton(
// Exercise name placeholder
ShimmerBox(
modifier = Modifier
- .width(120.dp)
- .height(20.dp)
+ .width(responsiveDimension(120.dp))
+ .height(responsiveDimension(20.dp))
)
Spacer(modifier = Modifier.height(6.dp))
// Mode placeholder
ShimmerBox(
modifier = Modifier
- .width(80.dp)
- .height(16.dp)
+ .width(responsiveDimension(80.dp))
+ .height(responsiveDimension(16.dp))
)
}
}
@@ -132,8 +152,8 @@ fun WorkoutHistoryCardSkeleton(
// Date placeholder
ShimmerBox(
modifier = Modifier
- .width(60.dp)
- .height(16.dp)
+ .width(responsiveDimension(60.dp))
+ .height(responsiveDimension(16.dp))
)
}
@@ -148,14 +168,14 @@ fun WorkoutHistoryCardSkeleton(
Column(horizontalAlignment = Alignment.CenterHorizontally) {
ShimmerBox(
modifier = Modifier
- .width(40.dp)
- .height(24.dp)
+ .width(responsiveDimension(40.dp))
+ .height(responsiveDimension(24.dp))
)
Spacer(modifier = Modifier.height(4.dp))
ShimmerBox(
modifier = Modifier
- .width(50.dp)
- .height(14.dp)
+ .width(responsiveDimension(50.dp))
+ .height(responsiveDimension(14.dp))
)
}
}
@@ -174,7 +194,7 @@ fun PersonalRecordCardSkeleton(
Card(
modifier = modifier
.fillMaxWidth()
- .height(100.dp),
+ .height(responsiveDimension(100.dp)),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
shape = RoundedCornerShape(20.dp), // Material 3 Expressive: More rounded (was 16dp)
elevation = CardDefaults.cardElevation(defaultElevation = 8.dp) // Material 3 Expressive: Higher elevation (was 4dp)
@@ -192,7 +212,7 @@ fun PersonalRecordCardSkeleton(
) {
// Rank placeholder
ShimmerBox(
- modifier = Modifier.size(40.dp)
+ modifier = Modifier.size(responsiveDimension(40.dp))
)
Spacer(modifier = Modifier.width(Spacing.medium))
@@ -201,22 +221,22 @@ fun PersonalRecordCardSkeleton(
// Exercise name placeholder
ShimmerBox(
modifier = Modifier
- .width(140.dp)
- .height(20.dp)
+ .width(responsiveDimension(140.dp))
+ .height(responsiveDimension(20.dp))
)
Spacer(modifier = Modifier.height(6.dp))
// Weight placeholder
ShimmerBox(
modifier = Modifier
- .width(100.dp)
- .height(18.dp)
+ .width(responsiveDimension(100.dp))
+ .height(responsiveDimension(18.dp))
)
Spacer(modifier = Modifier.height(4.dp))
// Details placeholder
ShimmerBox(
modifier = Modifier
- .width(120.dp)
- .height(14.dp)
+ .width(responsiveDimension(120.dp))
+ .height(responsiveDimension(14.dp))
)
}
}
@@ -234,7 +254,7 @@ fun RoutineCardSkeleton(
Card(
modifier = modifier
.fillMaxWidth()
- .height(140.dp),
+ .height(responsiveDimension(140.dp)),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
shape = RoundedCornerShape(20.dp), // Material 3 Expressive: More rounded (was 16dp)
elevation = CardDefaults.cardElevation(defaultElevation = 8.dp) // Material 3 Expressive: Higher elevation (was 4dp)
@@ -247,8 +267,8 @@ fun RoutineCardSkeleton(
// Title placeholder
ShimmerBox(
modifier = Modifier
- .width(160.dp)
- .height(24.dp)
+ .width(responsiveDimension(160.dp))
+ .height(responsiveDimension(24.dp))
)
Spacer(modifier = Modifier.height(Spacing.small))
@@ -257,13 +277,13 @@ fun RoutineCardSkeleton(
ShimmerBox(
modifier = Modifier
.fillMaxWidth()
- .height(16.dp)
+ .height(responsiveDimension(16.dp))
)
Spacer(modifier = Modifier.height(6.dp))
ShimmerBox(
modifier = Modifier
.fillMaxWidth(0.7f)
- .height(16.dp)
+ .height(responsiveDimension(16.dp))
)
Spacer(modifier = Modifier.height(Spacing.medium))
@@ -271,8 +291,8 @@ fun RoutineCardSkeleton(
// Exercise count placeholder
ShimmerBox(
modifier = Modifier
- .width(100.dp)
- .height(14.dp)
+ .width(responsiveDimension(100.dp))
+ .height(responsiveDimension(14.dp))
)
}
}
diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/charts/AreaChart.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/charts/AreaChart.kt
index 483e30fcc..d9ca68c97 100644
--- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/charts/AreaChart.kt
+++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/charts/AreaChart.kt
@@ -19,6 +19,7 @@ import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
+import com.devil.phoenixproject.presentation.util.ResponsiveDimensions
/**
* Material 3 Expressive Area Chart
@@ -67,6 +68,8 @@ fun AreaChart(
val gridColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.2f)
val density = LocalDensity.current
+ val chartHeight = ResponsiveDimensions.chartHeight(baseHeight = 200.dp)
+
Column(modifier = modifier.padding(16.dp)) {
// Title
title?.let {
@@ -81,17 +84,17 @@ fun AreaChart(
BoxWithConstraints(
modifier = Modifier
.fillMaxWidth()
- .height(200.dp)
+ .height(chartHeight)
) {
- val chartWidth = with(density) { maxWidth.toPx() }
- val chartHeight = with(density) { maxHeight.toPx() }
+ val canvasWidth = with(density) { maxWidth.toPx() }
+ val canvasHeight = with(density) { maxHeight.toPx() }
val paddingLeft = 40.dp
val paddingBottom = 30.dp
val paddingLeftPx = with(density) { paddingLeft.toPx() }
val paddingBottomPx = with(density) { paddingBottom.toPx() }
- val effectiveWidth = chartWidth - paddingLeftPx
- val effectiveHeight = chartHeight - paddingBottomPx
+ val effectiveWidth = canvasWidth - paddingLeftPx
+ val effectiveHeight = canvasHeight - paddingBottomPx
Canvas(modifier = Modifier.fillMaxSize()) {
val progress = animationProgress.value
@@ -103,8 +106,8 @@ fun AreaChart(
val y = paddingBottomPx + (effectiveHeight * i / 4f)
drawLine(
color = gridColor,
- start = Offset(paddingLeftPx, chartHeight - y),
- end = Offset(chartWidth, chartHeight - y),
+ start = Offset(paddingLeftPx, canvasHeight - y),
+ end = Offset(canvasWidth, canvasHeight - y),
strokeWidth = 1.dp.toPx()
)
}
@@ -116,7 +119,7 @@ fun AreaChart(
drawLine(
color = gridColor,
start = Offset(x, 0f),
- end = Offset(x, chartHeight - paddingBottomPx),
+ end = Offset(x, canvasHeight - paddingBottomPx),
strokeWidth = 1.dp.toPx()
)
}
@@ -126,7 +129,7 @@ fun AreaChart(
// Just draw a single point
val x = paddingLeftPx + effectiveWidth / 2
val normalizedValue = (values[0] - minValue) / valueRange
- val y = chartHeight - paddingBottomPx - (normalizedValue * effectiveHeight * progress)
+ val y = canvasHeight - paddingBottomPx - (normalizedValue * effectiveHeight * progress)
drawCircle(
color = lineColor,
radius = 6.dp.toPx(),
@@ -140,17 +143,17 @@ fun AreaChart(
val points = values.mapIndexed { index, value ->
val x = paddingLeftPx + stepX * index
val normalizedValue = (value - minValue) / valueRange
- val y = chartHeight - paddingBottomPx - (normalizedValue * effectiveHeight * progress)
+ val y = canvasHeight - paddingBottomPx - (normalizedValue * effectiveHeight * progress)
Offset(x, y)
}
// Draw area fill with gradient
val areaPath = Path().apply {
- moveTo(points.first().x, chartHeight - paddingBottomPx)
+ moveTo(points.first().x, canvasHeight - paddingBottomPx)
points.forEach { point ->
lineTo(point.x, point.y)
}
- lineTo(points.last().x, chartHeight - paddingBottomPx)
+ lineTo(points.last().x, canvasHeight - paddingBottomPx)
close()
}
@@ -162,7 +165,7 @@ fun AreaChart(
areaColor.copy(alpha = 0.1f * progress)
),
startY = 0f,
- endY = chartHeight - paddingBottomPx
+ endY = canvasHeight - paddingBottomPx
)
)
@@ -233,10 +236,12 @@ internal fun EmptyChartState(
message: String,
modifier: Modifier = Modifier
) {
+ val emptyStateHeight = ResponsiveDimensions.chartHeight(baseHeight = 280.dp)
+
Box(
modifier = modifier
.fillMaxWidth()
- .height(280.dp)
+ .height(emptyStateHeight)
.padding(16.dp),
contentAlignment = Alignment.Center
) {
diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/charts/CircleChart.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/charts/CircleChart.kt
index af635c2d7..fa0ebd3da 100644
--- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/charts/CircleChart.kt
+++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/charts/CircleChart.kt
@@ -6,8 +6,8 @@ import androidx.compose.foundation.Canvas
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.*
import androidx.compose.ui.input.pointer.pointerInput
-import androidx.compose.ui.platform.LocalDensity
import kotlin.math.PI
+import com.devil.phoenixproject.presentation.util.ResponsiveDimensions
import kotlin.math.atan2
import kotlin.math.min
import kotlin.math.sqrt
@@ -75,24 +75,15 @@ fun MuscleGroupCircleChart(
label = "chart_animation"
)
- // Use BoxWithConstraints to make chart responsive to screen size (tablet support)
- BoxWithConstraints(
+ // Use ResponsiveDimensions for consistent tablet-responsive sizing
+ val chartSize = ResponsiveDimensions.chartHeight(baseHeight = 280.dp)
+
+ Box(
modifier = modifier
.fillMaxWidth()
.padding(16.dp),
contentAlignment = Alignment.Center
) {
- // Calculate responsive chart size based on available width
- // On phones (~360dp width), this gives ~280dp chart height
- // On tablets (~800dp+ width), this scales up appropriately but caps at 400dp
- val density = LocalDensity.current
- val availableWidth = maxWidth
- val chartSize = with(density) {
- // Use 70% of available width, with min 240dp and max 400dp
- val calculatedSize = availableWidth * 0.70f
- calculatedSize.coerceIn(240.dp, 400.dp)
- }
-
Box(
modifier = Modifier.size(chartSize),
contentAlignment = Alignment.Center
@@ -195,21 +186,15 @@ fun MuscleGroupCircleChart(
private fun EmptyChartState(
modifier: Modifier = Modifier
) {
- // Use BoxWithConstraints for responsive sizing on tablets
- BoxWithConstraints(
+ // Use ResponsiveDimensions for consistent tablet-responsive sizing
+ val chartSize = ResponsiveDimensions.chartHeight(baseHeight = 280.dp)
+
+ Box(
modifier = modifier
.fillMaxWidth()
.padding(16.dp),
contentAlignment = Alignment.Center
) {
- // Match the responsive sizing logic from the main chart
- val density = LocalDensity.current
- val availableWidth = maxWidth
- val chartSize = with(density) {
- val calculatedSize = availableWidth * 0.70f
- calculatedSize.coerceIn(240.dp, 400.dp)
- }
-
Box(
modifier = Modifier.size(chartSize),
contentAlignment = Alignment.Center
diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/charts/ComboChart.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/charts/ComboChart.kt
index 94b3ed334..e221b9386 100644
--- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/charts/ComboChart.kt
+++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/charts/ComboChart.kt
@@ -24,6 +24,7 @@ import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
+import com.devil.phoenixproject.presentation.util.ResponsiveDimensions
/**
* Material 3 Expressive Combo Chart
@@ -123,22 +124,24 @@ fun ComboChart(
)
}
+ val chartHeight = ResponsiveDimensions.chartHeight(baseHeight = 200.dp)
+
BoxWithConstraints(
modifier = Modifier
.fillMaxWidth()
- .height(200.dp)
+ .height(chartHeight)
) {
- val chartWidth = with(density) { maxWidth.toPx() }
- val chartHeight = with(density) { maxHeight.toPx() }
+ val chartWidthPx = with(density) { maxWidth.toPx() }
+ val chartHeightPx = with(density) { maxHeight.toPx() }
val paddingBottom = 30.dp
val paddingBottomPx = with(density) { paddingBottom.toPx() }
- val effectiveHeight = chartHeight - paddingBottomPx
+ val effectiveHeight = chartHeightPx - paddingBottomPx
Canvas(modifier = Modifier.fillMaxSize()) {
val progress = animationProgress.value
val barCount = labels.size.coerceAtLeast(1)
- val barWidth = (chartWidth / barCount) * 0.6f
- val barSpacing = (chartWidth / barCount) * 0.4f / 2f
+ val barWidth = (chartWidthPx / barCount) * 0.6f
+ val barSpacing = (chartWidthPx / barCount) * 0.4f / 2f
// Draw horizontal grid
for (i in 0..4) {
@@ -146,7 +149,7 @@ fun ComboChart(
drawLine(
color = gridColor,
start = Offset(0f, y),
- end = Offset(chartWidth, y),
+ end = Offset(chartWidthPx, y),
strokeWidth = 1.dp.toPx()
)
}
diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/charts/GaugeChart.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/charts/GaugeChart.kt
index c1e0e0a6a..ed840e707 100644
--- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/charts/GaugeChart.kt
+++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/charts/GaugeChart.kt
@@ -23,6 +23,7 @@ import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
+import com.devil.phoenixproject.presentation.util.ResponsiveDimensions
/**
* Material 3 Expressive Gauge Chart
@@ -63,12 +64,13 @@ fun GaugeChart(
}
val surfaceContainerHighestColor = MaterialTheme.colorScheme.surfaceContainerHighest
+ val chartHeight = ResponsiveDimensions.chartHeight(baseHeight = 200.dp)
Column(modifier = modifier) {
Box(
modifier = Modifier
.fillMaxWidth()
- .height(200.dp)
+ .height(chartHeight)
.padding(16.dp),
contentAlignment = Alignment.Center
) {
@@ -162,6 +164,8 @@ private fun EmptyGaugeState(
message: String,
modifier: Modifier = Modifier
) {
+ val chartHeight = ResponsiveDimensions.chartHeight(baseHeight = 200.dp)
+
Column(
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally
@@ -169,7 +173,7 @@ private fun EmptyGaugeState(
Box(
modifier = Modifier
.fillMaxWidth()
- .height(200.dp)
+ .height(chartHeight)
.padding(16.dp),
contentAlignment = Alignment.Center
) {
diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/charts/RadarChart.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/charts/RadarChart.kt
index 21549bf71..4491a06cc 100644
--- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/charts/RadarChart.kt
+++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/charts/RadarChart.kt
@@ -24,6 +24,7 @@ import androidx.compose.ui.graphics.drawscope.Fill
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
+import com.devil.phoenixproject.presentation.util.ResponsiveDimensions
import kotlin.math.PI
import kotlin.math.cos
import kotlin.math.sin
@@ -69,11 +70,12 @@ fun RadarChart(
val density = LocalDensity.current
val numPoints = animatedData.size
val angleStep = (2 * PI) / numPoints
+ val chartHeight = ResponsiveDimensions.chartHeight(baseHeight = 320.dp)
BoxWithConstraints(
modifier = modifier
.fillMaxWidth()
- .height(320.dp)
+ .height(chartHeight)
.padding(24.dp)
) {
val boxWidth = with(density) { maxWidth.toPx() }
@@ -202,10 +204,11 @@ private fun RadarEmptyChartState(
message: String,
modifier: Modifier = Modifier
) {
+ val chartHeight = ResponsiveDimensions.chartHeight(baseHeight = 320.dp)
Box(
modifier = modifier
.fillMaxWidth()
- .height(320.dp)
+ .height(chartHeight)
.padding(24.dp),
contentAlignment = Alignment.Center
) {
diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/charts/VolumeTrendChart.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/charts/VolumeTrendChart.kt
index 5a3652ba6..84c6031f2 100644
--- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/charts/VolumeTrendChart.kt
+++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/charts/VolumeTrendChart.kt
@@ -20,6 +20,9 @@ import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.devil.phoenixproject.domain.model.WeightUnit
+import com.devil.phoenixproject.presentation.util.LocalWindowSizeClass
+import com.devil.phoenixproject.presentation.util.ResponsiveDimensions
+import com.devil.phoenixproject.presentation.util.WindowWidthSizeClass
import com.devil.phoenixproject.domain.model.WorkoutSession
import com.devil.phoenixproject.ui.theme.DataColors
import com.devil.phoenixproject.util.KmpUtils
@@ -69,7 +72,18 @@ fun VolumeTrendChart(
animationProgress = 1f
}
- Column(modifier = modifier.fillMaxWidth()) {
+ // Responsive column width for Y-axis labels
+ val windowSizeClass = LocalWindowSizeClass.current
+ val columnWidth = when (windowSizeClass.widthSizeClass) {
+ WindowWidthSizeClass.Expanded -> 80.dp
+ WindowWidthSizeClass.Medium -> 65.dp
+ WindowWidthSizeClass.Compact -> 50.dp
+ }
+
+ // Responsive chart height
+ val chartHeight = ResponsiveDimensions.chartHeight(baseHeight = 250.dp)
+
+ Column(modifier = modifier.fillMaxWidth().height(chartHeight)) {
// Chart area
Row(
modifier = Modifier
@@ -79,7 +93,7 @@ fun VolumeTrendChart(
// Y-axis labels
Column(
modifier = Modifier
- .width(50.dp)
+ .width(columnWidth)
.fillMaxHeight()
.padding(end = 4.dp),
verticalArrangement = Arrangement.SpaceBetween
@@ -158,7 +172,7 @@ fun VolumeTrendChart(
modifier = Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState())
- .padding(start = 54.dp, top = 4.dp)
+ .padding(start = columnWidth + 4.dp, top = 4.dp)
) {
volumeData.forEach { data ->
Text(
diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/cycle/AddDaySheet.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/cycle/AddDaySheet.kt
index adf28d610..ea0504ec9 100644
--- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/cycle/AddDaySheet.kt
+++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/cycle/AddDaySheet.kt
@@ -113,10 +113,13 @@ fun AddDaySheet(
Spacer(modifier = Modifier.height(24.dp))
// Routine list
- LazyColumn(
- modifier = Modifier.heightIn(max = 400.dp),
- verticalArrangement = Arrangement.spacedBy(2.dp)
- ) {
+ BoxWithConstraints {
+ val maxSheetHeight = (maxHeight * 0.8f).coerceIn(300.dp, 600.dp)
+
+ LazyColumn(
+ modifier = Modifier.heightIn(max = maxSheetHeight),
+ verticalArrangement = Arrangement.spacedBy(2.dp)
+ ) {
// Recent routines section
if (recentRoutines.isNotEmpty()) {
item {
@@ -171,6 +174,7 @@ fun AddDaySheet(
)
}
}
+ }
}
}
}
diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/AnalyticsScreen.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/AnalyticsScreen.kt
index d18180d49..9c71389c1 100644
--- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/AnalyticsScreen.kt
+++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/AnalyticsScreen.kt
@@ -30,6 +30,8 @@ import com.devil.phoenixproject.util.KmpUtils
import com.devil.phoenixproject.data.repository.ExerciseRepository
import com.devil.phoenixproject.domain.model.PersonalRecord
import androidx.compose.foundation.lazy.items
+import com.devil.phoenixproject.presentation.util.LocalWindowSizeClass
+import com.devil.phoenixproject.presentation.util.WindowWidthSizeClass
// Helper function for timestamp formatting
private fun formatTimestamp(timestamp: Long): String {
@@ -185,6 +187,19 @@ fun AnalyticsScreen(
val isAutoConnecting by viewModel.isAutoConnecting.collectAsState()
val connectionError by viewModel.connectionError.collectAsState()
+ // Responsive sizing based on window size class
+ val windowSizeClass = LocalWindowSizeClass.current
+ val tabIndicatorHeight = when (windowSizeClass.widthSizeClass) {
+ WindowWidthSizeClass.Expanded -> 12.dp
+ WindowWidthSizeClass.Medium -> 10.dp
+ WindowWidthSizeClass.Compact -> 8.dp
+ }
+ val fabIconSize = when (windowSizeClass.widthSizeClass) {
+ WindowWidthSizeClass.Expanded -> 36.dp
+ WindowWidthSizeClass.Medium -> 32.dp
+ WindowWidthSizeClass.Compact -> 28.dp
+ }
+
// Set global title
LaunchedEffect(Unit) {
viewModel.updateTopBarTitle("Analytics")
@@ -237,7 +252,7 @@ fun AnalyticsScreen(
TabRowDefaults.PrimaryIndicator(
modifier = Modifier
.tabIndicatorOffset(pagerState.currentPage)
- .height(8.dp),
+ .height(tabIndicatorHeight),
color = MaterialTheme.colorScheme.primary
)
}
@@ -380,7 +395,7 @@ fun AnalyticsScreen(
Icon(
Icons.Default.Share,
contentDescription = "Export data",
- modifier = Modifier.size(28.dp) // Material 3 Expressive: Larger icon (was default)
+ modifier = Modifier.size(fabIconSize) // Material 3 Expressive: Responsive icon size
)
}
}
diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/BadgesScreen.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/BadgesScreen.kt
index 34d8edaca..c05f81724 100644
--- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/BadgesScreen.kt
+++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/BadgesScreen.kt
@@ -37,6 +37,8 @@ import com.devil.phoenixproject.data.local.BadgeDefinitions
import com.devil.phoenixproject.data.repository.BadgeWithProgress
import com.devil.phoenixproject.domain.model.*
import com.devil.phoenixproject.presentation.viewmodel.GamificationViewModel
+import com.devil.phoenixproject.presentation.util.LocalWindowSizeClass
+import com.devil.phoenixproject.presentation.util.WindowWidthSizeClass
import com.devil.phoenixproject.ui.theme.Spacing
import org.koin.compose.koinInject
@@ -110,8 +112,14 @@ fun BadgesScreen(
CircularProgressIndicator()
}
} else {
+ val windowSizeClass = LocalWindowSizeClass.current
+ val gridColumns = when (windowSizeClass.widthSizeClass) {
+ WindowWidthSizeClass.Expanded -> 6
+ WindowWidthSizeClass.Medium -> 4
+ WindowWidthSizeClass.Compact -> 3
+ }
LazyVerticalGrid(
- columns = GridCells.Fixed(3),
+ columns = GridCells.Fixed(gridColumns),
contentPadding = PaddingValues(Spacing.medium),
horizontalArrangement = Arrangement.spacedBy(Spacing.small),
verticalArrangement = Arrangement.spacedBy(Spacing.small),
diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/ConnectionLogsScreen.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/ConnectionLogsScreen.kt
index fa8910fce..95be3b616 100644
--- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/ConnectionLogsScreen.kt
+++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/ConnectionLogsScreen.kt
@@ -2,6 +2,7 @@ package com.devil.phoenixproject.presentation.screen
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
@@ -215,21 +216,24 @@ fun ConnectionLogsScreen(
text = "Log content preview:",
style = MaterialTheme.typography.bodySmall
)
- Surface(
- modifier = Modifier
- .fillMaxWidth()
- .height(200.dp)
- .padding(top = 8.dp),
- color = MaterialTheme.colorScheme.surfaceVariant,
- shape = RoundedCornerShape(4.dp)
- ) {
- Text(
- text = exportContent.take(2000) + if (exportContent.length > 2000) "\n..." else "",
- modifier = Modifier.padding(8.dp),
- style = MaterialTheme.typography.bodySmall,
- fontFamily = FontFamily.Monospace,
- fontSize = 10.sp
- )
+ BoxWithConstraints {
+ val previewHeight = (maxHeight * 0.4f).coerceIn(150.dp, 400.dp)
+ Surface(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(previewHeight)
+ .padding(top = 8.dp),
+ color = MaterialTheme.colorScheme.surfaceVariant,
+ shape = RoundedCornerShape(4.dp)
+ ) {
+ Text(
+ text = exportContent.take(2000) + if (exportContent.length > 2000) "\n..." else "",
+ modifier = Modifier.padding(8.dp),
+ style = MaterialTheme.typography.bodySmall,
+ fontFamily = FontFamily.Monospace,
+ fontSize = 10.sp
+ )
+ }
}
}
},
diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/CountdownCard.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/CountdownCard.kt
index 199c28402..7d5cd2744 100644
--- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/CountdownCard.kt
+++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/CountdownCard.kt
@@ -21,6 +21,8 @@ import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
+import com.devil.phoenixproject.presentation.util.LocalWindowSizeClass
+import com.devil.phoenixproject.presentation.util.WindowWidthSizeClass
import com.devil.phoenixproject.ui.theme.*
/**
@@ -43,6 +45,19 @@ fun CountdownCard(
onEndWorkout: () -> Unit,
modifier: Modifier = Modifier
) {
+ // Responsive sizing based on window size class
+ val windowSizeClass = LocalWindowSizeClass.current
+ val countdownSize = when (windowSizeClass.widthSizeClass) {
+ WindowWidthSizeClass.Expanded -> 320.dp
+ WindowWidthSizeClass.Medium -> 270.dp
+ WindowWidthSizeClass.Compact -> 220.dp
+ }
+ val countdownFontSize = when (windowSizeClass.widthSizeClass) {
+ WindowWidthSizeClass.Expanded -> 120.sp
+ WindowWidthSizeClass.Medium -> 105.sp
+ WindowWidthSizeClass.Compact -> 90.sp
+ }
+
// Background gradient - respects theme mode
Box(
modifier = modifier
@@ -91,17 +106,17 @@ fun CountdownCard(
Box(
modifier = Modifier
.fillMaxWidth()
- .height(220.dp),
+ .height(countdownSize),
contentAlignment = Alignment.Center
) {
// Circular background with pulse effect
Box(
modifier = Modifier
- .size(220.dp)
+ .size(countdownSize)
.scale(pulse)
.background(
color = MaterialTheme.colorScheme.surfaceContainerHighest,
- shape = RoundedCornerShape(200.dp)
+ shape = RoundedCornerShape(countdownSize)
)
)
@@ -114,7 +129,7 @@ fun CountdownCard(
Text(
text = countdownText,
- style = MaterialTheme.typography.displayLarge.copy(fontSize = 90.sp),
+ style = MaterialTheme.typography.displayLarge.copy(fontSize = countdownFontSize),
fontWeight = FontWeight.ExtraBold,
color = if (countdownSecondsRemaining == 0)
MaterialTheme.colorScheme.tertiary
diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/DayCountPickerScreen.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/DayCountPickerScreen.kt
index 585b18b9e..96c67e06a 100644
--- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/DayCountPickerScreen.kt
+++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/DayCountPickerScreen.kt
@@ -12,6 +12,8 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
+import com.devil.phoenixproject.presentation.util.LocalWindowSizeClass
+import com.devil.phoenixproject.presentation.util.WindowWidthSizeClass
/**
* Screen for selecting the number of days in a training cycle.
@@ -53,11 +55,18 @@ fun DayCountPickerScreen(
)
}
) { paddingValues ->
+ val windowSizeClass = LocalWindowSizeClass.current
+ val horizontalPadding = when (windowSizeClass.widthSizeClass) {
+ WindowWidthSizeClass.Expanded -> 48.dp
+ WindowWidthSizeClass.Medium -> 36.dp
+ WindowWidthSizeClass.Compact -> 24.dp
+ }
+
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
- .padding(horizontal = 24.dp),
+ .padding(horizontal = horizontalPadding),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(48.dp))
diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/EnhancedMainScreen.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/EnhancedMainScreen.kt
index 501059fd2..f2245fd8e 100644
--- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/EnhancedMainScreen.kt
+++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/EnhancedMainScreen.kt
@@ -45,6 +45,10 @@ import com.devil.phoenixproject.presentation.components.AddProfileDialog
import com.devil.phoenixproject.presentation.components.ProfileSidePanel
import kotlinx.coroutines.launch
import org.koin.compose.koinInject
+import androidx.compose.runtime.CompositionLocalProvider
+import com.devil.phoenixproject.presentation.util.LocalWindowSizeClass
+import com.devil.phoenixproject.presentation.util.calculateWindowSizeClass
+import androidx.compose.foundation.layout.BoxWithConstraints
/**
* Enhanced main screen with dynamic top bar and bottom navigation.
@@ -129,7 +133,11 @@ fun EnhancedMainScreen(
currentRoute != NavigationRoutes.Settings.route
}
- Scaffold(
+ BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
+ val windowSizeClass = calculateWindowSizeClass(maxWidth, maxHeight)
+
+ CompositionLocalProvider(LocalWindowSizeClass provides windowSizeClass) {
+ Scaffold(
contentWindowInsets = WindowInsets(0, 0, 0, 0),
topBar = {
if (shouldShowTopBar) {
@@ -335,31 +343,33 @@ fun EnhancedMainScreen(
}
}
- // Show connection lost alert during workout (Issue #43)
- if (connectionLostDuringWorkout) {
- ConnectionLostDialog(
- onReconnect = {
- viewModel.dismissConnectionLostAlert()
- viewModel.ensureConnection(
- onConnected = {},
- onFailed = {}
+ // Show connection lost alert during workout (Issue #43)
+ if (connectionLostDuringWorkout) {
+ ConnectionLostDialog(
+ onReconnect = {
+ viewModel.dismissConnectionLostAlert()
+ viewModel.ensureConnection(
+ onConnected = {},
+ onFailed = {}
+ )
+ },
+ onDismiss = {
+ viewModel.dismissConnectionLostAlert()
+ }
)
- },
- onDismiss = {
- viewModel.dismissConnectionLostAlert()
}
- )
- }
- // Add Profile Dialog
- if (showAddProfileDialog) {
- AddProfileDialog(
- profiles = profiles,
- profileRepository = profileRepository,
- scope = scope,
- onDismiss = { showAddProfileDialog = false }
- )
- }
+ // Add Profile Dialog
+ if (showAddProfileDialog) {
+ AddProfileDialog(
+ profiles = profiles,
+ profileRepository = profileRepository,
+ scope = scope,
+ onDismiss = { showAddProfileDialog = false }
+ )
+ }
+ } // CompositionLocalProvider
+ } // BoxWithConstraints
}
/**
diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/ExerciseEditDialog.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/ExerciseEditDialog.kt
index 35cb835dd..0a7775a0a 100644
--- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/ExerciseEditDialog.kt
+++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/ExerciseEditDialog.kt
@@ -85,52 +85,58 @@ fun ExerciseEditBottomSheet(
// Number of sets control
Row(
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(8.dp)
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically
) {
- Text("Sets:", modifier = Modifier.width(60.dp))
- IconButton(
- onClick = {
- if (setReps.size > 1) {
- setReps = setReps.dropLast(1).toMutableList()
- }
- },
- enabled = setReps.size > 1
- ) {
- Icon(Icons.Default.Remove, contentDescription = "Remove set")
- }
- Text(
- text = "${setReps.size}",
- style = MaterialTheme.typography.titleMedium
- )
- IconButton(
- onClick = {
- if (setReps.size < 10) {
- setReps = (setReps + listOf(setReps.lastOrNull() ?: 10)).toMutableList()
- }
- },
- enabled = setReps.size < 10
+ Text("Sets:", modifier = Modifier.weight(0.3f))
+ Row(
+ modifier = Modifier.weight(0.7f),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
- Icon(Icons.Default.Add, contentDescription = "Add set")
+ IconButton(
+ onClick = {
+ if (setReps.size > 1) {
+ setReps = setReps.dropLast(1).toMutableList()
+ }
+ },
+ enabled = setReps.size > 1
+ ) {
+ Icon(Icons.Default.Remove, contentDescription = "Remove set")
+ }
+ Text(
+ text = "${setReps.size}",
+ style = MaterialTheme.typography.titleMedium
+ )
+ IconButton(
+ onClick = {
+ if (setReps.size < 10) {
+ setReps = (setReps + listOf(setReps.lastOrNull() ?: 10)).toMutableList()
+ }
+ },
+ enabled = setReps.size < 10
+ ) {
+ Icon(Icons.Default.Add, contentDescription = "Add set")
+ }
}
}
// Reps per set
setReps.forEachIndexed { index, reps ->
Row(
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(8.dp),
- modifier = Modifier.fillMaxWidth()
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically
) {
Text(
"Set ${index + 1}:",
- modifier = Modifier.width(60.dp),
+ modifier = Modifier.weight(0.3f),
style = MaterialTheme.typography.bodyMedium
)
if (isAMRAP && index == setReps.lastIndex) {
Text(
"AMRAP",
+ modifier = Modifier.weight(0.7f),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary
)
@@ -145,7 +151,7 @@ fun ExerciseEditBottomSheet(
},
label = { Text("Reps") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
- modifier = Modifier.width(100.dp),
+ modifier = Modifier.weight(0.7f),
singleLine = true
)
}
@@ -216,8 +222,8 @@ fun ExerciseEditBottomSheet(
)
Row(
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(8.dp)
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically
) {
OutlinedTextField(
value = restSeconds.toString(),
@@ -226,17 +232,24 @@ fun ExerciseEditBottomSheet(
},
label = { Text("Seconds") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
- modifier = Modifier.width(120.dp),
+ modifier = Modifier.weight(0.35f),
singleLine = true
)
+ Spacer(modifier = Modifier.width(8.dp))
+
// Quick select buttons
- listOf(30, 60, 90, 120).forEach { seconds ->
- FilterChip(
- selected = restSeconds == seconds,
- onClick = { restSeconds = seconds },
- label = { Text("${seconds}s") }
- )
+ Row(
+ modifier = Modifier.weight(0.65f),
+ horizontalArrangement = Arrangement.spacedBy(4.dp)
+ ) {
+ listOf(30, 60, 90, 120).forEach { seconds ->
+ FilterChip(
+ selected = restSeconds == seconds,
+ onClick = { restSeconds = seconds },
+ label = { Text("${seconds}s") }
+ )
+ }
}
}
diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/HomeScreen.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/HomeScreen.kt
index 0f83b388b..f29e5394a 100644
--- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/HomeScreen.kt
+++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/HomeScreen.kt
@@ -30,6 +30,8 @@ import com.devil.phoenixproject.presentation.navigation.NavigationRoutes
import com.devil.phoenixproject.presentation.viewmodel.MainViewModel
import com.devil.phoenixproject.presentation.components.AnimatedActionButton
import com.devil.phoenixproject.presentation.components.IconAnimation
+import com.devil.phoenixproject.presentation.util.LocalWindowSizeClass
+import com.devil.phoenixproject.presentation.util.WindowWidthSizeClass
import com.devil.phoenixproject.ui.theme.ThemeMode
import kotlin.time.Instant
import kotlinx.datetime.LocalDate
@@ -79,6 +81,14 @@ fun HomeScreen(
ThemeMode.DARK -> true
}
+ // Responsive dimensions based on window size
+ val windowSizeClass = LocalWindowSizeClass.current
+ val fabSpacerHeight = when (windowSizeClass.widthSizeClass) {
+ WindowWidthSizeClass.Expanded -> 240.dp
+ WindowWidthSizeClass.Medium -> 210.dp
+ WindowWidthSizeClass.Compact -> 180.dp
+ }
+
// Clear global title so our custom header shines
LaunchedEffect(Unit) {
viewModel.updateTopBarTitle("")
@@ -133,7 +143,7 @@ fun HomeScreen(
// Spacer for FABs clearance
item(key = "fab-spacer") {
- Spacer(modifier = Modifier.height(180.dp))
+ Spacer(modifier = Modifier.height(fabSpacerHeight))
}
}
@@ -314,9 +324,22 @@ private fun ActiveCycleHero(
val routine = cycleDay?.routineId?.let { id -> routines.find { it.id == id } }
val isRest = cycleDay?.isRestDay == true || cycleDay?.routineId == null
+ // Responsive dimensions based on window size
+ val windowSizeClass = LocalWindowSizeClass.current
+ val heroCardHeight = when (windowSizeClass.widthSizeClass) {
+ WindowWidthSizeClass.Expanded -> 240.dp
+ WindowWidthSizeClass.Medium -> 210.dp
+ WindowWidthSizeClass.Compact -> 180.dp
+ }
+ val heroImageSize = when (windowSizeClass.widthSizeClass) {
+ WindowWidthSizeClass.Expanded -> 280.dp
+ WindowWidthSizeClass.Medium -> 240.dp
+ WindowWidthSizeClass.Compact -> 200.dp
+ }
+
Card(
onClick = onViewSchedule,
- modifier = Modifier.fillMaxWidth().height(180.dp),
+ modifier = Modifier.fillMaxWidth().height(heroCardHeight),
shape = RoundedCornerShape(28.dp), // Expressive shape
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer)
) {
@@ -327,7 +350,7 @@ private fun ActiveCycleHero(
contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.05f),
modifier = Modifier
- .size(200.dp)
+ .size(heroImageSize)
.align(Alignment.BottomEnd)
.offset(x = 40.dp, y = 40.dp)
)
diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/InsightsTab.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/InsightsTab.kt
index b09608cd8..6bbffdfaa 100644
--- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/InsightsTab.kt
+++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/InsightsTab.kt
@@ -17,6 +17,33 @@ import com.devil.phoenixproject.domain.model.WeightUnit
import com.devil.phoenixproject.domain.model.WorkoutSession
import com.devil.phoenixproject.ui.theme.Spacing
import com.devil.phoenixproject.presentation.components.*
+import com.devil.phoenixproject.presentation.util.ResponsiveDimensions
+
+/**
+ * Wrapper composable that constrains card width on tablets to prevent over-stretching.
+ */
+@Composable
+private fun ResponsiveCardWrapper(
+ modifier: Modifier = Modifier,
+ content: @Composable () -> Unit
+) {
+ val maxWidth = ResponsiveDimensions.cardMaxWidth()
+
+ Box(
+ modifier = modifier.fillMaxWidth(),
+ contentAlignment = Alignment.Center
+ ) {
+ Box(
+ modifier = if (maxWidth != null) {
+ Modifier.widthIn(max = maxWidth).fillMaxWidth()
+ } else {
+ Modifier.fillMaxWidth()
+ }
+ ) {
+ content()
+ }
+ }
+}
/**
* Improved Insights Tab - Clear, actionable analytics with proper formatting
@@ -54,99 +81,113 @@ fun InsightsTab(
// This Week Summary Card - week-over-week comparison
item {
- ThisWeekSummaryCard(
- workoutSessions = workoutSessions,
- personalRecords = prs,
- weightUnit = weightUnit,
- modifier = Modifier.fillMaxWidth()
- )
+ ResponsiveCardWrapper {
+ ThisWeekSummaryCard(
+ workoutSessions = workoutSessions,
+ personalRecords = prs,
+ weightUnit = weightUnit,
+ modifier = Modifier.fillMaxWidth()
+ )
+ }
}
// 1. Muscle Balance Radar Chart (Replaces linear progress bars)
if (prs.isNotEmpty()) {
item {
- MuscleBalanceRadarCard(
- personalRecords = prs,
- exerciseRepository = exerciseRepository,
- modifier = Modifier.fillMaxWidth()
- )
+ ResponsiveCardWrapper {
+ MuscleBalanceRadarCard(
+ personalRecords = prs,
+ exerciseRepository = exerciseRepository,
+ modifier = Modifier.fillMaxWidth()
+ )
+ }
}
}
// 2. Workout Consistency Gauge (Replaces circular progress)
item {
- ConsistencyGaugeCard(
- workoutSessions = workoutSessions,
- modifier = Modifier.fillMaxWidth()
- )
+ ResponsiveCardWrapper {
+ ConsistencyGaugeCard(
+ workoutSessions = workoutSessions,
+ modifier = Modifier.fillMaxWidth()
+ )
+ }
}
// 3. Volume vs Intensity Combo Chart (New Metric)
if (workoutSessions.isNotEmpty()) {
item {
- VolumeVsIntensityCard(
- workoutSessions = workoutSessions,
- weightUnit = weightUnit,
- modifier = Modifier.fillMaxWidth()
- )
+ ResponsiveCardWrapper {
+ VolumeVsIntensityCard(
+ workoutSessions = workoutSessions,
+ weightUnit = weightUnit,
+ modifier = Modifier.fillMaxWidth()
+ )
+ }
}
}
// 4. Total Volume Trend (User Request)
if (workoutSessions.isNotEmpty()) {
item {
- TotalVolumeCard(
- workoutSessions = workoutSessions,
- weightUnit = weightUnit,
- formatWeight = formatWeight,
- modifier = Modifier.fillMaxWidth()
- )
+ ResponsiveCardWrapper {
+ TotalVolumeCard(
+ workoutSessions = workoutSessions,
+ weightUnit = weightUnit,
+ formatWeight = formatWeight,
+ modifier = Modifier.fillMaxWidth()
+ )
+ }
}
}
// 5. Mode Distribution Donut Chart (New Metric)
if (workoutSessions.isNotEmpty()) {
item {
- WorkoutModeDistributionCard(
- workoutSessions = workoutSessions,
- modifier = Modifier.fillMaxWidth()
- )
+ ResponsiveCardWrapper {
+ WorkoutModeDistributionCard(
+ workoutSessions = workoutSessions,
+ modifier = Modifier.fillMaxWidth()
+ )
+ }
}
}
// Empty state
if (prs.isEmpty() && workoutSessions.isEmpty()) {
item {
- Card(
- modifier = Modifier.fillMaxWidth(),
- colors = CardDefaults.cardColors(
- containerColor = MaterialTheme.colorScheme.surfaceContainerHighest
- )
- ) {
- Column(
- modifier = Modifier
- .fillMaxWidth()
- .padding(32.dp),
- horizontalAlignment = Alignment.CenterHorizontally
- ) {
- Icon(
- Icons.Default.Insights,
- contentDescription = null,
- tint = MaterialTheme.colorScheme.primary,
- modifier = Modifier.size(64.dp)
- )
- Spacer(modifier = Modifier.height(16.dp))
- Text(
- text = "No Insights Yet",
- style = MaterialTheme.typography.titleLarge,
- fontWeight = FontWeight.Bold
- )
- Spacer(modifier = Modifier.height(8.dp))
- Text(
- text = "Complete workouts to unlock insights",
- style = MaterialTheme.typography.bodyMedium,
- color = MaterialTheme.colorScheme.onSurfaceVariant
+ ResponsiveCardWrapper {
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surfaceContainerHighest
)
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(32.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Icon(
+ Icons.Default.Insights,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.size(64.dp)
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+ Text(
+ text = "No Insights Yet",
+ style = MaterialTheme.typography.titleLarge,
+ fontWeight = FontWeight.Bold
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ text = "Complete workouts to unlock insights",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
}
}
}
diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/WorkoutHud.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/WorkoutHud.kt
index 6581204ec..5e9af903a 100644
--- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/WorkoutHud.kt
+++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/WorkoutHud.kt
@@ -23,6 +23,9 @@ import com.devil.phoenixproject.data.repository.ExerciseRepository
import com.devil.phoenixproject.domain.model.*
import com.devil.phoenixproject.presentation.components.CircularForceGauge
import com.devil.phoenixproject.presentation.components.EnhancedCablePositionBar
+import com.devil.phoenixproject.presentation.util.ResponsiveDimensions
+import com.devil.phoenixproject.presentation.util.LocalWindowSizeClass
+import com.devil.phoenixproject.presentation.util.WindowWidthSizeClass
/**
* Workout Heads-Up Display (HUD)
@@ -207,11 +210,17 @@ private fun HudTopBar(
workoutMode: String,
onStopWorkout: () -> Unit
) {
+ val windowSizeClass = LocalWindowSizeClass.current
+ val buttonHeight = when (windowSizeClass.widthSizeClass) {
+ WindowWidthSizeClass.Expanded -> 80.dp
+ WindowWidthSizeClass.Medium -> 72.dp
+ WindowWidthSizeClass.Compact -> 64.dp
+ }
Row(
modifier = Modifier
.fillMaxWidth()
.statusBarsPadding()
- .height(64.dp)
+ .height(buttonHeight)
.padding(horizontal = 16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
@@ -405,13 +414,14 @@ private fun ExecutionPage(
formatWeight(perCableKg, weightUnit)
}
+ val hudSize = ResponsiveDimensions.componentSize(baseSize = 200.dp)
CircularForceGauge(
currentForce = perCableKg,
maxForce = gaugeMax,
velocity = (metric.velocityA + metric.velocityB) / 2.0,
label = forceLabel,
subLabel = "PER CABLE",
- modifier = Modifier.size(200.dp)
+ modifier = Modifier.size(hudSize)
)
} else {
Text("Waiting for data...", color = MaterialTheme.colorScheme.onSurfaceVariant)
diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/WorkoutTab.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/WorkoutTab.kt
index 3c2ad946f..feae642dc 100644
--- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/WorkoutTab.kt
+++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/WorkoutTab.kt
@@ -34,6 +34,8 @@ import com.devil.phoenixproject.presentation.components.RpeIndicator
import com.devil.phoenixproject.presentation.components.VideoPlayer
import com.devil.phoenixproject.data.repository.ExerciseVideoEntity
import com.devil.phoenixproject.ui.theme.Spacing
+import com.devil.phoenixproject.presentation.util.LocalWindowSizeClass
+import com.devil.phoenixproject.presentation.util.WindowWidthSizeClass
import kotlinx.coroutines.flow.SharedFlow
import kotlin.math.roundToInt
import com.devil.phoenixproject.ui.theme.screenBackgroundBrush
@@ -1068,6 +1070,13 @@ fun LiveMetricsCard(
weightUnit: WeightUnit,
formatWeight: (Float, WeightUnit) -> String
) {
+ val windowSizeClass = LocalWindowSizeClass.current
+ val labelWidth = when (windowSizeClass.widthSizeClass) {
+ WindowWidthSizeClass.Expanded -> 80.dp
+ WindowWidthSizeClass.Medium -> 65.dp
+ WindowWidthSizeClass.Compact -> 50.dp
+ }
+
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHighest),
@@ -1130,7 +1139,7 @@ fun LiveMetricsCard(
"${metric.positionA.toInt()}mm",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
- modifier = Modifier.width(50.dp).padding(start = Spacing.extraSmall),
+ modifier = Modifier.width(labelWidth).padding(start = Spacing.extraSmall),
textAlign = TextAlign.End
)
}
@@ -1160,7 +1169,7 @@ fun LiveMetricsCard(
"${metric.positionB.toInt()}mm",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
- modifier = Modifier.width(50.dp).padding(start = Spacing.extraSmall),
+ modifier = Modifier.width(labelWidth).padding(start = Spacing.extraSmall),
textAlign = TextAlign.End
)
}
@@ -2583,11 +2592,14 @@ fun ExercisePickerDialog(
)
},
text = {
- Column(
- modifier = Modifier
- .fillMaxWidth()
- .heightIn(max = 400.dp)
- ) {
+ BoxWithConstraints {
+ val maxSheetHeight = (maxHeight * 0.8f).coerceIn(300.dp, 600.dp)
+
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .heightIn(max = maxSheetHeight)
+ ) {
// Search field
OutlinedTextField(
value = searchQuery,
@@ -2681,6 +2693,7 @@ fun ExercisePickerDialog(
}
}
}
+ }
}
},
confirmButton = {},
diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/WorkoutTabAlt.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/WorkoutTabAlt.kt
index d34125a36..62621c092 100644
--- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/WorkoutTabAlt.kt
+++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/WorkoutTabAlt.kt
@@ -25,6 +25,9 @@ import com.devil.phoenixproject.presentation.components.AutoStopOverlay
import com.devil.phoenixproject.presentation.components.EnhancedCablePositionBar
import com.devil.phoenixproject.presentation.components.HapticFeedbackEffect
import com.devil.phoenixproject.presentation.components.VideoPlayer
+import com.devil.phoenixproject.presentation.util.LocalWindowSizeClass
+import com.devil.phoenixproject.presentation.util.ResponsiveDimensions
+import com.devil.phoenixproject.presentation.util.WindowWidthSizeClass
import com.devil.phoenixproject.ui.theme.screenBackgroundBrush
/**
@@ -79,6 +82,14 @@ fun WorkoutTabAlt(
// Note: HapticFeedbackEffect is now global in EnhancedMainScreen
// No need for local haptic effect here
+ // Responsive button height based on window size
+ val windowSizeClass = LocalWindowSizeClass.current
+ val buttonHeight = when (windowSizeClass.widthSizeClass) {
+ WindowWidthSizeClass.Expanded -> 80.dp
+ WindowWidthSizeClass.Medium -> 72.dp
+ WindowWidthSizeClass.Compact -> 64.dp
+ }
+
// Gradient backgrounds
val backgroundGradient = screenBackgroundBrush()
@@ -219,7 +230,7 @@ fun WorkoutTabAlt(
onClick = onStopWorkout,
modifier = Modifier
.fillMaxWidth()
- .height(64.dp), // Large hit target
+ .height(buttonHeight), // Responsive hit target (64/72/80dp)
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.errorContainer),
shape = RoundedCornerShape(32.dp)
) {
@@ -716,9 +727,10 @@ private fun AltCompletedStateView(
*/
@Composable
private fun SimpleCountdownOverlay(secondsRemaining: Int) {
+ val countdownSize = ResponsiveDimensions.componentSize(baseSize = 200.dp)
Card(
- modifier = Modifier.size(200.dp),
- shape = RoundedCornerShape(100.dp),
+ modifier = Modifier.size(countdownSize),
+ shape = RoundedCornerShape(countdownSize / 2), // Keep circular (radius = size/2)
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.95f)
),
diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/util/WindowSizeClass.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/util/WindowSizeClass.kt
new file mode 100644
index 000000000..cf3e52508
--- /dev/null
+++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/util/WindowSizeClass.kt
@@ -0,0 +1,134 @@
+package com.devil.phoenixproject.presentation.util
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.compositionLocalOf
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+
+/**
+ * Represents the size class of the window for responsive layouts.
+ * Based on Material 3 window size class breakpoints.
+ */
+enum class WindowWidthSizeClass {
+ /** Phones in portrait (< 600dp) */
+ Compact,
+ /** Small tablets, phones in landscape (600-840dp) */
+ Medium,
+ /** Large tablets, desktops (> 840dp) */
+ Expanded
+}
+
+enum class WindowHeightSizeClass {
+ /** Short screens (< 480dp) */
+ Compact,
+ /** Medium height (480-900dp) */
+ Medium,
+ /** Tall screens (> 900dp) */
+ Expanded
+}
+
+data class WindowSizeClass(
+ val widthSizeClass: WindowWidthSizeClass,
+ val heightSizeClass: WindowHeightSizeClass,
+ val widthDp: Dp,
+ val heightDp: Dp
+) {
+ val isTablet: Boolean
+ get() = widthSizeClass != WindowWidthSizeClass.Compact
+
+ val isExpandedTablet: Boolean
+ get() = widthSizeClass == WindowWidthSizeClass.Expanded
+}
+
+/**
+ * CompositionLocal for accessing WindowSizeClass throughout the app.
+ * Defaults to Compact (phone) if not provided.
+ */
+val LocalWindowSizeClass = compositionLocalOf {
+ WindowSizeClass(
+ widthSizeClass = WindowWidthSizeClass.Compact,
+ heightSizeClass = WindowHeightSizeClass.Medium,
+ widthDp = 400.dp,
+ heightDp = 800.dp
+ )
+}
+
+/**
+ * Calculate WindowSizeClass from screen dimensions.
+ */
+fun calculateWindowSizeClass(widthDp: Dp, heightDp: Dp): WindowSizeClass {
+ val widthClass = when {
+ widthDp < 600.dp -> WindowWidthSizeClass.Compact
+ widthDp < 840.dp -> WindowWidthSizeClass.Medium
+ else -> WindowWidthSizeClass.Expanded
+ }
+
+ val heightClass = when {
+ heightDp < 480.dp -> WindowHeightSizeClass.Compact
+ heightDp < 900.dp -> WindowHeightSizeClass.Medium
+ else -> WindowHeightSizeClass.Expanded
+ }
+
+ return WindowSizeClass(
+ widthSizeClass = widthClass,
+ heightSizeClass = heightClass,
+ widthDp = widthDp,
+ heightDp = heightDp
+ )
+}
+
+/**
+ * Responsive dimension helpers for common UI patterns.
+ */
+object ResponsiveDimensions {
+
+ /**
+ * Calculate responsive chart height based on window size.
+ * @param baseHeight The phone-sized height (compact)
+ * @param mediumMultiplier Scale factor for medium tablets (default 1.25)
+ * @param expandedMultiplier Scale factor for large tablets (default 1.5)
+ */
+ @Composable
+ fun chartHeight(
+ baseHeight: Dp,
+ mediumMultiplier: Float = 1.25f,
+ expandedMultiplier: Float = 1.5f
+ ): Dp {
+ val windowSizeClass = LocalWindowSizeClass.current
+ return when (windowSizeClass.widthSizeClass) {
+ WindowWidthSizeClass.Expanded -> baseHeight * expandedMultiplier
+ WindowWidthSizeClass.Medium -> baseHeight * mediumMultiplier
+ WindowWidthSizeClass.Compact -> baseHeight
+ }
+ }
+
+ /**
+ * Calculate max width for cards to prevent over-stretching on tablets.
+ * Returns null for phones (use full width), or a max width for tablets.
+ */
+ @Composable
+ fun cardMaxWidth(): Dp? {
+ val windowSizeClass = LocalWindowSizeClass.current
+ return when (windowSizeClass.widthSizeClass) {
+ WindowWidthSizeClass.Expanded -> 600.dp
+ WindowWidthSizeClass.Medium -> 500.dp
+ WindowWidthSizeClass.Compact -> null // No max, use full width
+ }
+ }
+
+ /**
+ * Calculate responsive component size (for gauges, HUDs, etc.)
+ */
+ @Composable
+ fun componentSize(baseSize: Dp): Dp {
+ val windowSizeClass = LocalWindowSizeClass.current
+ return when (windowSizeClass.widthSizeClass) {
+ WindowWidthSizeClass.Expanded -> baseSize * 1.6f
+ WindowWidthSizeClass.Medium -> baseSize * 1.3f
+ WindowWidthSizeClass.Compact -> baseSize
+ }
+ }
+}
+
+// Extension for Dp multiplication
+private operator fun Dp.times(factor: Float): Dp = (this.value * factor).dp
diff --git a/shared/src/commonMain/sqldelight/com/devil/phoenixproject/database/migrations/6.sqm b/shared/src/commonMain/sqldelight/com/devil/phoenixproject/database/migrations/6.sqm
new file mode 100644
index 000000000..e4e788e81
--- /dev/null
+++ b/shared/src/commonMain/sqldelight/com/devil/phoenixproject/database/migrations/6.sqm
@@ -0,0 +1,102 @@
+-- Migration 6: Add Training Cycles feature tables
+-- These tables support the new rolling training cycle feature
+-- Fixes startup crash for users upgrading from versions before Training Cycles
+
+-- Training Cycles (replaces WeeklyProgram - rolling schedule)
+CREATE TABLE IF NOT EXISTS TrainingCycle (
+ id TEXT PRIMARY KEY NOT NULL,
+ name TEXT NOT NULL,
+ description TEXT,
+ created_at INTEGER NOT NULL,
+ is_active INTEGER NOT NULL DEFAULT 0
+);
+
+-- Cycle Days (replaces ProgramDay - day 1, 2, 3... instead of Mon/Tue/Wed)
+CREATE TABLE IF NOT EXISTS CycleDay (
+ id TEXT PRIMARY KEY NOT NULL,
+ cycle_id TEXT NOT NULL,
+ day_number INTEGER NOT NULL,
+ name TEXT,
+ routine_id TEXT,
+ is_rest_day INTEGER NOT NULL DEFAULT 0,
+ echo_level TEXT,
+ eccentric_load_percent INTEGER,
+ weight_progression_percent REAL,
+ rep_modifier INTEGER,
+ rest_time_override_seconds INTEGER,
+ FOREIGN KEY (cycle_id) REFERENCES TrainingCycle(id) ON DELETE CASCADE,
+ FOREIGN KEY (routine_id) REFERENCES Routine(id) ON DELETE SET NULL
+);
+
+-- Cycle Progress (tracks current position in cycle)
+CREATE TABLE IF NOT EXISTS CycleProgress (
+ id TEXT PRIMARY KEY NOT NULL,
+ cycle_id TEXT NOT NULL UNIQUE,
+ current_day_number INTEGER NOT NULL DEFAULT 1,
+ last_completed_date INTEGER,
+ cycle_start_date INTEGER NOT NULL,
+ last_advanced_at INTEGER,
+ completed_days TEXT,
+ missed_days TEXT,
+ rotation_count INTEGER NOT NULL DEFAULT 0,
+ FOREIGN KEY (cycle_id) REFERENCES TrainingCycle(id) ON DELETE CASCADE
+);
+
+-- Cycle Progression Settings (cycle-wide, replaces per-day modifiers)
+CREATE TABLE IF NOT EXISTS CycleProgression (
+ cycle_id TEXT PRIMARY KEY NOT NULL,
+ frequency_cycles INTEGER NOT NULL DEFAULT 2,
+ weight_increase_percent REAL,
+ echo_level_increase INTEGER NOT NULL DEFAULT 0,
+ eccentric_load_increase_percent INTEGER,
+ FOREIGN KEY (cycle_id) REFERENCES TrainingCycle(id) ON DELETE CASCADE
+);
+
+-- Planned Sets (pre-defined sets for routine exercises)
+CREATE TABLE IF NOT EXISTS PlannedSet (
+ id TEXT PRIMARY KEY NOT NULL,
+ routine_exercise_id TEXT NOT NULL,
+ set_number INTEGER NOT NULL,
+ set_type TEXT NOT NULL DEFAULT 'STANDARD',
+ target_reps INTEGER,
+ target_weight_kg REAL,
+ target_rpe INTEGER,
+ rest_seconds INTEGER,
+ FOREIGN KEY (routine_exercise_id) REFERENCES RoutineExercise(id) ON DELETE CASCADE
+);
+
+-- Completed Sets (actual performed sets with full data)
+CREATE TABLE IF NOT EXISTS CompletedSet (
+ id TEXT PRIMARY KEY NOT NULL,
+ session_id TEXT NOT NULL,
+ planned_set_id TEXT,
+ set_number INTEGER NOT NULL,
+ set_type TEXT NOT NULL DEFAULT 'STANDARD',
+ actual_reps INTEGER NOT NULL,
+ actual_weight_kg REAL NOT NULL,
+ logged_rpe INTEGER,
+ is_pr INTEGER NOT NULL DEFAULT 0,
+ completed_at INTEGER NOT NULL,
+ FOREIGN KEY (session_id) REFERENCES WorkoutSession(id) ON DELETE CASCADE,
+ FOREIGN KEY (planned_set_id) REFERENCES PlannedSet(id) ON DELETE SET NULL
+);
+
+-- Progression Events (tracks auto-progression suggestions)
+CREATE TABLE IF NOT EXISTS ProgressionEvent (
+ id TEXT PRIMARY KEY NOT NULL,
+ exercise_id TEXT NOT NULL,
+ suggested_weight_kg REAL NOT NULL,
+ previous_weight_kg REAL NOT NULL,
+ reason TEXT NOT NULL,
+ user_response TEXT,
+ actual_weight_kg REAL,
+ timestamp INTEGER NOT NULL,
+ FOREIGN KEY (exercise_id) REFERENCES Exercise(id) ON DELETE CASCADE
+);
+
+-- Create indexes for efficient queries
+CREATE INDEX IF NOT EXISTS idx_cycle_day_cycle ON CycleDay(cycle_id);
+CREATE INDEX IF NOT EXISTS idx_cycle_progress_cycle ON CycleProgress(cycle_id);
+CREATE INDEX IF NOT EXISTS idx_planned_set_exercise ON PlannedSet(routine_exercise_id);
+CREATE INDEX IF NOT EXISTS idx_completed_set_session ON CompletedSet(session_id);
+CREATE INDEX IF NOT EXISTS idx_progression_event_exercise ON ProgressionEvent(exercise_id);
diff --git a/shared/src/iosMain/kotlin/com/devil/phoenixproject/util/CsvExporter.ios.kt b/shared/src/iosMain/kotlin/com/devil/phoenixproject/util/CsvExporter.ios.kt
index 9fff68109..07d94a435 100644
--- a/shared/src/iosMain/kotlin/com/devil/phoenixproject/util/CsvExporter.ios.kt
+++ b/shared/src/iosMain/kotlin/com/devil/phoenixproject/util/CsvExporter.ios.kt
@@ -7,6 +7,8 @@ import kotlinx.cinterop.ExperimentalForeignApi
import platform.Foundation.*
import platform.UIKit.UIActivityViewController
import platform.UIKit.UIApplication
+import platform.darwin.dispatch_async
+import platform.darwin.dispatch_get_main_queue
/**
* iOS implementation of CsvExporter.
@@ -123,25 +125,29 @@ class IosCsvExporter : CsvExporter {
override fun shareCSV(fileUri: String, fileName: String) {
val url = NSURL.fileURLWithPath(fileUri)
- // Get the key window's root view controller
- @Suppress("UNCHECKED_CAST")
- val scenes = UIApplication.sharedApplication.connectedScenes as Set<*>
- val windowScene = scenes.firstOrNull {
- it is platform.UIKit.UIWindowScene
- } as? platform.UIKit.UIWindowScene
-
- val rootViewController = windowScene?.keyWindow?.rootViewController ?: return
-
- val activityVC = UIActivityViewController(
- activityItems = listOf(url),
- applicationActivities = null
- )
-
- rootViewController.presentViewController(
- activityVC,
- animated = true,
- completion = null
- )
+ // Dispatch to main thread - UIKit requires all UI operations on main thread
+ dispatch_async(dispatch_get_main_queue()) {
+ // Get the key window's root view controller
+ @Suppress("UNCHECKED_CAST")
+ val scenes = UIApplication.sharedApplication.connectedScenes as Set<*>
+ val windowScene = scenes.firstOrNull {
+ it is platform.UIKit.UIWindowScene
+ } as? platform.UIKit.UIWindowScene
+
+ val rootViewController = windowScene?.keyWindow?.rootViewController
+ ?: return@dispatch_async
+
+ val activityVC = UIActivityViewController(
+ activityItems = listOf(url),
+ applicationActivities = null
+ )
+
+ rootViewController.presentViewController(
+ activityVC,
+ animated = true,
+ completion = null
+ )
+ }
}
/**