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 + ) + } } /**