Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
47e2c00
feat(tablet): add WindowSizeClass utility for responsive layouts
9thLevelSoftware Jan 4, 2026
cb43304
feat(tablet): integrate WindowSizeClass into EnhancedMainScreen
9thLevelSoftware Jan 4, 2026
54612d2
feat(tablet): make RoutinePickerDialog height responsive
9thLevelSoftware Jan 4, 2026
255bbda
feat(tablet): make bottom sheet heights responsive
9thLevelSoftware Jan 4, 2026
c1d5008
feat(tablet): make AutoStopOverlay dialog width responsive
9thLevelSoftware Jan 4, 2026
4214f0d
feat(tablet): add ResponsiveDimensions utility for chart scaling
9thLevelSoftware Jan 4, 2026
db88a8e
feat(tablet): make RadarChart height responsive
9thLevelSoftware Jan 4, 2026
64c1900
feat(tablet): make GaugeChart height responsive
9thLevelSoftware Jan 4, 2026
a0f42c0
feat(tablet): make AreaChart height responsive
9thLevelSoftware Jan 4, 2026
19265b0
feat(tablet): make ComboChart height responsive
9thLevelSoftware Jan 4, 2026
f394834
feat(tablet): make CircleChart height responsive
9thLevelSoftware Jan 4, 2026
233039e
feat(tablet): make VolumeTrendChart height responsive
9thLevelSoftware Jan 4, 2026
4ff7ee9
feat(database): add Training Cycles feature tables and migration hand…
9thLevelSoftware Jan 4, 2026
6cb4f97
feat(database): add Training Cycles feature tables and migration hand…
9thLevelSoftware Jan 4, 2026
d8d9ff5
feat(tablet): add responsive card wrapper to InsightsTab
9thLevelSoftware Jan 4, 2026
833c2d9
feat(tablet): make HomeScreen dimensions responsive
9thLevelSoftware Jan 4, 2026
63fb4de
feat(tablet): make WorkoutHud responsive
9thLevelSoftware Jan 4, 2026
e1ae0ad
feat(tablet): make BadgesScreen grid responsive
9thLevelSoftware Jan 4, 2026
120cf87
feat(tablet): make WorkoutTabAlt responsive
9thLevelSoftware Jan 4, 2026
89fe5a2
feat(tablet): make AnalyticsScreen UI elements responsive
9thLevelSoftware Jan 4, 2026
1f6d73b
feat(tablet): make WorkoutTab weight labels responsive
9thLevelSoftware Jan 4, 2026
bf39c64
feat(tablet): make ProfileSidePanel width responsive
9thLevelSoftware Jan 4, 2026
0badcb8
feat(tablet): make AnimatedActionButton heights responsive
9thLevelSoftware Jan 4, 2026
8416264
fix(DayCountPickerScreen): make horizontal padding responsive for tab…
9thLevelSoftware Jan 4, 2026
e79683d
fix(ConnectionLogsScreen): make log preview height responsive
9thLevelSoftware Jan 4, 2026
4efccf5
fix(ExerciseEditDialog): replace fixed widths with weight-based layouts
9thLevelSoftware Jan 4, 2026
74da12c
feat(tablet): add responsive dimensions to ShimmerEffect skeleton com…
9thLevelSoftware Jan 4, 2026
1795a88
Merge remote-tracking branch 'origin/tablet_fixes' into tablet_fixes
9thLevelSoftware Jan 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file modified .daem0nmcp/storage/daem0nmcp.db
Binary file not shown.
11 changes: 11 additions & 0 deletions androidApp/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,17 @@
android:name=".service.WorkoutForegroundService"
android:foregroundServiceType="connectedDevice"
android:exported="false" />

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

</manifest>
5 changes: 5 additions & 0 deletions androidApp/src/main/res/xml/file_paths.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<!-- Cache directory for CSV exports -->
<cache-path name="exports" path="exports/" />
</paths>
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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())) {
Comment on lines +73 to +74

Copilot AI Jan 4, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The regex pattern "table .* already exists" is created on every SQL statement execution inside a loop. For better performance, compile this regex once outside the loop or use a simple string contains check instead.

Suggested change
msg.contains("already exists") ||
msg.contains("table .* already exists".toRegex())) {
msg.contains("already exists")) {

Copilot uses AI. Check for mistakes.
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<String> {
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()
}
}
Comment on lines +88 to +195

Copilot AI Jan 4, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The hardcoded SQL migration statements in getMigrationStatements() duplicate the migration definitions from the .sqm files. This creates a maintenance burden - if migrations are updated, both locations must be changed. Consider reading the .sqm files directly or relying solely on SQLDelight's built-in migration mechanism with better error handling.

Copilot uses AI. Check for mistakes.

/**
* 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<app.cash.sqldelight.Transacter.Transaction> {
throw UnsupportedOperationException()
}
override fun execute(
identifier: Int?,
sql: String,
parameters: Int,
binders: (app.cash.sqldelight.db.SqlPreparedStatement.() -> Unit)?
): app.cash.sqldelight.db.QueryResult<Long> {
db.execSQL(sql)
return app.cash.sqldelight.db.QueryResult.Value(0L)
}
override fun <R> executeQuery(
identifier: Int?,
sql: String,
mapper: (app.cash.sqldelight.db.SqlCursor) -> app.cash.sqldelight.db.QueryResult<R>,
parameters: Int,
binders: (app.cash.sqldelight.db.SqlPreparedStatement.() -> Unit)?
): app.cash.sqldelight.db.QueryResult<R> {
throw UnsupportedOperationException()
}
}
}

override fun onCorruption(db: SupportSQLiteDatabase) {
Log.e(TAG, "Database corruption detected")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -254,7 +264,7 @@ fun AnimatedActionButton(
Box(
modifier = modifier
.fillMaxWidth()
.height(64.dp)
.height(buttonHeight)
.scale(scale * pulseScale)
.clip(RoundedCornerShape(28.dp))
.onFire()
Expand Down Expand Up @@ -305,7 +315,7 @@ fun AnimatedActionButton(
interactionSource = interactionSource,
modifier = modifier
.fillMaxWidth()
.height(64.dp)
.height(buttonHeight)
.scale(scale * pulseScale),
icon = {
Icon(
Expand All @@ -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) }
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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(
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading