diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..2557a4a7 --- /dev/null +++ b/Makefile @@ -0,0 +1,36 @@ +.PHONY: release debug pull tasks find repo one install clean + +JAVA_HOME := /Library/Java/JavaVirtualMachines/temurin-21.jdk/Contents/Home + +release: + ./gradlew --no-daemon assembleRelease + +debug: + ./gradlew --no-daemon assembleDebug + +repo: + open https://github.com/Wavesonics/FastTrack +tasks: + ./gradlew --no-daemon -q :tasks + +find: + find app/build/outputs -iname '*.apk' + open -R app/build/outputs/apk/release/*.apk + +one: + adb -s 321c9dca install app/build/outputs/apk/release/*.apk + +install: + adb install app/build/outputs/apk/release/*.apk + +install-debug: + adb install app/build/outputs/apk/debug/*.apk + +clean: + ./gradlew --no-daemon clean + +check-jetifier: + ./gradlew checkJetifier + +test: + ./gradlew app:testDebugUnitTest diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 8c746192..46eb43f0 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -7,6 +7,12 @@ plugins { alias(libs.plugins.kotlin.compose) } +// make APK filenames follow -.apk, +// i.e. FastTrack-release.apk and FastTrack-dev.apk +base { + archivesName.set("FastTrack") +} + android { namespace = "com.darkrockstudios.apps.fasttrack" compileSdk = libs.versions.compileSdk.get().toInt() @@ -144,16 +150,12 @@ dependencies { implementation(libs.koin.androidx.compose) implementation(libs.koin.androidx.workmanager) - implementation(libs.customdatetimepicker) - implementation(libs.satchel.core) implementation(libs.satchel.storer.encrypted.file) implementation(libs.satchel.serializer.base64.android) implementation(libs.compose.markdown) - implementation(libs.materialabout) - implementation(libs.core) implementation(libs.ext.latex) implementation(libs.ext.strikethrough) diff --git a/app/schemas/com.darkrockstudios.apps.fasttrack.data.database.AppDatabase/2.json b/app/schemas/com.darkrockstudios.apps.fasttrack.data.database.AppDatabase/2.json new file mode 100644 index 00000000..c10bb8e2 --- /dev/null +++ b/app/schemas/com.darkrockstudios.apps.fasttrack.data.database.AppDatabase/2.json @@ -0,0 +1,50 @@ +{ + "formatVersion": 1, + "database": { + "version": 2, + "identityHash": "82e9fabc07c671aa9a050a69e505a918", + "entities": [ + { + "tableName": "FastEntry", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `start` INTEGER NOT NULL, `length` INTEGER NOT NULL, `notes` TEXT NOT NULL DEFAULT '')", + "fields": [ + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "start", + "columnName": "start", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "length", + "columnName": "length", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notes", + "columnName": "notes", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "uid" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '82e9fabc07c671aa9a050a69e505a918')" + ] + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/darkrockstudios/apps/fasttrack/data/settings/FakeSettingsDatasource.kt b/app/src/androidTest/java/com/darkrockstudios/apps/fasttrack/data/settings/FakeSettingsDatasource.kt index bff1f5ff..6d8f9b17 100644 --- a/app/src/androidTest/java/com/darkrockstudios/apps/fasttrack/data/settings/FakeSettingsDatasource.kt +++ b/app/src/androidTest/java/com/darkrockstudios/apps/fasttrack/data/settings/FakeSettingsDatasource.kt @@ -10,6 +10,7 @@ class FakeSettingsDatasource : SettingsDatasource { private var showFastingNotification: Boolean = true private var useMetricSystem: Boolean? = null private var themeMode: ThemeMode = ThemeMode.SYSTEM + private var dateStyle: DateStyle = DateStyle.OPTIMIZED_COMPACT private var logViewMode: LogViewMode = LogViewMode.LIST override fun getFastingAlerts(): Boolean = fastingAlerts @@ -46,8 +47,32 @@ class FakeSettingsDatasource : SettingsDatasource { themeMode = mode } + override fun getDateStyle(): DateStyle = dateStyle + override fun setDateStyle(style: DateStyle) { + dateStyle = style + } + override fun getLogViewMode(): LogViewMode = logViewMode override fun setLogViewMode(mode: LogViewMode) { logViewMode = mode } + + // In-memory phase visibility, matching the production defaults so a screen + // driven by this fake behaves like the real app unless a test overrides it. + private var showFatBurn: Boolean = true + private var showKetosis: Boolean = true + private var showAutophagy: Boolean = true + private var phaseAutoMode: Boolean = false + + override fun getShowFatBurn(): Boolean = showFatBurn + override fun setShowFatBurn(enabled: Boolean) { showFatBurn = enabled } + override fun getShowKetosis(): Boolean = showKetosis + override fun setShowKetosis(enabled: Boolean) { showKetosis = enabled } + override fun getShowAutophagy(): Boolean = showAutophagy + override fun setShowAutophagy(enabled: Boolean) { showAutophagy = enabled } + override fun getPhaseAutoMode(): Boolean = phaseAutoMode + override fun setPhaseAutoMode(enabled: Boolean) { phaseAutoMode = enabled } + + override fun phaseVisibilityFlow(): Flow = + flowOf(PhaseVisibility(showFatBurn, showKetosis, showAutophagy, phaseAutoMode)) } diff --git a/app/src/main/java/com/darkrockstudios/apps/fasttrack/FastTrackApp.kt b/app/src/main/java/com/darkrockstudios/apps/fasttrack/FastTrackApp.kt index cb834fd6..34affe54 100644 --- a/app/src/main/java/com/darkrockstudios/apps/fasttrack/FastTrackApp.kt +++ b/app/src/main/java/com/darkrockstudios/apps/fasttrack/FastTrackApp.kt @@ -30,7 +30,9 @@ class FastTrackApp : Application(), Configuration.Provider { override fun onCreate() { super.onCreate() - Napier.base(DebugAntilog()) + if (BuildConfig.DEBUG) { + Napier.base(DebugAntilog()) + } Satchel.init( storer = FileSatchelStorer(File(filesDir, Data.STORAGE_PATH)), diff --git a/app/src/main/java/com/darkrockstudios/apps/fasttrack/FastingNotificationManager.kt b/app/src/main/java/com/darkrockstudios/apps/fasttrack/FastingNotificationManager.kt index 4f62fb43..d3b2aeab 100644 --- a/app/src/main/java/com/darkrockstudios/apps/fasttrack/FastingNotificationManager.kt +++ b/app/src/main/java/com/darkrockstudios/apps/fasttrack/FastingNotificationManager.kt @@ -14,7 +14,9 @@ import androidx.compose.ui.graphics.toArgb import androidx.core.app.ActivityCompat import androidx.core.app.NotificationCompat import com.darkrockstudios.apps.fasttrack.data.Stages +import com.darkrockstudios.apps.fasttrack.data.descriptionFor import com.darkrockstudios.apps.fasttrack.screens.main.MainActivity +import com.darkrockstudios.apps.fasttrack.utils.formatDuration import com.darkrockstudios.apps.fasttrack.utils.getColorFor import io.github.aakira.napier.Napier import kotlin.time.Duration @@ -53,10 +55,8 @@ object FastingNotificationManager { } val stage = Stages.stage[stageIndex] - // Format elapsed time - val timeText = elapsedTime.toComponents { hours, _, _, _ -> - "%d".format(hours) - } + // The notification refreshes hourly, so keep the duration hour-granular + val timeText = formatDuration(context, elapsedTime, withMinutes = false) // Get energy mode val energyMode = if (currentPhase.fatBurning) { @@ -66,14 +66,14 @@ object FastingNotificationManager { } // Build notification content - val title = context.getString(R.string.notification_fasting_title, timeText) + val title = context.getString(R.string.notification_fasting_title_duration, timeText) val contentText = context.getString(stage.title) val expandedText = buildString { append(context.getString(stage.title)) append("\n") append(context.getString(R.string.notification_fasting_energy_mode, energyMode)) append("\n") - append(context.getString(stage.description)) + append(context.getString(descriptionFor(stage, elapsedHours))) } val intent = Intent(context, MainActivity::class.java).apply { diff --git a/app/src/main/java/com/darkrockstudios/apps/fasttrack/data/Data.kt b/app/src/main/java/com/darkrockstudios/apps/fasttrack/data/Data.kt index 7f1f82d4..434820a8 100644 --- a/app/src/main/java/com/darkrockstudios/apps/fasttrack/data/Data.kt +++ b/app/src/main/java/com/darkrockstudios/apps/fasttrack/data/Data.kt @@ -12,7 +12,12 @@ object Data const val KEY_FASTING_NOTIFICATION = "fasting_notification" const val KEY_METRIC_SYSTEM = "metric_system" const val KEY_THEME_MODE = "theme_mode" + const val KEY_DATE_STYLE = "date_style" const val KEY_LOG_VIEW_MODE = "log_view_mode" + const val KEY_SHOW_FAT_BURN = "show_fat_burn" + const val KEY_SHOW_KETOSIS = "show_ketosis" + const val KEY_SHOW_AUTOPHAGY = "show_autophagy" + const val KEY_PHASE_AUTO_MODE = "phase_auto_mode" private const val CM_INCH_RATIO = 2.54 fun inchToCm(inches: Int): Double = inches.toDouble() * CM_INCH_RATIO diff --git a/app/src/main/java/com/darkrockstudios/apps/fasttrack/data/FastingJourney.kt b/app/src/main/java/com/darkrockstudios/apps/fasttrack/data/FastingJourney.kt new file mode 100644 index 00000000..71fb5b82 --- /dev/null +++ b/app/src/main/java/com/darkrockstudios/apps/fasttrack/data/FastingJourney.kt @@ -0,0 +1,37 @@ +package com.darkrockstudios.apps.fasttrack.data + +import androidx.annotation.StringRes +import com.darkrockstudios.apps.fasttrack.R + +/** + * One stage of the fasting journey as shown on the dial. + * The canonical stage text lives in fasting-stages.md at the repo root; + */ +data class JourneyStage( + val startHours: Int, + /** null means the stage is open-ended (the final stage) */ + val endHours: Int?, + val emoji: String, + @StringRes val title: Int, + @StringRes val body: Int, +) + +object FastingJourney { + val stages = listOf( + JourneyStage(0, 2, "🌅", R.string.journey_stage_0_title, R.string.journey_stage_0_body), + JourneyStage(2, 5, "🌊", R.string.journey_stage_1_title, R.string.journey_stage_1_body), + JourneyStage(5, 8, "⚖️", R.string.journey_stage_2_title, R.string.journey_stage_2_body), + JourneyStage(8, 12, "🧪", R.string.journey_stage_3_title, R.string.journey_stage_3_body), + JourneyStage(12, 18, "🔥", R.string.journey_stage_4_title, R.string.journey_stage_4_body), + JourneyStage(18, 24, "💎", R.string.journey_stage_5_title, R.string.journey_stage_5_body), + JourneyStage(24, 48, "♻️", R.string.journey_stage_6_title, R.string.journey_stage_6_body), + JourneyStage(48, 54, "💪", R.string.journey_stage_7_title, R.string.journey_stage_7_body), + JourneyStage(54, 72, "🎯", R.string.journey_stage_8_title, R.string.journey_stage_8_body), + JourneyStage(72, null, "🌱", R.string.journey_stage_9_title, R.string.journey_stage_9_body), + ) + + fun indexFor(elapsedHours: Double): Int = + stages.indexOfLast { elapsedHours >= it.startHours }.coerceAtLeast(0) + + fun stageFor(elapsedHours: Double): JourneyStage = stages[indexFor(elapsedHours)] +} diff --git a/app/src/main/java/com/darkrockstudios/apps/fasttrack/data/Stages.kt b/app/src/main/java/com/darkrockstudios/apps/fasttrack/data/Stages.kt index 8cd15390..76b1a594 100644 --- a/app/src/main/java/com/darkrockstudios/apps/fasttrack/data/Stages.kt +++ b/app/src/main/java/com/darkrockstudios/apps/fasttrack/data/Stages.kt @@ -46,6 +46,31 @@ object Stages { ) } +/** Hours spent in ketosis for a fast of the given [length] (0 if it never reached ketosis). */ +fun ketosisHours(length: Duration): Double { + val start = Stages.PHASE_KETOSIS.hours.toDouble() + return (length.toDouble(DurationUnit.HOURS) - start).coerceAtLeast(0.0) +} + +/** Hours spent in autophagy for a fast of the given [length] (0 if it never reached autophagy). */ +fun autophagyHours(length: Duration): Double { + val start = Stages.PHASE_AUTOPHAGY.hours.toDouble() + return (length.toDouble(DurationUnit.HOURS) - start).coerceAtLeast(0.0) +} + +/** + * The description to show for [stage] at [elapsedHours]. Optimal autophagy's copy + * talks about the window "between now and 72 hours", which no longer applies once a + * fast is past 72h, so beyond that we switch to a version without the deadline. + */ +@StringRes +fun descriptionFor(stage: Stage, elapsedHours: Long): Int = + if (stage.description == R.string.stage_optimal_autophagy_description && elapsedHours >= 72L) { + R.string.stage_optimal_autophagy_description_past72 + } else { + stage.description + } + fun phaseForStage(stage: Stage): Phase { return when { stage.hours >= 48 -> Stages.PHASE_OPTIMAL_AUTOPHAGY diff --git a/app/src/main/java/com/darkrockstudios/apps/fasttrack/data/activefast/ActiveFastPreferencesDataSource.kt b/app/src/main/java/com/darkrockstudios/apps/fasttrack/data/activefast/ActiveFastPreferencesDataSource.kt index c2882b45..afc5d09f 100644 --- a/app/src/main/java/com/darkrockstudios/apps/fasttrack/data/activefast/ActiveFastPreferencesDataSource.kt +++ b/app/src/main/java/com/darkrockstudios/apps/fasttrack/data/activefast/ActiveFastPreferencesDataSource.kt @@ -1,14 +1,17 @@ package com.darkrockstudios.apps.fasttrack.data.activefast import android.content.Context -import android.preference.PreferenceManager import androidx.core.content.edit import com.darkrockstudios.apps.fasttrack.data.Data import kotlin.time.Instant class ActiveFastPreferencesDataSource(appContext: Context) : ActiveFastDataSource { - private val storage = PreferenceManager.getDefaultSharedPreferences(appContext) + // The historical default-preferences file, addressed directly now that + // android.preference.PreferenceManager is deprecated (same file, same data). + private val storage = appContext.getSharedPreferences( + "${appContext.packageName}_preferences", Context.MODE_PRIVATE + ) override fun getFastStart(): Instant? { val mills = storage.getLong(Data.KEY_FAST_START, -1) diff --git a/app/src/main/java/com/darkrockstudios/apps/fasttrack/data/database/AppDatabase.kt b/app/src/main/java/com/darkrockstudios/apps/fasttrack/data/database/AppDatabase.kt index 0c4e9579..0ff9e91f 100644 --- a/app/src/main/java/com/darkrockstudios/apps/fasttrack/data/database/AppDatabase.kt +++ b/app/src/main/java/com/darkrockstudios/apps/fasttrack/data/database/AppDatabase.kt @@ -2,9 +2,19 @@ package com.darkrockstudios.apps.fasttrack.data.database import androidx.room.Database import androidx.room.RoomDatabase +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase -@Database(entities = [FastEntry::class], version = 1) -abstract class AppDatabase: RoomDatabase() -{ +@Database(entities = [FastEntry::class], version = 2) +abstract class AppDatabase : RoomDatabase() { abstract fun fastDao(): FastEntryDao + + companion object { + /** v2 adds the optional `notes` column to each fast entry. */ + val MIGRATION_1_2 = object : Migration(1, 2) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE FastEntry ADD COLUMN notes TEXT NOT NULL DEFAULT ''") + } + } + } } diff --git a/app/src/main/java/com/darkrockstudios/apps/fasttrack/data/database/FastEntry.kt b/app/src/main/java/com/darkrockstudios/apps/fasttrack/data/database/FastEntry.kt index 63c7e7a8..29cdc810 100644 --- a/app/src/main/java/com/darkrockstudios/apps/fasttrack/data/database/FastEntry.kt +++ b/app/src/main/java/com/darkrockstudios/apps/fasttrack/data/database/FastEntry.kt @@ -3,39 +3,26 @@ package com.darkrockstudios.apps.fasttrack.data.database import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.PrimaryKey -import com.darkrockstudios.apps.fasttrack.data.Stages +import com.darkrockstudios.apps.fasttrack.data.autophagyHours +import com.darkrockstudios.apps.fasttrack.data.ketosisHours import kotlin.time.Duration.Companion.milliseconds import kotlin.time.DurationUnit /** - * start - a linux UTC epoch timestamp in milliseconds + * start - a linux UTC epoch timestamp in milliseconds * length - duration in milliseconds of the fast + * notes - optional user note captured when a fast ends (may be empty) */ @Entity data class FastEntry( @PrimaryKey(autoGenerate = true) val uid: Int = 0, @ColumnInfo val start: Long, - @ColumnInfo val length: Long + @ColumnInfo val length: Long, + @ColumnInfo(defaultValue = "''") val notes: String = "" ) { fun lengthHours() = length.milliseconds.toDouble(DurationUnit.HOURS) - fun calculateKetosis(): Double { - val ketosisStart = Stages.PHASE_KETOSIS.hours.toDouble() - val lenHours = lengthHours() - return if (lenHours > ketosisStart) { - return lenHours - ketosisStart - } else { - 0.0 - } - } + fun calculateKetosis(): Double = ketosisHours(length.milliseconds) - fun calculateAutophagy(): Double { - val autophagyStart = Stages.PHASE_AUTOPHAGY.hours.toDouble() - val lenHours = lengthHours() - return if (lenHours > autophagyStart) { - return lenHours - autophagyStart - } else { - 0.0 - } - } + fun calculateAutophagy(): Double = autophagyHours(length.milliseconds) } \ No newline at end of file diff --git a/app/src/main/java/com/darkrockstudios/apps/fasttrack/data/database/FastEntryDao.kt b/app/src/main/java/com/darkrockstudios/apps/fasttrack/data/database/FastEntryDao.kt index df54f3fd..a36d6079 100644 --- a/app/src/main/java/com/darkrockstudios/apps/fasttrack/data/database/FastEntryDao.kt +++ b/app/src/main/java/com/darkrockstudios/apps/fasttrack/data/database/FastEntryDao.kt @@ -24,6 +24,12 @@ interface FastEntryDao @Query("DELETE FROM fastentry WHERE start = :startTime") fun deleteByStartTime(startTime: Long): Int + @Query("DELETE FROM fastentry WHERE start >= :fromInclusive AND start < :toExclusive") + fun deleteByStartRange(fromInclusive: Long, toExclusive: Long): Int + @Query("DELETE FROM fastentry WHERE uid = :uid") fun deleteByUid(uid: Int): Int + + @Query("DELETE FROM fastentry") + fun deleteAll(): Int } diff --git a/app/src/main/java/com/darkrockstudios/apps/fasttrack/data/log/FakeFastingLogDatasource.kt b/app/src/main/java/com/darkrockstudios/apps/fasttrack/data/log/FakeFastingLogDatasource.kt index d957c3d5..096a1298 100644 --- a/app/src/main/java/com/darkrockstudios/apps/fasttrack/data/log/FakeFastingLogDatasource.kt +++ b/app/src/main/java/com/darkrockstudios/apps/fasttrack/data/log/FakeFastingLogDatasource.kt @@ -56,6 +56,13 @@ class FakeFastingLogDatasource : FastingLogDatasource { return entries.size < initialSize } + override fun deleteByStartRange(fromInclusive: Long, toExclusive: Long): Boolean { + val initialSize = entries.size + entries.removeAll { it.start >= fromInclusive && it.start < toExclusive } + updateFlow() + return entries.size < initialSize + } + override fun deleteByUid(uid: Int): Boolean { val initialSize = entries.size entries.removeAll { it.uid == uid } @@ -63,6 +70,13 @@ class FakeFastingLogDatasource : FastingLogDatasource { return entries.size < initialSize } + override fun deleteAllEntries(): Int { + val removed = entries.size + entries.clear() + updateFlow() + return removed + } + private fun updateFlow() { entriesFlow.value = entries.toList() } diff --git a/app/src/main/java/com/darkrockstudios/apps/fasttrack/data/log/FastingLogDatabaseDatasource.kt b/app/src/main/java/com/darkrockstudios/apps/fasttrack/data/log/FastingLogDatabaseDatasource.kt index 9a452034..7e9f7a2c 100644 --- a/app/src/main/java/com/darkrockstudios/apps/fasttrack/data/log/FastingLogDatabaseDatasource.kt +++ b/app/src/main/java/com/darkrockstudios/apps/fasttrack/data/log/FastingLogDatabaseDatasource.kt @@ -15,5 +15,8 @@ class FastingLogDatabaseDatasource( override fun update(entry: FastEntry): Boolean = dao.update(entry) > 0 override fun delete(entry: FastEntry): Boolean = dao.delete(entry) > 0 override fun deleteByStartTime(startTime: Long): Boolean = dao.deleteByStartTime(startTime) > 0 + override fun deleteByStartRange(fromInclusive: Long, toExclusive: Long): Boolean = + dao.deleteByStartRange(fromInclusive, toExclusive) > 0 override fun deleteByUid(uid: Int): Boolean = dao.deleteByUid(uid) > 0 + override fun deleteAllEntries(): Int = dao.deleteAll() } diff --git a/app/src/main/java/com/darkrockstudios/apps/fasttrack/data/log/FastingLogDatasource.kt b/app/src/main/java/com/darkrockstudios/apps/fasttrack/data/log/FastingLogDatasource.kt index 5f1906e9..09dabc19 100644 --- a/app/src/main/java/com/darkrockstudios/apps/fasttrack/data/log/FastingLogDatasource.kt +++ b/app/src/main/java/com/darkrockstudios/apps/fasttrack/data/log/FastingLogDatasource.kt @@ -10,5 +10,8 @@ interface FastingLogDatasource { fun update(entry: FastEntry): Boolean fun delete(entry: FastEntry): Boolean fun deleteByStartTime(startTime: Long): Boolean + fun deleteByStartRange(fromInclusive: Long, toExclusive: Long): Boolean fun deleteByUid(uid: Int): Boolean + /** Delete every entry; returns the number of rows removed. */ + fun deleteAllEntries(): Int } diff --git a/app/src/main/java/com/darkrockstudios/apps/fasttrack/data/log/FastingLogEntry.kt b/app/src/main/java/com/darkrockstudios/apps/fasttrack/data/log/FastingLogEntry.kt index ec9f547f..e63ec004 100644 --- a/app/src/main/java/com/darkrockstudios/apps/fasttrack/data/log/FastingLogEntry.kt +++ b/app/src/main/java/com/darkrockstudios/apps/fasttrack/data/log/FastingLogEntry.kt @@ -4,7 +4,8 @@ import kotlinx.datetime.LocalDateTime import kotlin.time.Duration data class FastingLogEntry( - val id: Int, + val id: Int, val start: LocalDateTime, - val length: Duration + val length: Duration, + val notes: String = "" ) diff --git a/app/src/main/java/com/darkrockstudios/apps/fasttrack/data/log/FastingLogRepository.kt b/app/src/main/java/com/darkrockstudios/apps/fasttrack/data/log/FastingLogRepository.kt index 4255b862..e708650f 100644 --- a/app/src/main/java/com/darkrockstudios/apps/fasttrack/data/log/FastingLogRepository.kt +++ b/app/src/main/java/com/darkrockstudios/apps/fasttrack/data/log/FastingLogRepository.kt @@ -6,11 +6,45 @@ import kotlin.time.Duration import kotlin.time.Instant interface FastingLogRepository { - fun logFast(startTime: Instant, endTime: Instant) + fun logFast(startTime: Instant, endTime: Instant, notes: String = "") fun loadAll(): Flow> fun delete(item: FastingLogEntry): Boolean - fun addLogEntry(start: LocalDateTime, length: Duration) - fun updateLogEntry(entry: FastingLogEntry, start: LocalDateTime, length: Duration): Boolean + /** Delete the entire logbook; returns the number of entries removed. */ + fun deleteAllEntries(): Int + fun addLogEntry(start: LocalDateTime, length: Duration, notes: String = "") + // notes defaults to the entry's current notes so an edit that omits them preserves them + fun updateLogEntry( + entry: FastingLogEntry, + start: LocalDateTime, + length: Duration, + notes: String = entry.notes + ): Boolean suspend fun exportLog(): String suspend fun importLog(cvsExport: String): Boolean + + /** Export the logbook as an iCalendar (RFC 5545) document. */ + suspend fun exportIcs(): String + + /** Export the logbook as an ActivityStreams 2.0 (JSON-LD) document. */ + suspend fun exportActivityStreams(): String + + /** + * Import fasts from an EasyFast backup ZIP (we only read its `fasts.json`). + * Any imported fast whose [start, finish) range overlaps an existing log + * entry is skipped and counted, so repeated imports never duplicate data. + */ + suspend fun importEasyFastBackup(zipBytes: ByteArray): ImportResult + + /** Import fasts from an iCalendar (RFC 5545) document, skipping overlaps. */ + suspend fun importIcs(icsText: String): ImportResult + + /** Import fasts from an ActivityStreams 2.0 (JSON-LD) document, skipping overlaps. */ + suspend fun importActivityStreams(jsonText: String): ImportResult } + +/** Outcome of a backup import. */ +data class ImportResult( + val imported: Int, + val skippedOverlapping: Int, + val ok: Boolean, +) diff --git a/app/src/main/java/com/darkrockstudios/apps/fasttrack/data/log/FastingLogRepositoryImpl.kt b/app/src/main/java/com/darkrockstudios/apps/fasttrack/data/log/FastingLogRepositoryImpl.kt index f4b6c743..c6f31dac 100644 --- a/app/src/main/java/com/darkrockstudios/apps/fasttrack/data/log/FastingLogRepositoryImpl.kt +++ b/app/src/main/java/com/darkrockstudios/apps/fasttrack/data/log/FastingLogRepositoryImpl.kt @@ -1,139 +1,861 @@ package com.darkrockstudios.apps.fasttrack.data.log import com.darkrockstudios.apps.fasttrack.data.database.FastEntry +import com.darkrockstudios.apps.fasttrack.utils.csvEscape +import com.darkrockstudios.apps.fasttrack.utils.formatDurationFull +import com.darkrockstudios.apps.fasttrack.utils.parseCsv import io.github.aakira.napier.Napier +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map -import kotlinx.datetime.* -import java.time.format.DateTimeFormatter +import kotlinx.coroutines.withContext +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.TimeZone +import kotlinx.datetime.number +import kotlinx.datetime.toInstant +import kotlinx.datetime.toLocalDateTime +import java.util.Locale import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds -import java.time.LocalDate as JavaLocalDate -import java.time.LocalDateTime as JavaLocalDateTime -import java.time.LocalTime as JavaLocalTime +import kotlin.time.Instant + class FastingLogRepositoryImpl( private val datasource: FastingLogDatasource ) : FastingLogRepository { - override fun logFast(startTime: Instant, endTime: Instant) { + + override fun logFast(startTime: Instant, endTime: Instant, notes: String) { val duration = endTime.minus(startTime) - val newEntry = FastEntry( - start = startTime.toEpochMilliseconds(), - length = duration.inWholeMilliseconds + datasource.insertAll( + FastEntry( + start = startTime.toEpochMilliseconds(), + length = duration.inWholeMilliseconds, + notes = notes, + ) ) - datasource.insertAll(newEntry) } override fun loadAll(): Flow> = datasource.loadAll().map { entries -> entries.map { it.toFastingLogEntry() } } - override fun delete(item: FastingLogEntry): Boolean { - return datasource.deleteByUid(item.id) - } + override fun delete(item: FastingLogEntry): Boolean = datasource.deleteByUid(item.id) - override fun addLogEntry(start: LocalDateTime, length: Duration) { - // Convert LocalDateTime to Instant (UTC time) - val startInstant = start.toInstant(TimeZone.currentSystemDefault()) + override fun deleteAllEntries(): Int = datasource.deleteAllEntries() - // Create FastEntry with UTC epoch milliseconds and duration in milliseconds - val newEntry = FastEntry( - start = startInstant.toEpochMilliseconds(), - length = length.inWholeMilliseconds + override fun addLogEntry(start: LocalDateTime, length: Duration, notes: String) { + val startInstant = start.toInstant(TimeZone.currentSystemDefault()) + datasource.insertAll( + FastEntry( + start = startInstant.toEpochMilliseconds(), + length = length.inWholeMilliseconds, + notes = notes, + ) ) - datasource.insertAll(newEntry) } - override fun updateLogEntry(entry: FastingLogEntry, start: LocalDateTime, length: Duration): Boolean { - // Convert LocalDateTime to Instant (UTC time) + override fun updateLogEntry( + entry: FastingLogEntry, + start: LocalDateTime, + length: Duration, + notes: String + ): Boolean { val startInstant = start.toInstant(TimeZone.currentSystemDefault()) - - // Create updated FastEntry with the same ID but new values val updatedEntry = FastEntry( uid = entry.id, start = startInstant.toEpochMilliseconds(), - length = length.inWholeMilliseconds + length = length.inWholeMilliseconds, + notes = notes, ) return datasource.update(updatedEntry) } - override suspend fun exportLog(): String { - val entries = loadAll().first() + // region Export - val header = "ID,Start Date,Start Time,Duration (hours)" + override suspend fun exportLog(): String = withContext(Dispatchers.IO) { + val entries = datasource.getAll() + val tz = TimeZone.currentSystemDefault() + val header = "ID,Start,End,Duration (s),Duration,Notes" val rows = entries.map { entry -> - val startDate = entry.start.date.toString() - val startTime = "${entry.start.hour}:${entry.start.minute.toString().padStart(2, '0')}" - val durationHours = entry.length.inWholeHours + val startInstant = Instant.fromEpochMilliseconds(entry.start) + val length = entry.length.milliseconds + val endInstant = startInstant.plus(length) + + val startStr = formatDateTime(startInstant.toLocalDateTime(tz)) + val endStr = formatDateTime(endInstant.toLocalDateTime(tz)) + val seconds = length.inWholeSeconds + val humanized = formatDurationFull(length) - "${entry.id},$startDate,$startTime,$durationHours" + listOf( + entry.uid.toString(), + startStr, + endStr, + seconds.toString(), + humanized, + csvEscape(entry.notes), + ).joinToString(",") } - return (listOf(header) + rows).joinToString("\n") + (listOf(header) + rows).joinToString("\n") } - private val dateFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") - private val timeFormatter = DateTimeFormatter.ofPattern("H:mm") + // endregion + + // region Import - override suspend fun importLog(cvsExport: String): Boolean { + /** + * Import a logbook CSV. Supports both the current schema + * (ID, Start, End, Duration (s), Duration, Notes) and the legacy schema + * (ID, Start Date, Start Time, Duration (hours)). Timestamps are interpreted + * in the device's current time zone, matching how they were exported. + * + * Entries are de-duplicated by start-second, so re-importing the same file + * updates rather than duplicates. + */ + override suspend fun importLog(cvsExport: String): Boolean = withContext(Dispatchers.IO) { try { - val lines = cvsExport.split("\n") + val allRows = parseCsv(cvsExport).filter { row -> row.any { it.isNotBlank() } } + if (allRows.isEmpty()) return@withContext false + + val headerCells = allRows.first().map { it.trim().lowercase() } + val hasHeader = headerCells.any { cell -> + cell == "id" || cell == "start" || cell == "end" || cell == "notes" || + cell == "start date" || cell == "start time" || cell.startsWith("duration") + } + val isLegacy = headerCells.contains("start date") || + headerCells.any { it.startsWith("duration (h") } + + val dataRows = if (hasHeader) allRows.drop(1) else allRows + val tz = TimeZone.currentSystemDefault() + + // Resolve column indices from the header when present; otherwise fall + // back to the canonical positions of each schema. + val cols = if (hasHeader) headerCells else emptyList() + fun col(vararg names: String, default: Int): Int { + for (n in names) { + val idx = cols.indexOfFirst { it == n || it.startsWith(n) } + if (idx >= 0) return idx + } + return default + } + + var imported = false + for (row in dataRows) { + val parsed = if (isLegacy) { + parseLegacyRow( + row, + dateIdx = col("start date", default = 1), + timeIdx = col("start time", default = 2), + hoursIdx = col("duration (h", default = 3), + tz = tz, + ) + } else { + parseCurrentRow( + row, + startIdx = col("start", default = 1), + endIdx = col("end", default = 2), + secondsIdx = col("duration (s", default = 3), + notesIdx = col("notes", default = 5), + tz = tz, + ) + } ?: continue + + val (startEpoch, lengthMs, notes) = parsed + // De-dupe within the whole second the start falls in + val secondFloor = startEpoch - (startEpoch % 1000) + datasource.deleteByStartRange(secondFloor, secondFloor + 1000) + datasource.insertAll(FastEntry(start = startEpoch, length = lengthMs, notes = notes)) + imported = true + } + + imported + } catch (e: Exception) { + Napier.e("Failed to import logbook", e) + false + } + } + + private data class ParsedRow(val startEpoch: Long, val lengthMs: Long, val notes: String) + + private fun parseCurrentRow( + row: List, + startIdx: Int, + endIdx: Int, + secondsIdx: Int, + notesIdx: Int, + tz: TimeZone, + ): ParsedRow? { + val start = parseLocalDateTime(row.getOrNull(startIdx)) ?: return null + val startInstant = start.toInstant(tz) + + val end = parseLocalDateTime(row.getOrNull(endIdx)) + val lengthMs: Long = when { + end != null -> { + val d = end.toInstant(tz).minus(startInstant) + if (d > Duration.ZERO) d.inWholeMilliseconds + else row.getOrNull(secondsIdx)?.trim()?.toLongOrNull()?.times(1000) ?: return null + } + + else -> row.getOrNull(secondsIdx)?.trim()?.toLongOrNull()?.times(1000) ?: return null + } + + val notes = row.getOrNull(notesIdx)?.trim().orEmpty() + return ParsedRow(startInstant.toEpochMilliseconds(), lengthMs, notes) + } + + private fun parseLegacyRow( + row: List, + dateIdx: Int, + timeIdx: Int, + hoursIdx: Int, + tz: TimeZone, + ): ParsedRow? { + val dateStr = row.getOrNull(dateIdx)?.trim() ?: return null + val timeStr = row.getOrNull(timeIdx)?.trim() ?: return null + val start = parseLocalDateTime("$dateStr $timeStr") ?: return null + val startInstant = start.toInstant(tz) - // Skip header line - if (lines.size <= 1) { - return false + // Legacy duration was whole hours (but tolerate a decimal just in case) + val hoursStr = row.getOrNull(hoursIdx)?.trim() ?: return null + val hours = hoursStr.toLongOrNull()?.toDouble() ?: hoursStr.toDoubleOrNull() ?: return null + val lengthMs = (hours * 60.0 * 60.0 * 1000.0).toLong() + + return ParsedRow(startInstant.toEpochMilliseconds(), lengthMs, "") + } + + // endregion + + // region Backup import (shared) + + /** A fast to import: absolute epoch-millis [start, finish) plus notes. */ + private data class ImportedFast(val start: Long, val finish: Long, val notes: String) + + /** + * Insert [records] that don't overlap an existing entry (or an earlier + * record in this same batch). Overlaps are half-open [start, finish) and are + * skipped + counted, so repeated imports never duplicate data. Runs blocking + * DB I/O, so callers must invoke it on a background dispatcher. + */ + private fun importFasts(records: List): ImportResult { + val intervals = datasource.getAll() + .map { it.start to (it.start + it.length) } + .toMutableList() + + var imported = 0 + var skippedOverlapping = 0 + for (rec in records) { + // Ignore records without a valid finished range (never counted) + if (rec.finish <= rec.start) continue + + val overlaps = intervals.any { (s, e) -> rec.start < e && s < rec.finish } + if (overlaps) { + skippedOverlapping++ + continue } - // Process each data row - for (i in 1 until lines.size) { - val line = lines[i].trim() - if (line.isEmpty()) continue + datasource.insertAll( + FastEntry( + start = rec.start, + length = rec.finish - rec.start, + notes = rec.notes, + ) + ) + intervals.add(rec.start to rec.finish) + imported++ + } + return ImportResult(imported, skippedOverlapping, ok = true) + } - val parts = line.split(",") - if (parts.size < 4) continue // Skip invalid lines + // endregion + + // region EasyFast backup import + + override suspend fun importEasyFastBackup(zipBytes: ByteArray): ImportResult = + withContext(Dispatchers.IO) { + try { + val json = extractFastsJson(zipBytes) + ?: return@withContext ImportResult(0, 0, ok = false) + importFasts(parseEasyFastRecords(json)) + } catch (e: Exception) { + Napier.e("Failed to import EasyFast backup", e) + ImportResult(0, 0, ok = false) + } + } + + /** Read the `fasts.json` entry out of an EasyFast backup ZIP, or null. */ + private fun extractFastsJson(zipBytes: ByteArray): String? { + java.util.zip.ZipInputStream(java.io.ByteArrayInputStream(zipBytes)).use { zis -> + var entry = zis.nextEntry + while (entry != null) { + if (!entry.isDirectory && entry.name.substringAfterLast('/') == "fasts.json") { + return zis.readBytes().toString(Charsets.UTF_8) + } + entry = zis.nextEntry + } + } + return null + } + + /** Parse the EasyFast fasts.json array; start/finish are epoch millis. */ + private fun parseEasyFastRecords(json: String): List { + val array = org.json.JSONArray(json) + val out = ArrayList(array.length()) + for (i in 0 until array.length()) { + val obj = array.getJSONObject(i) + out.add( + ImportedFast( + start = obj.optLong("start", 0L), + finish = obj.optLong("finish", 0L), + notes = obj.optString("notes", "").trim(), + ) + ) + } + return out + } + + // endregion + + // region iCalendar (RFC 5545) + + override suspend fun exportIcs(): String = withContext(Dispatchers.IO) { + val entries = datasource.getAll() + val dtStamp = icsUtc(System.currentTimeMillis()) + + val lines = ArrayList() + lines += "BEGIN:VCALENDAR" + lines += "VERSION:2.0" + lines += "PRODID:-//Dark Rock Studios//Fast Track//EN" + lines += "CALSCALE:GREGORIAN" + for (e in entries) { + val start = e.start + val finish = e.start + e.length + lines += "BEGIN:VEVENT" + lines += "UID:fasttrack-$start-$finish@darkrockstudios.com" + lines += "DTSTAMP:$dtStamp" + lines += "DTSTART:${icsUtc(start)}" + lines += "DTEND:${icsUtc(finish)}" + lines += "SUMMARY:${icsEscape("Fast — " + formatDurationFull(e.length.milliseconds))}" + if (e.notes.isNotBlank()) lines += "DESCRIPTION:${icsEscape(e.notes)}" + lines += "END:VEVENT" + } + lines += "END:VCALENDAR" + + // RFC 5545 uses CRLF line breaks and folds content lines over 75 octets. + lines.joinToString("\r\n") { foldIcsLine(it) } + "\r\n" + } + + override suspend fun importIcs(icsText: String): ImportResult = withContext(Dispatchers.IO) { + try { + importFasts(parseIcs(icsText)) + } catch (e: Exception) { + Napier.e("Failed to import iCalendar", e) + ImportResult(0, 0, ok = false) + } + } + + private fun parseIcs(icsText: String): List { + val out = ArrayList() + var inEvent = false + var start: Long? = null + var end: Long? = null + var durationMs: Long? = null + var desc = "" + + for (line in unfoldIcs(icsText)) { + when (line.uppercase()) { + "BEGIN:VEVENT" -> { + inEvent = true; start = null; end = null; durationMs = null; desc = "" + } + + "END:VEVENT" -> { + if (inEvent && start != null) { + val finish = end ?: durationMs?.let { start + it } + if (finish != null) out += ImportedFast(start, finish, desc.trim()) + } + inEvent = false + } + + else -> if (inEvent) { + val colon = line.indexOf(':') + if (colon > 0) { + val propPart = line.substring(0, colon) + val value = line.substring(colon + 1) + val segs = propPart.split(';') + val tzid = segs.drop(1) + .firstOrNull { it.uppercase().startsWith("TZID=") } + ?.substringAfter('=') + when (segs[0].uppercase()) { + "DTSTART" -> start = parseIcsDateTime(value, tzid) + "DTEND" -> end = parseIcsDateTime(value, tzid) + "DURATION" -> durationMs = parseIsoDurationMs(value) + "DESCRIPTION" -> desc = icsUnescape(value) + } + } + } + } + } + return out + } + + /** RFC 5545 line unfolding: a line starting with space/tab continues the prior line. */ + private fun unfoldIcs(text: String): List { + val physical = text.replace("\r\n", "\n").replace("\r", "\n").split("\n") + val logical = ArrayList() + for (line in physical) { + if ((line.startsWith(" ") || line.startsWith("\t")) && logical.isNotEmpty()) { + logical[logical.size - 1] = logical[logical.size - 1] + line.substring(1) + } else { + logical += line + } + } + return logical + } + + /** Parse a basic-format iCalendar date-time ("YYYYMMDDThhmmss[Z]"), or null. + * Optimized to avoid object allocation. + */ + internal fun parseIcsDateTime(value: String, tzid: String?): Long? { + var idx = 0 + var end = value.lastIndex + while (idx <= end && value[idx].isWhitespace()) idx++ + while (end >= idx && value[end].isWhitespace()) end-- + if (idx > end) return null + + val isUtc = value[end] == 'Z' + val coreEnd = if (isUtc) end - 1 else end + + var tPos = idx + while (tPos <= coreEnd && value[tPos] != 'T') { + tPos++ + } - val id = parts[0].toIntOrNull() ?: continue - val dateStr = parts[1] - val timeStr = parts[2] - val durationHours = parts[3].toLongOrNull() ?: continue + val dateLength = tPos - idx + if (dateLength < 8) return null - val localDateTime: LocalDateTime + val y = parse4Digits(value, idx) + if (y < 0) return null + val m = parse2Digits(value, idx + 4) + if (m !in 1..12) return null + val day = parse2Digits(value, idx + 6) + if (day !in 1..31) return null + + val tl = coreEnd - tPos + val h = if (tl >= 2) parse2Digits(value, tPos + 1) else 0 + if (h !in 0..23) return null + val mi = if (tl >= 4) parse2Digits(value, tPos + 3) else 0 + if (mi !in 0..59) return null + val s = if (tl >= 6) parse2Digits(value, tPos + 5) else 0 + if (s !in 0..59) return null + + if (isUtc) { + val maxDay = when (m) { + 2 -> if (y % 4 == 0 && (y % 100 != 0 || y % 400 == 0)) 29 else 28 + 4, 6, 9, 11 -> 30 + else -> 31 + } + if (day > maxDay) return null + + val yr = if (m <= 2) y - 1L else y.toLong() + val mo = if (m <= 2) m + 12 else m + val days = 365L * yr + yr / 4 - yr / 100 + yr / 400 + (153L * mo - 457) / 5 + day - 719469L + return days * 86_400_000L + h * 3_600_000L + mi * 60_000L + s * 1_000L + } + + return try { + val ldt = LocalDateTime(y, m, day, h, mi, s) + val zone = if (tzid != null) { try { - val javaDate = JavaLocalDate.parse(dateStr, dateFormatter) - val javaTime = JavaLocalTime.parse(timeStr, timeFormatter) - val javaDateTime = JavaLocalDateTime.of(javaDate, javaTime) - - localDateTime = LocalDateTime( - javaDateTime.year, - javaDateTime.monthValue, - javaDateTime.dayOfMonth, - javaDateTime.hour, - javaDateTime.minute - ) - } catch (e: Exception) { - Napier.w("Failed to import log entry", e) - continue + TimeZone.of(tzid) + } catch (_: Exception) { + TimeZone.currentSystemDefault() } + } else { + TimeZone.currentSystemDefault() + } + ldt.toInstant(zone).toEpochMilliseconds() + } catch (_: IllegalArgumentException) { + null + } + } + + /** + * Parses a 4-digit number at the specified index offset. + * Returns -1 if index is out of bounds or characters are non-numeric. + */ + @Suppress("NOTHING_TO_INLINE") + private inline fun parse4Digits(s: String, offset: Int): Int { + if (offset + 3 >= s.length) return -1 + val d0 = s[offset] - '0' + val d1 = s[offset + 1] - '0' + val d2 = s[offset + 2] - '0' + val d3 = s[offset + 3] - '0' + if (d0 !in 0..9 || d1 !in 0..9 || d2 !in 0..9 || d3 !in 0..9) return -1 + return d0 * 1000 + d1 * 100 + d2 * 10 + d3 + } - val startInstant = localDateTime.toInstant(TimeZone.UTC) + /** + * Parses a 2-digit number at the specified index offset. + * Returns -1 if index is out of bounds or characters are non-numeric. + */ + @Suppress("NOTHING_TO_INLINE") + private inline fun parse2Digits(s: String, offset: Int): Int { + if (offset + 1 >= s.length) return -1 + val d0 = s[offset] - '0' + val d1 = s[offset + 1] - '0' + if (d0 !in 0..9 || d1 !in 0..9) return -1 + return d0 * 10 + d1 + } - val newEntry = FastEntry( - uid = id, - start = startInstant.toEpochMilliseconds(), - length = durationHours * 60 * 60 * 1000 // Convert hours to milliseconds - ) + private fun icsUtc(epochMs: Long): String { + val d = Instant.fromEpochMilliseconds(epochMs).toLocalDateTime(TimeZone.UTC) + return String.format( + Locale.ROOT, "%04d%02d%02dT%02d%02d%02dZ", + d.year, d.month.number, d.day, d.hour, d.minute, d.second + ) + } - // Handle conflicts by deleting existing entry with the same ID - datasource.deleteByUid(id) + private fun icsEscape(text: String): String = + text.replace("\\", "\\\\") + .replace(";", "\\;") + .replace(",", "\\,") + .replace("\r\n", "\\n") + .replace("\n", "\\n") + .replace("\r", "\\n") - datasource.insertAll(newEntry) + private fun icsUnescape(text: String): String { + val sb = StringBuilder(text.length) + var i = 0 + while (i < text.length) { + val c = text[i] + if (c == '\\' && i + 1 < text.length) { + when (val n = text[i + 1]) { + 'n', 'N' -> sb.append('\n') + '\\' -> sb.append('\\') + ';' -> sb.append(';') + ',' -> sb.append(',') + else -> sb.append(n) + } + i += 2 + } else { + sb.append(c) + i++ } + } + return sb.toString() + } - return true + /** Fold a content line to <=75 octets per physical line (continuation begins with a space). */ + private fun foldIcsLine(line: String): String { + val out = StringBuilder() + var octets = 0 + for (ch in line) { + val chOctets = ch.toString().toByteArray(Charsets.UTF_8).size + if (octets + chOctets > 75) { + out.append("\r\n ") + octets = 1 + } + out.append(ch) + octets += chOctets + } + return out.toString() + } + + // endregion + + // region ActivityStreams 2.0 (JSON-LD) + + override suspend fun exportActivityStreams(): String = withContext(Dispatchers.IO) { + val entries = datasource.getAll() + val items = org.json.JSONArray() + for (e in entries) { + val start = e.start + val finish = e.start + e.length + val obj = org.json.JSONObject() + obj.put("type", "Event") + obj.put("name", "Fast") + obj.put("startTime", isoUtc(start)) + obj.put("endTime", isoUtc(finish)) + obj.put("duration", isoDuration(e.length)) + if (e.notes.isNotBlank()) obj.put("content", e.notes) + items.put(obj) + } + val root = org.json.JSONObject() + root.put("@context", "https://www.w3.org/ns/activitystreams") + root.put("type", "OrderedCollection") + root.put("totalItems", entries.size) + root.put("orderedItems", items) + root.toString(2) + } + + override suspend fun importActivityStreams(jsonText: String): ImportResult = withContext(Dispatchers.IO) { + try { + importFasts(parseActivityStreams(jsonText)) } catch (e: Exception) { - return false + Napier.e("Failed to import ActivityStreams", e) + ImportResult(0, 0, ok = false) + } + } + + private fun parseActivityStreams(jsonText: String): List { + val trimmed = jsonText.trim() + val items = org.json.JSONArray() + if (trimmed.startsWith("[")) { + val a = org.json.JSONArray(trimmed) + for (i in 0 until a.length()) items.put(a.get(i)) + } else { + val root = org.json.JSONObject(trimmed) + val arr = root.optJSONArray("orderedItems") ?: root.optJSONArray("items") + if (arr != null) { + for (i in 0 until arr.length()) items.put(arr.get(i)) + } else if (root.has("startTime")) { + items.put(root) + } + } + + val out = ArrayList(items.length()) + for (i in 0 until items.length()) { + val o = items.optJSONObject(i) ?: continue + val start = parseIso8601(o.optString("startTime", "")) ?: continue + val end = parseIso8601(o.optString("endTime", "")) + ?: parseIsoDurationMs(o.optString("duration", ""))?.let { start + it } + ?: continue + val notes = when { + o.has("content") -> o.optString("content", "") + o.has("summary") -> o.optString("summary", "") + else -> "" + }.trim() + out += ImportedFast(start, end, notes) + } + return out + } + + private fun isoUtc(epochMs: Long): String { + val d = Instant.fromEpochMilliseconds(epochMs).toLocalDateTime(TimeZone.UTC) + return String.format( + Locale.ROOT, "%04d-%02d-%02dT%02d:%02d:%02dZ", + d.year, d.month.number, d.day, d.hour, d.minute, d.second + ) + } + + private fun isoDuration(ms: Long): String { + val totalSeconds = (ms / 1000).coerceAtLeast(0) + val h = totalSeconds / 3600 + val m = (totalSeconds % 3600) / 60 + val s = totalSeconds % 60 + return "PT${h}H${m}M${s}S" + } + + /** Parse an extended-format ISO 8601 date-time (Z, ±offset, or floating/local), or null. */ +// ==================================================================== +// SECTION 1: Standard ISO 8601 Parser (V4 Supercharged) +// ==================================================================== + + /** + * High-performance, zero-allocation parser for ISO 8601 date-time strings. + * Hand-optimized to avoid substrings, splits, and JVM allocations. + */ + internal fun parseIso8601(value: String): Long? { + // 1. Zero-Allocation Unicode-Aware Trim + var idx = 0 + var end = value.lastIndex + while (idx <= end && value[idx].isWhitespace()) idx++ + while (end >= idx && value[end].isWhitespace()) end-- + if (idx > end) return null + + // 2. Locate 'T' separator + var tPos = idx + while (tPos <= end && value[tPos] != 'T' && value[tPos] != 't') { + tPos++ + } + if (tPos > end) return null // Missing 'T' separator + + // 3. Strict Date Part Validation (Expected: YYYY-MM-DD = 10 chars) + val dateLength = tPos - idx + if (dateLength != 10) return null + if (value[idx + 4] != '-' || value[idx + 7] != '-') return null + + val y = parse4Digits(value, idx) + if (y < 0) return null + val m = parse2Digits(value, idx + 5) + if (m !in 1..12) return null + val day = parse2Digits(value, idx + 8) + if (day !in 1..31) return null + + // 4. Single-pass Scan of Time and Offset components + var offsetSignPos = -1 + var dotPos = -1 + var i = tPos + 1 + while (i <= end) { + val c = value[i] + if (c == '+' || c == '-') { + offsetSignPos = i + break // Offset components occupy remainder of string + } + if (c == 'Z' || c == 'z') { + offsetSignPos = i + break + } + if (c == '.') { + dotPos = i + } + i++ + } + + // Isolate pure time component length (excluding milliseconds and offsets) + val timeEndLimit = if (offsetSignPos != -1) offsetSignPos else end + 1 + val mainTimeEnd = if (dotPos != -1 && dotPos < timeEndLimit) dotPos else timeEndLimit + val mainTimeLen = mainTimeEnd - (tPos + 1) + + // 5. Extract Time Elements (HH:mm:ss, HH:mm, or HH) + val h = if (mainTimeLen >= 2) { + val v = parse2Digits(value, tPos + 1) + if (v !in 0..23) return null else v + } else 0 + + val mi = if (mainTimeLen >= 5) { + if (value[tPos + 3] != ':') return null + val v = parse2Digits(value, tPos + 4) + if (v !in 0..59) return null else v + } else 0 + + val s = if (mainTimeLen >= 8) { + if (value[tPos + 6] != ':') return null + val v = parse2Digits(value, tPos + 7) + if (v !in 0..59) return null else v + } else 0 + + // 6. Resolve Offset Minutes without allocations + var offsetMinutes: Int? = null + if (offsetSignPos != -1) { + val c = value[offsetSignPos] + if (c == 'Z' || c == 'z') { + offsetMinutes = 0 + } else { + val sign = if (c == '-') -1 else 1 + val offLen = end - offsetSignPos + var offsetHours = 0 + var offsetMins = 0 + + if (offLen >= 2) { + val h0 = value[offsetSignPos + 1] - '0' + val h1 = value[offsetSignPos + 2] - '0' + if (h0 !in 0..9 || h1 !in 0..9) return null + offsetHours = h0 * 10 + h1 + + if (offLen >= 4) { // Handles +HH:MM or +HHMM styles + val hasColon = value[offsetSignPos + 3] == ':' + val minStart = if (hasColon) offsetSignPos + 4 else offsetSignPos + 3 + if (minStart + 1 <= end) { + val m0 = value[minStart] - '0' + val m1 = value[minStart + 1] - '0' + if (m0 in 0..9 && m1 in 0..9) { + offsetMins = m0 * 10 + m1 + } else return null + } else return null + } + } else { + return null + } + offsetMinutes = sign * (offsetHours * 60 + offsetMins) + } + } + + // 7. Calculate final timestamp + if (offsetMinutes != null) { + // Enforce Leap Year / Month bounds manually + val maxDay = when (m) { + 2 -> if (y % 4 == 0 && (y % 100 != 0 || y % 400 == 0)) 29 else 28 + 4, 6, 9, 11 -> 30 + else -> 31 + } + if (day > maxDay) return null + + // Safe Gregorian Epoch Calculation (No-Alloc UTC math) + val yr = if (m <= 2) y - 1L else y.toLong() + val mo = if (m <= 2) m + 12 else m + val days = 365L * yr + yr / 4 - yr / 100 + yr / 400 + (153L * mo - 457) / 5 + day - 719469L + val localEpochMs = days * 86_400_000L + h * 3_600_000L + mi * 60_000L + s * 1_000L + + // Subtract offset to get absolute UTC Epoch Mills + return localEpochMs - (offsetMinutes * 60_000L) + } + + // Fallback: Local Date-Time without offset -> resolves via System TimeZone + return try { + val ldt = LocalDateTime(y, m, day, h, mi, s) + ldt.toInstant(TimeZone.currentSystemDefault()).toEpochMilliseconds() + } catch (_: IllegalArgumentException) { + null + } + } + /** Parse an ISO 8601 duration ("PnW", "PnDTnHnMnS", …) into milliseconds, or null. */ + private fun parseIsoDurationMs(value: String): Long? { + var idx = 0 + val end = value.length + while (idx < end && value[idx] <= ' ') idx++ // Fast, zero-alloc trim start + + if (idx >= end) { + return null + } + + val sign = when (value[idx]) { + '-' -> -1.also { idx++ } + '+' -> 1.also { idx++ } + else -> 1 + } + + if (idx >= end || (value[idx] != 'P' && value[idx] != 'p')) { + return null + } + idx++ + + var total = 0L + var accum = 0L + var inTime = false + + while (idx < end) { + val c = value[idx++] + when (c) { + in '0'..'9' -> accum = accum * 10 + (c - '0') + 'T', 't' -> inTime = true + 'W', 'w' -> { total += accum * 604800000L; accum = 0 } + 'D', 'd' -> { total += accum * 86400000L; accum = 0 } + 'H', 'h' -> { total += accum * 3600000L; accum = 0 } + 'M', 'm' -> { total += accum * (if (inTime) 60000L else return null); accum = 0 } + 'S', 's' -> { total += accum * 1000L; accum = 0 } + ' ', '\t', '\r', '\n' -> {} // Leniently skip inner/trailing spaces + else -> return null // Protects against corrupt characters + } + } + // If accum is not 0, a number was left dangling without a designator (invalid RFC format) + return if (accum == 0L) total * sign else null + } + + // endregion + + private fun formatDateTime(d: LocalDateTime): String = + "%04d-%02d-%02d %02d:%02d:%02d".format( + d.year, d.month.number, d.day, d.hour, d.minute, d.second + ) + + /** + * Parse "yyyy-MM-dd HH:mm[:ss]" (a space or 'T' separator, seconds optional). + * Locale-independent; returns null on any malformed input. + */ + private fun parseLocalDateTime(raw: String?): LocalDateTime? { + val text = raw?.trim()?.replace('T', ' ') ?: return null + return try { + val parts = text.split(Regex("\\s+")) + if (parts.size < 2) return null + val (y, mo, d) = parts[0].split('-').map { it.toInt() } + val timeParts = parts[1].split(':') + val h = timeParts[0].toInt() + val mi = timeParts.getOrNull(1)?.toInt() ?: 0 + val s = timeParts.getOrNull(2)?.toInt() ?: 0 + LocalDateTime(y, mo, d, h, mi, s) + } catch (_: Exception) { + null } } @@ -143,7 +865,8 @@ class FastingLogRepositoryImpl( return FastingLogEntry( id = uid, start = localDateTime, - length = length.milliseconds + length = length.milliseconds, + notes = notes, ) } } diff --git a/app/src/main/java/com/darkrockstudios/apps/fasttrack/data/log/LogExportFormat.kt b/app/src/main/java/com/darkrockstudios/apps/fasttrack/data/log/LogExportFormat.kt new file mode 100644 index 00000000..f5d1e96c --- /dev/null +++ b/app/src/main/java/com/darkrockstudios/apps/fasttrack/data/log/LogExportFormat.kt @@ -0,0 +1,15 @@ +package com.darkrockstudios.apps.fasttrack.data.log + +import androidx.annotation.StringRes +import com.darkrockstudios.apps.fasttrack.R + +/** Logbook export formats offered to the user. */ +enum class LogExportFormat( + @StringRes val labelRes: Int, + val extension: String, + val mimeType: String, +) { + CSV(R.string.export_format_csv, "csv", "text/csv"), + ICS(R.string.export_format_ics, "ics", "text/calendar"), + ACTIVITY_STREAMS(R.string.export_format_activitystreams, "json", "application/activity+json"), +} diff --git a/app/src/main/java/com/darkrockstudios/apps/fasttrack/data/settings/DateStyle.kt b/app/src/main/java/com/darkrockstudios/apps/fasttrack/data/settings/DateStyle.kt new file mode 100644 index 00000000..76fbbd94 --- /dev/null +++ b/app/src/main/java/com/darkrockstudios/apps/fasttrack/data/settings/DateStyle.kt @@ -0,0 +1,31 @@ +package com.darkrockstudios.apps.fasttrack.data.settings + +/** + * How dates and times are presented throughout the app. Each style is locale-aware + * (field order and hour cycle follow the user's locale / system 24-hour setting); + * only the level of detail differs. See [com.darkrockstudios.apps.fasttrack.utils.AppDateTime]. + */ +enum class DateStyle { + /** Tightest one-line form: no weekday, current year omitted, minutes dropped at :00 (12h). */ + OPTIMIZED_COMPACT, + + /** Compact, but keeps the short weekday. */ + OPTIMIZED_WEEKDAY, + + /** The OS locale's short date + short time. */ + SYSTEM_SHORT, + + /** The OS locale's medium date + short time. */ + SYSTEM_MEDIUM, + + /** The OS locale's long date + short time. */ + SYSTEM_LONG, + + /** Year-first, unambiguous, sortable (2025-06-01 20:05). */ + ISO; + + companion object { + fun fromName(name: String?): DateStyle = + entries.firstOrNull { it.name == name } ?: OPTIMIZED_COMPACT + } +} diff --git a/app/src/main/java/com/darkrockstudios/apps/fasttrack/data/settings/SettingsDatasource.kt b/app/src/main/java/com/darkrockstudios/apps/fasttrack/data/settings/SettingsDatasource.kt index e8cddd07..d0331348 100644 --- a/app/src/main/java/com/darkrockstudios/apps/fasttrack/data/settings/SettingsDatasource.kt +++ b/app/src/main/java/com/darkrockstudios/apps/fasttrack/data/settings/SettingsDatasource.kt @@ -25,6 +25,29 @@ interface SettingsDatasource { fun getThemeMode(): ThemeMode fun setThemeMode(mode: ThemeMode) + fun getDateStyle(): DateStyle + fun setDateStyle(style: DateStyle) + fun getLogViewMode(): LogViewMode fun setLogViewMode(mode: LogViewMode) -} \ No newline at end of file + + fun getShowFatBurn(): Boolean + fun setShowFatBurn(enabled: Boolean) + fun getShowKetosis(): Boolean + fun setShowKetosis(enabled: Boolean) + fun getShowAutophagy(): Boolean + fun setShowAutophagy(enabled: Boolean) + + /** Auto: reveal each phase row only once it has begun (positive countup). */ + fun getPhaseAutoMode(): Boolean + fun setPhaseAutoMode(enabled: Boolean) + + fun phaseVisibilityFlow(): Flow +} + +data class PhaseVisibility( + val fatBurn: Boolean, + val ketosis: Boolean, + val autophagy: Boolean, + val autoMode: Boolean, +) \ No newline at end of file diff --git a/app/src/main/java/com/darkrockstudios/apps/fasttrack/data/settings/SettingsPreferencesDatasource.kt b/app/src/main/java/com/darkrockstudios/apps/fasttrack/data/settings/SettingsPreferencesDatasource.kt index 591f47f2..d21ddd75 100644 --- a/app/src/main/java/com/darkrockstudios/apps/fasttrack/data/settings/SettingsPreferencesDatasource.kt +++ b/app/src/main/java/com/darkrockstudios/apps/fasttrack/data/settings/SettingsPreferencesDatasource.kt @@ -2,7 +2,6 @@ package com.darkrockstudios.apps.fasttrack.data.settings import android.content.Context import android.content.SharedPreferences -import android.preference.PreferenceManager import androidx.core.content.edit import com.darkrockstudios.apps.fasttrack.data.Data import kotlinx.coroutines.channels.awaitClose @@ -13,9 +12,11 @@ class SettingsPreferencesDatasource( appContext: Context ) : SettingsDatasource { + // The historical default-preferences file, addressed directly now that + // android.preference.PreferenceManager is deprecated (same file, same data). private val storage: SharedPreferences by lazy { - PreferenceManager.getDefaultSharedPreferences( - appContext + appContext.getSharedPreferences( + "${appContext.packageName}_preferences", Context.MODE_PRIVATE ) } @@ -88,10 +89,57 @@ class SettingsPreferencesDatasource( storage.edit { putString(Data.KEY_THEME_MODE, mode.name) } } + override fun getDateStyle(): DateStyle = + DateStyle.fromName(storage.getString(Data.KEY_DATE_STYLE, null)) + + override fun setDateStyle(style: DateStyle) { + storage.edit { putString(Data.KEY_DATE_STYLE, style.name) } + } + override fun getLogViewMode(): LogViewMode = LogViewMode.fromName(storage.getString(Data.KEY_LOG_VIEW_MODE, null)) override fun setLogViewMode(mode: LogViewMode) { storage.edit { putString(Data.KEY_LOG_VIEW_MODE, mode.name) } } + + override fun getShowFatBurn(): Boolean = storage.getBoolean(Data.KEY_SHOW_FAT_BURN, true) + override fun setShowFatBurn(enabled: Boolean) { + storage.edit { putBoolean(Data.KEY_SHOW_FAT_BURN, enabled) } + } + + override fun getShowKetosis(): Boolean = storage.getBoolean(Data.KEY_SHOW_KETOSIS, true) + override fun setShowKetosis(enabled: Boolean) { + storage.edit { putBoolean(Data.KEY_SHOW_KETOSIS, enabled) } + } + + override fun getShowAutophagy(): Boolean = storage.getBoolean(Data.KEY_SHOW_AUTOPHAGY, true) + override fun setShowAutophagy(enabled: Boolean) { + storage.edit { putBoolean(Data.KEY_SHOW_AUTOPHAGY, enabled) } + } + + override fun getPhaseAutoMode(): Boolean = storage.getBoolean(Data.KEY_PHASE_AUTO_MODE, false) + override fun setPhaseAutoMode(enabled: Boolean) { + storage.edit { putBoolean(Data.KEY_PHASE_AUTO_MODE, enabled) } + } + + override fun phaseVisibilityFlow(): Flow = callbackFlow { + fun current() = PhaseVisibility( + fatBurn = getShowFatBurn(), + ketosis = getShowKetosis(), + autophagy = getShowAutophagy(), + autoMode = getPhaseAutoMode(), + ) + trySend(current()) + + val keys = setOf( + Data.KEY_SHOW_FAT_BURN, Data.KEY_SHOW_KETOSIS, + Data.KEY_SHOW_AUTOPHAGY, Data.KEY_PHASE_AUTO_MODE + ) + val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, key -> + if (key in keys) trySend(current()) + } + storage.registerOnSharedPreferenceChangeListener(listener) + awaitClose { storage.unregisterOnSharedPreferenceChangeListener(listener) } + } } \ No newline at end of file diff --git a/app/src/main/java/com/darkrockstudios/apps/fasttrack/di/MainModule.kt b/app/src/main/java/com/darkrockstudios/apps/fasttrack/di/MainModule.kt index 9160268c..ded2c7f4 100644 --- a/app/src/main/java/com/darkrockstudios/apps/fasttrack/di/MainModule.kt +++ b/app/src/main/java/com/darkrockstudios/apps/fasttrack/di/MainModule.kt @@ -32,7 +32,9 @@ val mainModule = module { get(), AppDatabase::class.java, "app-database" - ).build() + ) + .addMigrations(AppDatabase.MIGRATION_1_2) + .build() } single { Clock.System } bind Clock::class diff --git a/app/src/main/java/com/darkrockstudios/apps/fasttrack/screens/fasting/DateTimePickerDialog.kt b/app/src/main/java/com/darkrockstudios/apps/fasttrack/screens/fasting/DateTimePickerDialog.kt index f03977e2..273b6111 100644 --- a/app/src/main/java/com/darkrockstudios/apps/fasttrack/screens/fasting/DateTimePickerDialog.kt +++ b/app/src/main/java/com/darkrockstudios/apps/fasttrack/screens/fasting/DateTimePickerDialog.kt @@ -1,12 +1,36 @@ package com.darkrockstudios.apps.fasttrack.screens.fasting -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +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.layout.width +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Close -import androidx.compose.material3.* -import androidx.compose.runtime.* +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.DatePicker +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TimePicker +import androidx.compose.material3.getSelectedDate +import androidx.compose.material3.rememberDatePickerState +import androidx.compose.material3.rememberTimePickerState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource @@ -17,9 +41,15 @@ import com.darkrockstudios.apps.fasttrack.R import com.darkrockstudios.apps.fasttrack.screens.preview.getContext import com.darkrockstudios.apps.fasttrack.utils.DateRangeSelectableDates import com.darkrockstudios.apps.fasttrack.utils.shouldUse24HourFormat -import kotlinx.datetime.* +import kotlinx.datetime.LocalDate +import kotlinx.datetime.LocalDateTime import kotlinx.datetime.TimeZone -import java.util.* +import kotlinx.datetime.atStartOfDayIn +import kotlinx.datetime.number +import kotlinx.datetime.toInstant +import kotlinx.datetime.toKotlinMonth +import kotlinx.datetime.toLocalDateTime +import java.util.Calendar import kotlin.time.ExperimentalTime import kotlin.time.Instant @@ -114,8 +144,8 @@ fun DateTimePickerDialog( if (selectedDate != null && minDateTime != null) { // Check if we're on the same day as minDateTime val isSameDay = selectedDate.year == minDateTime.year && - selectedDate.monthValue == minDateTime.monthNumber && - selectedDate.dayOfMonth == minDateTime.dayOfMonth + selectedDate.monthValue == minDateTime.month.number && + selectedDate.dayOfMonth == minDateTime.day if (isSameDay) { // Validate time is not before minDateTime's time @@ -203,15 +233,15 @@ fun DateTimePickerDialog( } else { val selectedDate = datePickerState.getSelectedDate() selectedDate?.let { date -> - val dateTime = LocalDateTime( - year = date.year, - month = date.month, - dayOfMonth = date.dayOfMonth, - hour = timePickerState.hour, - minute = timePickerState.minute, - second = 0, - nanosecond = 0 - ) + val dateTime = LocalDateTime( + year = date.year, + month = date.month.toKotlinMonth(), + day = date.dayOfMonth, + hour = timePickerState.hour, + minute = timePickerState.minute, + second = 0, + nanosecond = 0 + ) val instant = dateTime.toInstant(TimeZone.currentSystemDefault()) onDateTimeSelected(instant) onDismiss() diff --git a/app/src/main/java/com/darkrockstudios/apps/fasttrack/screens/fasting/DeepLinkRequests.kt b/app/src/main/java/com/darkrockstudios/apps/fasttrack/screens/fasting/DeepLinkRequests.kt index 022462cd..aeabfe43 100644 --- a/app/src/main/java/com/darkrockstudios/apps/fasttrack/screens/fasting/DeepLinkRequests.kt +++ b/app/src/main/java/com/darkrockstudios/apps/fasttrack/screens/fasting/DeepLinkRequests.kt @@ -13,6 +13,8 @@ data class StartFastRequest(val startNow: Boolean) data class ExternalRequests( val startFastRequest: StartFastRequest? = null, val stopFastRequested: Boolean = false, + val shareRequested: Boolean = false, val consumeStartFastRequest: () -> Unit = {}, val consumeStopFastRequest: () -> Unit = {}, + val consumeShareRequest: () -> Unit = {}, ) diff --git a/app/src/main/java/com/darkrockstudios/apps/fasttrack/screens/fasting/FastingScreen.Preview.kt b/app/src/main/java/com/darkrockstudios/apps/fasttrack/screens/fasting/FastingScreen.Preview.kt index 4198d3fb..5f71dc61 100644 --- a/app/src/main/java/com/darkrockstudios/apps/fasttrack/screens/fasting/FastingScreen.Preview.kt +++ b/app/src/main/java/com/darkrockstudios/apps/fasttrack/screens/fasting/FastingScreen.Preview.kt @@ -567,7 +567,7 @@ class FakeFastingViewModel(state: IFastingViewModel.FastingUiState) : IFastingVi override fun onCreate() {} override fun updateUi() {} override fun startFast(timeStartedMills: Instant?) {} - override fun endFast(timeEnded: Instant?) {} + override fun endFast(timeEnded: Instant?, notes: String) {} override fun setupAlerts() {} override fun debugIncreaseFastingTimeByOneHour() {} } \ No newline at end of file diff --git a/app/src/main/java/com/darkrockstudios/apps/fasttrack/screens/fasting/FastingScreen.kt b/app/src/main/java/com/darkrockstudios/apps/fasttrack/screens/fasting/FastingScreen.kt index 80981953..0ebe5af2 100644 --- a/app/src/main/java/com/darkrockstudios/apps/fasttrack/screens/fasting/FastingScreen.kt +++ b/app/src/main/java/com/darkrockstudios/apps/fasttrack/screens/fasting/FastingScreen.kt @@ -1,39 +1,101 @@ package com.darkrockstudios.apps.fasttrack.screens.fasting +import android.app.Activity import android.content.res.Configuration -import androidx.annotation.StringRes -import androidx.appcompat.app.AlertDialog -import androidx.compose.foundation.layout.* +import android.graphics.Rect +import android.os.Handler +import android.os.Looper +import android.view.PixelCopy +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +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.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicText +import androidx.compose.foundation.text.TextAutoSize import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add -import androidx.compose.material3.* -import androidx.compose.runtime.* +import androidx.compose.material3.Button +import androidx.compose.material3.FilledTonalIconButton +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.boundsInWindow +import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.min +import androidx.compose.ui.unit.sp +import androidx.core.graphics.createBitmap import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.currentStateAsState import com.darkrockstudios.apps.fasttrack.BuildConfig import com.darkrockstudios.apps.fasttrack.R +import com.darkrockstudios.apps.fasttrack.data.FastingJourney +import com.darkrockstudios.apps.fasttrack.data.JourneyStage +import com.darkrockstudios.apps.fasttrack.data.Stages import com.darkrockstudios.apps.fasttrack.screens.confetti.ConfettiState import com.darkrockstudios.apps.fasttrack.screens.confetti.confettiEffect -import com.darkrockstudios.apps.fasttrack.screens.preview.getContext import com.darkrockstudios.apps.fasttrack.ui.theme.fastBackgroundGradient -import com.darkrockstudios.apps.fasttrack.utils.Utils +import com.darkrockstudios.apps.fasttrack.utils.formatDuration +import com.darkrockstudios.apps.fasttrack.utils.shareFastImage +import io.github.aakira.napier.Napier import kotlinx.coroutines.delay -import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine import org.koin.compose.viewmodel.koinViewModel +import kotlin.coroutines.resume +import kotlin.math.roundToInt +import kotlin.time.Clock +import kotlin.time.Duration +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds +import kotlin.time.DurationUnit @Composable fun FastingScreen( @@ -41,7 +103,6 @@ fun FastingScreen( viewModel: IFastingViewModel = koinViewModel(), externalRequests: ExternalRequests = ExternalRequests(), ) { - val context = getContext() val scope = rememberCoroutineScope() val confetti = remember { ConfettiState() } val uiState by viewModel.uiState.collectAsState() @@ -52,18 +113,27 @@ fun FastingScreen( var showStartDateTimePicker by remember { mutableStateOf(false) } var showEndDateTimePicker by remember { mutableStateOf(false) } + var showStartSheet by remember { mutableStateOf(false) } + var showEndSheet by remember { mutableStateOf(false) } + // Note captured while ending a fast; carried across the "stopped earlier" picker + var endNotes by rememberSaveable { mutableStateOf("") } fun onShowStartFastSelector() { - if (!uiState.isFasting) { - AlertDialog.Builder(context) - .setTitle(R.string.confirm_start_fast_title) - .setPositiveButton(R.string.confirm_start_fast_positive) { _, _ -> viewModel.startFast() } - .setNeutralButton(R.string.confirm_start_fast_neutral) { _, _ -> - showStartDateTimePicker = true - } - .setNegativeButton(R.string.confirm_start_fast_negative, null) - .show() - } + if (!uiState.isFasting) showStartSheet = true + } + + if (showStartSheet) { + StartFastSheet( + onStartNow = { + showStartSheet = false + viewModel.startFast() + }, + onStartEarlier = { + showStartSheet = false + showStartDateTimePicker = true + }, + onDismiss = { showStartSheet = false }, + ) } if (showStartDateTimePicker) { @@ -81,17 +151,29 @@ fun FastingScreen( } fun onShowEndFastConfirmation() { - AlertDialog.Builder(context) - .setTitle(R.string.confirm_end_fast_title) - .setPositiveButton(R.string.confirm_end_fast_positive) { _, _ -> - viewModel.endFast() + showEndSheet = true + } + + if (showEndSheet) { + EndFastSheet( + notes = endNotes, + onNotesChange = { endNotes = it }, + onEndNow = { + showEndSheet = false + viewModel.endFast(notes = endNotes.trim()) + endNotes = "" confetti.start(scope) - } - .setNeutralButton(R.string.confirm_end_fast_neutral) { _, _ -> + }, + onEndEarlier = { + // Keep the note; it is applied once the end time is chosen + showEndSheet = false showEndDateTimePicker = true - } - .setNegativeButton(R.string.confirm_end_fast_negative, null) - .show() + }, + onDismiss = { + showEndSheet = false + endNotes = "" + }, + ) } if (showEndDateTimePicker) { @@ -99,7 +181,8 @@ fun FastingScreen( DateTimePickerDialog( onDismiss = { showEndDateTimePicker = false }, onDateTimeSelected = { selectedDateTime -> - viewModel.endFast(selectedDateTime) + viewModel.endFast(selectedDateTime, endNotes.trim()) + endNotes = "" showEndDateTimePicker = false confetti.start(scope) }, @@ -110,12 +193,44 @@ fun FastingScreen( ) } - fun onShowInfoDialog(titleRes: Int, contentRes: Int) { - Utils.showInfoDialog( - titleRes, - contentRes, - context - ) + // Journey stage overlay, opened from the dial or the phase rows + var selectedStage by remember { mutableStateOf(null) } + + // One shared time format for the center timer and the phase rows: + // days+hours ("2d 12h") vs total hours ("60h 30m"), toggled by tapping either + var showTotalHours by rememberSaveable { mutableStateOf(false) } + + val context = LocalContext.current + // Window-space bounds of the shareable hero (dial + rows), tracked for capture + var heroBounds by remember { mutableStateOf(null) } + + // Share request: PixelCopy the hero region straight off the window surface + // (so it's the screen as-is, minus the status bar and other windows), then + // fire the chooser with a rich caption. + LaunchedEffect(externalRequests.shareRequested) { + if (externalRequests.shareRequested) { + @Suppress("TooGenericExceptionCaught") + try { + val bounds = heroBounds + val window = (context as? Activity)?.window + if (bounds != null && window != null && bounds.width() > 0 && bounds.height() > 0) { + val bitmap = createBitmap(bounds.width(), bounds.height()) + val ok = suspendCancellableCoroutine { cont -> + PixelCopy.request( + window, bounds, bitmap, + { result -> cont.resume(result == PixelCopy.SUCCESS) }, + Handler(Looper.getMainLooper()) + ) + } + if (ok) { + shareFastImage(context, bitmap, buildShareCaption(context, uiState)) + } + } + } catch (e: Throwable) { + Napier.e("Failed to share fast", e) + } + externalRequests.consumeShareRequest() + } } // Handle deep link requests to show dialogs or start/stop directly @@ -147,9 +262,12 @@ fun FastingScreen( val lifecycleState by LocalLifecycleOwner.current.lifecycle.currentStateAsState() LaunchedEffect(uiState.isFasting, lifecycleState) { + // The timer displays minute granularity, so there is no reason to poll + // faster than once a minute. Refresh immediately (e.g. on resume), then + // wake exactly on each whole-minute boundary so the value flips on time. while (uiState.isFasting && lifecycleState == Lifecycle.State.RESUMED) { viewModel.updateUi() - delay(10) + delay((60_000L - (System.currentTimeMillis() % 60_000L)).milliseconds) } } @@ -168,6 +286,16 @@ fun FastingScreen( val spacing = rememberFastingSpacing(isCompact) val typography = rememberFastingTypography(isCompact) + // The dial must never starve the controls below it: cap it against the + // viewport height, not just the width. In portrait the cap is 1/phi^2 + // (~0.382) of the height — the golden section between the dial zone + // and everything below it. + val dialMaxSize = if (isLandscape) { + maxHeight * 0.8f + } else { + min(maxWidth, maxHeight * 0.382f) + } + CompositionLocalProvider( LocalFastingSpacing provides spacing, LocalFastingTypography provides typography @@ -181,17 +309,24 @@ fun FastingScreen( ) { FastHeadingContent( uiState = uiState, + dialMaxSize = dialMaxSize, + showTotalHours = showTotalHours, + onToggleTimeFormat = { showTotalHours = !showTotalHours }, + onStageSelected = { selectedStage = it }, modifier = Modifier .weight(1f) .fillMaxHeight() .padding(end = spacing.medium) + .onGloballyPositioned { heroBounds = it.boundsInWindow().toAndroidRect() } ) Spacer(modifier = Modifier.size(height = spacing.large, width = 1.dp)) FastDetailsContent( uiState = uiState, - onShowInfoDialog = ::onShowInfoDialog, + showTotalHours = showTotalHours, + onToggleTimeFormat = { showTotalHours = !showTotalHours }, + onStageSelected = { selectedStage = it }, viewModel = viewModel, onShowEndFastConfirmation = ::onShowEndFastConfirmation, onShowStartFastSelector = ::onShowStartFastSelector, @@ -208,44 +343,63 @@ fun FastingScreen( .padding(spacing.large), horizontalAlignment = Alignment.CenterHorizontally ) { - FastHeadingContent( + // Golden-seat the whole hero cluster: a smaller minor share of + // slack above, a larger major share below (before the pinned + // action). Title + dial + rows + description read as one unit, + // and this whole block is what gets captured for image sharing. + Spacer(modifier = Modifier.weight(GOLDEN_MINOR)) + + FastHero( uiState = uiState, - modifier = Modifier.fillMaxWidth() + dialMaxSize = dialMaxSize, + showTotalHours = showTotalHours, + onToggleTimeFormat = { showTotalHours = !showTotalHours }, + onStageSelected = { selectedStage = it }, + modifier = Modifier + .fillMaxWidth() + .onGloballyPositioned { heroBounds = it.boundsInWindow().toAndroidRect() } ) - FastDetailsContent( + Spacer(modifier = Modifier.weight(GOLDEN_MAJOR)) + + FastActionRow( uiState = uiState, - onShowInfoDialog = ::onShowInfoDialog, viewModel = viewModel, onShowEndFastConfirmation = ::onShowEndFastConfirmation, onShowStartFastSelector = ::onShowStartFastSelector, - modifier = Modifier.fillMaxWidth() ) } } } } + + selectedStage?.let { stage -> + JourneyStageSheet( + stage = stage, + onDismiss = { selectedStage = null } + ) + } } @Composable private fun FastHeadingContent( uiState: IFastingViewModel.FastingUiState, + dialMaxSize: Dp, + showTotalHours: Boolean, + onToggleTimeFormat: () -> Unit, + onStageSelected: (JourneyStage) -> Unit, modifier: Modifier = Modifier ) { - val scope = rememberCoroutineScope() - val tooltipState = rememberTooltipState() val spacing = fastingSpacing() val typography = fastingTypography() - @StringRes - var phaseTooltipResId by remember { mutableStateOf(null) } - - LaunchedEffect(tooltipState.isVisible) { - if (tooltipState.isVisible) { - delay(4.seconds) - tooltipState.dismiss() - phaseTooltipResId = null - } + // Precise elapsed time; uiState.elapsedHours is truncated to whole hours. + // After a fast has ended the dial rests at zero: muted milestones, no + // heartbeat — the pulse is a reward that belongs to fasting alone. + val elapsedHoursPrecise = if (uiState.isFasting) { + uiState.elapsedTime?.toDouble(DurationUnit.HOURS) ?: uiState.elapsedHours + } else { + 0.0 } Column( @@ -262,66 +416,173 @@ private fun FastHeadingContent( modifier = Modifier.padding(bottom = spacing.small) ) - TooltipBox( - positionProvider = TooltipDefaults - .rememberTooltipPositionProvider(TooltipAnchorPosition.Below), - tooltip = { - phaseTooltipResId?.let { stringRes -> - PlainTooltip { Text(stringResource(stringRes)) } - } - }, - state = tooltipState, + TimeLine( + elapsedHours = elapsedHoursPrecise, + modifier = Modifier + .widthIn(max = dialMaxSize) + .fillMaxWidth() + .padding(vertical = spacing.medium), + onStageClick = onStageSelected ) { - TimeLine( - elapsedHours = uiState.elapsedHours, - modifier = Modifier - .fillMaxWidth() - .padding(vertical = spacing.medium), - onPhaseClick = { phase -> - phaseTooltipResId = phase.title - scope.launch { - tooltipState.show() - } + // Timer + Energy Mode, centered inside the ring — only while a fast + // is running. Tapping the timer toggles days+hours vs total hours. + if (uiState.isFasting) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null + ) { onToggleTimeFormat() } + ) { + val timerText = formatDuration( + uiState.elapsedTime ?: uiState.elapsedHours.hours, + showTotalHours + ) + // Auto-size so the value always fits inside the ring — the + // Compose-native answer (the View-system autoSizeTextType + // APIs don't apply to Compose). One line, shrinks to fit. + BasicText( + text = timerText, + maxLines = 1, + softWrap = false, + autoSize = TextAutoSize.StepBased( + minFontSize = 20.sp, + maxFontSize = 60.sp, + stepSize = 1.sp, + ), + style = typography.timerText().merge( + TextStyle( + color = MaterialTheme.colorScheme.onBackground, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center, + ) + ), + modifier = Modifier.fillMaxWidth(), + ) + BasicText( + text = uiState.energyMode, + maxLines = 2, + autoSize = TextAutoSize.StepBased( + minFontSize = 9.sp, + maxFontSize = 15.sp, + stepSize = 1.sp, + ), + style = typography.energyMode().merge( + TextStyle( + color = MaterialTheme.colorScheme.onBackground, + textAlign = TextAlign.Center, + ) + ), + modifier = Modifier + .fillMaxWidth() + .padding(top = spacing.small) + ) } - ) + } } + } +} + +// Golden section of the free vertical space: the hero cluster is seated with +// ~38.2% of the slack above it and ~61.8% below, so it rises toward the upper +// golden line and the primary action keeps a larger, deliberate breathing zone. +private const val GOLDEN_MINOR = 0.382f +private const val GOLDEN_MAJOR = 0.618f + +/** + * Portrait hero: stage title, dial (with center timer + energy), phase rows, + * the status line, and a small brand footer — one cohesive cluster that also + * doubles as the shareable image (the action button lives outside it). + */ +@Composable +private fun FastHero( + uiState: IFastingViewModel.FastingUiState, + dialMaxSize: Dp, + showTotalHours: Boolean, + onToggleTimeFormat: () -> Unit, + onStageSelected: (JourneyStage) -> Unit, + modifier: Modifier = Modifier, +) { + val spacing = fastingSpacing() + val typography = fastingTypography() + val elapsed = uiState.elapsedTime ?: uiState.elapsedHours.takeIf { it > 0 }?.hours + + Column( + modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + FastHeadingContent( + uiState = uiState, + dialMaxSize = dialMaxSize, + showTotalHours = showTotalHours, + onToggleTimeFormat = onToggleTimeFormat, + onStageSelected = onStageSelected, + modifier = Modifier.fillMaxWidth() + ) + + PhaseRows(uiState, showTotalHours, onToggleTimeFormat, onStageSelected, elapsed) - // Energy Mode Text( - text = uiState.energyMode, - style = typography.energyMode(), + text = rememberFastStatusText(uiState, showTotalHours), + style = typography.stageDescription(), color = MaterialTheme.colorScheme.onBackground, textAlign = TextAlign.Center, - modifier = Modifier.padding(bottom = spacing.medium) + modifier = Modifier + .fillMaxWidth() + .padding(top = spacing.large, start = spacing.medium, end = spacing.medium) ) + } +} - Spacer(modifier = Modifier.size(height = spacing.large, width = 1.dp)) +/** Rounded window-space rectangle for PixelCopy capture. */ +private fun androidx.compose.ui.geometry.Rect.toAndroidRect(): Rect = + Rect(left.roundToInt(), top.roundToInt(), right.roundToInt(), bottom.roundToInt()) + +/** + * A multi-line caption for a shared fast: the lead sentence plus a line for each + * milestone the body has actually reached (positive count-up), reusing the + * already-localized phase labels. + */ +private fun buildShareCaption( + context: android.content.Context, + uiState: IFastingViewModel.FastingUiState, +): String { + val elapsed = uiState.elapsedTime ?: return context.getString(R.string.app_name) + val durationText = formatDuration(context, elapsed) + val curPhase = Stages.getCurrentPhase(elapsed) + val energyStr = if (curPhase.fatBurning) { + context.getString(R.string.fasting_energy_mode_fat) + } else { + context.getString(R.string.fasting_energy_mode_glucose) + } + val lead = if (uiState.isFasting) { + context.getString(R.string.share_text_fasting, durationText, energyStr) + } else { + context.getString(R.string.share_text_finished, durationText) + } - // Timer - Row( - verticalAlignment = Alignment.Bottom, - modifier = Modifier.padding(bottom = spacing.large) - ) { - Text( - text = uiState.timerText, - style = typography.timerText(), - color = MaterialTheme.colorScheme.onBackground, - fontWeight = FontWeight.Bold, - ) - Text( - text = uiState.milliseconds, - style = typography.timerMilliseconds(), - color = MaterialTheme.colorScheme.onBackground, - modifier = Modifier.padding(start = spacing.small, bottom = spacing.small) - ) + val active = IFastingViewModel.StageState.StartedActive + val lines = buildList { + if (uiState.fatBurnStageState == active) { + add("🔥 " + context.getString(R.string.fast_fat_burn_label) + " " + uiState.fatBurnTime) + } + if (uiState.ketosisStageState == active) { + add("💎 " + context.getString(R.string.fast_ketosis_label) + " " + uiState.ketosisTime) + } + if (uiState.autophagyStageState == active) { + add("♻️ " + context.getString(R.string.fast_autophagy_label) + " " + uiState.autophagyTime) } } + return (listOf(lead) + lines).joinToString("\n") } +/** Landscape body: rows + a scroll-safe description that fills the column, action pinned. */ @Composable private fun FastDetailsContent( uiState: IFastingViewModel.FastingUiState, - onShowInfoDialog: (Int, Int) -> Unit, + showTotalHours: Boolean, + onToggleTimeFormat: () -> Unit, + onStageSelected: (JourneyStage) -> Unit, viewModel: IFastingViewModel, onShowEndFastConfirmation: () -> Unit, onShowStartFastSelector: () -> Unit, @@ -329,174 +590,434 @@ private fun FastDetailsContent( ) { val spacing = fastingSpacing() val typography = fastingTypography() + val elapsed = uiState.elapsedTime ?: uiState.elapsedHours.takeIf { it > 0 }?.hours Column( modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally, ) { - Spacer(modifier = Modifier.weight(1f)) + PhaseRows(uiState, showTotalHours, onToggleTimeFormat, onStageSelected, elapsed) - // Phase Information - Column( + Box( modifier = Modifier .fillMaxWidth() - .padding(bottom = spacing.medium) + .weight(1f) + .verticalScroll(rememberScrollState()), + contentAlignment = Alignment.Center ) { - // Fat Burn Phase + Text( + text = rememberFastStatusText(uiState, showTotalHours), + style = typography.stageDescription(), + color = MaterialTheme.colorScheme.onBackground, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = spacing.medium) + ) + } + + FastActionRow( + uiState = uiState, + viewModel = viewModel, + onShowEndFastConfirmation = onShowEndFastConfirmation, + onShowStartFastSelector = onShowStartFastSelector, + ) + } +} + +/** + * Fat Burn / Ketosis / Autophagy rows. Only while fasting; tap the label to open + * its journey stage, tap the time to switch formats everywhere. In auto mode a + * row appears only once its phase has begun (positive count-up); otherwise it + * follows the per-phase Settings toggle. + */ +@Composable +private fun PhaseRows( + uiState: IFastingViewModel.FastingUiState, + showTotalHours: Boolean, + onToggleTimeFormat: () -> Unit, + onStageSelected: (JourneyStage) -> Unit, + elapsed: Duration?, +) { + if (!uiState.isFasting) return + + fun visible(show: Boolean, startHours: Int): Boolean = + if (uiState.phaseAutoMode) { + elapsed != null && elapsed >= startHours.hours + } else { + show + } + + Column(modifier = Modifier.fillMaxWidth()) { + if (visible(uiState.showFatBurn, Stages.PHASE_FAT_BURN.hours)) { StageInfo( - onShowInfoDialog = onShowInfoDialog, - titleRes = R.string.info_dialog_fat_burn_title, - contentRes = R.string.info_dialog_fat_burn_content, labelRes = R.string.fast_fat_burn_label, - timeText = uiState.fatBurnTime, - stageState = uiState.fatBurnStageState + phaseStartHours = Stages.PHASE_FAT_BURN.hours, + elapsed = elapsed, + showTotalHours = showTotalHours, + onToggleFormat = onToggleTimeFormat, + onClick = { onStageSelected(FastingJourney.stages[4]) } ) - - // Ketosis Phase + } + if (visible(uiState.showKetosis, Stages.PHASE_KETOSIS.hours)) { StageInfo( - onShowInfoDialog = onShowInfoDialog, - titleRes = R.string.info_dialog_ketosis_title, - contentRes = R.string.info_dialog_ketosis_content, labelRes = R.string.fast_ketosis_label, - timeText = uiState.ketosisTime, - stageState = uiState.ketosisStageState + phaseStartHours = Stages.PHASE_KETOSIS.hours, + elapsed = elapsed, + showTotalHours = showTotalHours, + onToggleFormat = onToggleTimeFormat, + onClick = { onStageSelected(FastingJourney.stages[5]) } ) - - // Autophagy Phase + } + if (visible(uiState.showAutophagy, Stages.PHASE_AUTOPHAGY.hours)) { StageInfo( - onShowInfoDialog = onShowInfoDialog, - titleRes = R.string.info_dialog_autophagy_title, - contentRes = R.string.info_dialog_autophagy_content, labelRes = R.string.fast_autophagy_label, - timeText = uiState.autophagyTime, - stageState = uiState.autophagyStageState + phaseStartHours = Stages.PHASE_AUTOPHAGY.hours, + elapsed = elapsed, + showTotalHours = showTotalHours, + onToggleFormat = onToggleTimeFormat, + onClick = { onStageSelected(FastingJourney.stages[6]) } + ) + } + } +} + +/** + * While fasting: the stage description. In the first hour after a fast: a moment + * of recognition. Beyond that: how long since the last one, pulled from storage + * and refreshed once a minute. + */ +@Composable +private fun rememberFastStatusText( + uiState: IFastingViewModel.FastingUiState, + showTotalHours: Boolean, +): String { + if (uiState.isFasting) return uiState.stageDescription + + var now by remember { mutableStateOf(Clock.System.now()) } + LaunchedEffect(Unit) { + while (true) { + now = Clock.System.now() + delay(60.seconds) + } + } + + return uiState.lastFastEndTime?.let { lastEnd -> + val since = (now - lastEnd).coerceAtLeast(Duration.ZERO) + if (since < 1.hours) { + stringResource(R.string.just_finished_fast) + } else { + stringResource( + R.string.time_since_last_fast, + formatDuration(since, showTotalHours) ) } + } ?: "" +} - Row(modifier = Modifier +/** Debug +1h (debug builds) plus the full-width Start/End action, pinned. */ +@Composable +private fun FastActionRow( + uiState: IFastingViewModel.FastingUiState, + viewModel: IFastingViewModel, + onShowEndFastConfirmation: () -> Unit, + onShowStartFastSelector: () -> Unit, +) { + val spacing = fastingSpacing() + Row( + modifier = Modifier .fillMaxWidth() - .weight(2f)) { - // Stage Description - Box( - modifier = Modifier - .fillMaxWidth() - .weight(1f) - .verticalScroll(rememberScrollState()) + .padding(top = spacing.medium), + verticalAlignment = Alignment.CenterVertically + ) { + if (BuildConfig.DEBUG) { + FilledTonalIconButton( + onClick = { viewModel.debugIncreaseFastingTimeByOneHour() }, + modifier = Modifier.padding(end = spacing.medium) ) { - Text( - text = uiState.stageDescription, - style = typography.stageDescription(), - color = MaterialTheme.colorScheme.onBackground, - modifier = Modifier.padding(top = spacing.medium, end = spacing.medium) + Icon( + imageVector = Icons.Default.Add, + contentDescription = stringResource(id = R.string.debug_add_hour_button) ) } + } - // Bottom Controls Row - Row( - modifier = Modifier - .align(Alignment.Bottom) - .wrapContentHeight() - .padding(top = spacing.medium), - horizontalArrangement = Arrangement.End, - verticalAlignment = Alignment.CenterVertically - ) { - // Debug Button (only in debug builds) - if (BuildConfig.DEBUG) { - FloatingActionButton( - onClick = { viewModel.debugIncreaseFastingTimeByOneHour() }, - modifier = Modifier.padding(end = spacing.medium) - ) { - Icon( - imageVector = Icons.Default.Add, - contentDescription = stringResource(id = R.string.debug_add_hour_button) - ) - } - } - - // Start/Stop Button - if (uiState.isFasting) { - FloatingActionButton( - onClick = onShowEndFastConfirmation, - ) { - Icon( - painter = painterResource(id = R.drawable.ic_fast_stop), - contentDescription = stringResource(id = R.string.stop_fast_button_description) - ) - } - } else { - FloatingActionButton( - onClick = onShowStartFastSelector, - ) { - Icon( - painter = painterResource(id = R.drawable.ic_start_fast), - contentDescription = stringResource(id = R.string.start_fast_button_description) - ) - } - } - } + val (onClick, iconRes, labelRes) = if (uiState.isFasting) { + Triple(onShowEndFastConfirmation, R.drawable.ic_fast_stop, R.string.end_fast_button) + } else { + Triple(onShowStartFastSelector, R.drawable.ic_start_fast, R.string.start_fast_button) + } + Button( + onClick = onClick, + modifier = Modifier + .weight(1f) + .heightIn(min = 55.dp) + ) { + Icon( + painter = painterResource(id = iconRes), + contentDescription = null, + modifier = Modifier.padding(end = spacing.medium) + ) + Text( + text = stringResource(id = labelRes), + style = MaterialTheme.typography.titleMedium, + ) } } } @Composable private fun StageInfo( - onShowInfoDialog: (Int, Int) -> Unit, - titleRes: Int, - contentRes: Int, labelRes: Int, - timeText: String, - stageState: IFastingViewModel.StageState + phaseStartHours: Int, + elapsed: Duration?, + showTotalHours: Boolean, + onToggleFormat: () -> Unit, + onClick: () -> Unit, ) { val spacing = fastingSpacing() val typography = fastingTypography() + val delta = elapsed?.minus(phaseStartHours.hours) + val timeText: String + val timeColor: Color + when { + delta == null -> { + timeText = "—" + timeColor = MaterialTheme.colorScheme.onBackground + } + + delta >= Duration.ZERO -> { + // Underway: alive, affirming green — the dial's own "burn begins" hue, + // so it tracks the theme (light/dark) instead of a fixed color. + timeText = formatDuration(delta, showTotalHours) + timeColor = journeyStageColor(4) + } + + else -> { + // Ahead: calm anticipation, never red — an upcoming phase is not a failure + timeText = stringResource(R.string.phase_time_until, formatDuration(-delta, showTotalHours)) + timeColor = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.65f) + } + } + Row( modifier = Modifier .fillMaxWidth() - .padding(vertical = spacing.small), + .clip(RoundedCornerShape(12.dp)) + .clickable(onClick = onClick) + .padding(horizontal = spacing.medium, vertical = spacing.small), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { - TextButton( - modifier = Modifier.weight(1f), - onClick = { onShowInfoDialog(titleRes, contentRes) }, - contentPadding = PaddingValues( - horizontal = spacing.buttonPaddingHorizontal, - vertical = spacing.buttonPaddingVertical - ) - ) { - Icon( - painter = painterResource(id = R.drawable.ic_more_info), - tint = phaseTextColor(), - contentDescription = null, - modifier = Modifier - .padding(end = spacing.small) - .size(spacing.iconSize) - ) - Text( - text = stringResource(id = labelRes), - style = typography.phaseLabel(), - color = phaseTextColor(), - maxLines = 1, - softWrap = false, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f) - ) - } + Text( + text = stringResource(id = labelRes), + style = typography.phaseLabel(), + color = phaseTextColor(), + maxLines = 1, + softWrap = false, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) Text( text = timeText, style = typography.phaseTime(), - color = stageColor(stageState), + color = timeColor, maxLines = 1, softWrap = false, overflow = TextOverflow.Visible, + modifier = Modifier.clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null + ) { onToggleFormat() } ) } } +/** + * Confirmation for starting a fast — a calm bottom sheet replacing the old + * system alert. "Start now" is the primary action; "I started earlier" opens + * the date/time picker. Dismiss by swipe or scrim. + */ @Composable -private fun stageColor(stageState: IFastingViewModel.StageState): Color = when (stageState) { - IFastingViewModel.StageState.StartedActive -> Color(0xFF00DD00) - IFastingViewModel.StageState.StartedInactive -> Color.Red - IFastingViewModel.StageState.NotStarted -> MaterialTheme.colorScheme.onBackground +private fun StartFastSheet( + onStartNow: () -> Unit, + onStartEarlier: () -> Unit, + onDismiss: () -> Unit, +) { + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = rememberModalBottomSheetState(), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(start = 21.dp, end = 21.dp, bottom = 34.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = stringResource(R.string.confirm_start_fast_title), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + ) + Spacer(modifier = Modifier.size(21.dp)) + Button( + onClick = onStartNow, + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 55.dp), + ) { + Icon( + painter = painterResource(id = R.drawable.ic_start_fast), + contentDescription = null, + modifier = Modifier.padding(end = 8.dp), + ) + Text( + text = stringResource(R.string.sheet_start_now), + style = MaterialTheme.typography.titleMedium, + ) + } + Spacer(modifier = Modifier.size(8.dp)) + TextButton(onClick = onStartEarlier, modifier = Modifier.fillMaxWidth()) { + Text(stringResource(R.string.sheet_start_earlier)) + } + } + } +} + +/** + * Confirmation for ending a fast. Carries an optional Notes field saved to the + * logbook. "End now" ends at the current time; "I stopped earlier" opens the + * picker (the typed note is preserved and applied afterward). + */ +@Composable +private fun EndFastSheet( + notes: String, + onNotesChange: (String) -> Unit, + onEndNow: () -> Unit, + onEndEarlier: () -> Unit, + onDismiss: () -> Unit, +) { + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = rememberModalBottomSheetState(), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .imePadding() + .padding(start = 21.dp, end = 21.dp, bottom = 34.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = stringResource(R.string.confirm_end_fast_title), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + ) + Spacer(modifier = Modifier.size(13.dp)) + OutlinedTextField( + value = notes, + onValueChange = onNotesChange, + modifier = Modifier.fillMaxWidth(), + label = { Text(stringResource(R.string.fast_notes_label)) }, + placeholder = { Text(stringResource(R.string.fast_notes_placeholder)) }, + minLines = 2, + maxLines = 5, + ) + Spacer(modifier = Modifier.size(21.dp)) + Button( + onClick = onEndNow, + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 55.dp), + ) { + Icon( + painter = painterResource(id = R.drawable.ic_fast_stop), + contentDescription = null, + modifier = Modifier.padding(end = 8.dp), + ) + Text( + text = stringResource(R.string.sheet_end_now), + style = MaterialTheme.typography.titleMedium, + ) + } + Spacer(modifier = Modifier.size(8.dp)) + TextButton(onClick = onEndEarlier, modifier = Modifier.fillMaxWidth()) { + Text(stringResource(R.string.sheet_end_earlier)) + } + } + } +} + +/** + * Overlay for one stage of the fasting journey, opened from the dial. + * A bottom sheet: it slides in gently, never covers the dial fully, + * and dismisses with a swipe or a tap outside. + */ +@Composable +private fun JourneyStageSheet( + stage: JourneyStage, + onDismiss: () -> Unit, +) { + val stageIndex = FastingJourney.stages.indexOf(stage) + val accent = journeyStageColor(stageIndex) + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = rememberModalBottomSheetState(), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(start = 21.dp, end = 21.dp, bottom = 34.dp) + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + modifier = Modifier + .size(55.dp) + .clip(CircleShape) + .background(accent.copy(alpha = 0.18f)), + contentAlignment = Alignment.Center + ) { + Text(text = stage.emoji, fontSize = 26.sp) + } + Spacer(modifier = Modifier.size(13.dp)) + Column { + Text( + text = stringResource(stage.title), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + ) + val rangeText = stage.endHours?.let { end -> + stringResource(R.string.journey_stage_hours_range, stage.startHours, end) + } ?: stringResource(R.string.journey_stage_hours_open, stage.startHours) + Text( + text = rangeText, + style = MaterialTheme.typography.labelLarge, + color = accent, + ) + } + } + + Spacer(modifier = Modifier.size(21.dp)) + + Text( + text = stringResource(stage.body), + style = MaterialTheme.typography.bodyLarge, + ) + } + } } + +/** + * The app-wide duration format (see [com.darkrockstudios.apps.fasttrack.utils.formatDuration]), + * bound to the composition's context. + */ +@Composable +private fun formatDuration(duration: Duration, showTotalHours: Boolean): String = + formatDuration( + LocalContext.current, + duration, + showTotalHours + ) diff --git a/app/src/main/java/com/darkrockstudios/apps/fasttrack/screens/fasting/FastingScreenStyling.kt b/app/src/main/java/com/darkrockstudios/apps/fasttrack/screens/fasting/FastingScreenStyling.kt index bf5ebf64..d4d90e22 100644 --- a/app/src/main/java/com/darkrockstudios/apps/fasttrack/screens/fasting/FastingScreenStyling.kt +++ b/app/src/main/java/com/darkrockstudios/apps/fasttrack/screens/fasting/FastingScreenStyling.kt @@ -34,13 +34,15 @@ data class FastingSpacing( val buttonPaddingVertical: Dp, ) +// Spacing follows the Fibonacci ladder (3, 5, 8, 13, 21) — the integer +// expression of the golden ratio, so every gap relates to its neighbor by ~phi. val LocalFastingSpacing = staticCompositionLocalOf { FastingSpacing( - small = 4.dp, + small = 5.dp, medium = 8.dp, - large = 16.dp, - iconSize = 24.dp, - buttonPaddingHorizontal = 12.dp, + large = 13.dp, + iconSize = 21.dp, + buttonPaddingHorizontal = 13.dp, buttonPaddingVertical = 8.dp, ) } @@ -66,8 +68,8 @@ fun fastingTypography(): FastingTypography = energyMode = { MaterialTheme.typography.labelMedium }, timerText = { MaterialTheme.typography.displayLarge }, timerMilliseconds = { MaterialTheme.typography.headlineMedium }, - phaseLabel = { MaterialTheme.typography.headlineSmall }, - phaseTime = { MaterialTheme.typography.headlineMedium }, + phaseLabel = { MaterialTheme.typography.titleMedium }, + phaseTime = { MaterialTheme.typography.titleMedium }, stageDescription = { MaterialTheme.typography.bodyMedium }, checkboxLabel = { MaterialTheme.typography.labelLarge }, ) @@ -80,20 +82,20 @@ fun rememberFastingSpacing(isCompact: Boolean): FastingSpacing { return remember(isCompact) { if (isCompact) { FastingSpacing( - small = 2.dp, - medium = 4.dp, + small = 3.dp, + medium = 5.dp, large = 8.dp, - iconSize = 16.dp, + iconSize = 13.dp, buttonPaddingHorizontal = 8.dp, - buttonPaddingVertical = 4.dp, + buttonPaddingVertical = 5.dp, ) } else { FastingSpacing( - small = 4.dp, + small = 5.dp, medium = 8.dp, - large = 16.dp, - iconSize = 24.dp, - buttonPaddingHorizontal = 12.dp, + large = 13.dp, + iconSize = 21.dp, + buttonPaddingHorizontal = 13.dp, buttonPaddingVertical = 8.dp, ) } @@ -109,8 +111,8 @@ fun rememberFastingTypography(isCompact: Boolean): FastingTypography { energyMode = { MaterialTheme.typography.labelSmall }, timerText = { MaterialTheme.typography.displayMedium }, timerMilliseconds = { MaterialTheme.typography.headlineSmall }, - phaseLabel = { MaterialTheme.typography.titleMedium }, - phaseTime = { MaterialTheme.typography.titleLarge }, + phaseLabel = { MaterialTheme.typography.titleSmall }, + phaseTime = { MaterialTheme.typography.titleSmall }, stageDescription = { MaterialTheme.typography.bodySmall }, checkboxLabel = { MaterialTheme.typography.labelMedium }, ) @@ -120,8 +122,8 @@ fun rememberFastingTypography(isCompact: Boolean): FastingTypography { energyMode = { MaterialTheme.typography.labelMedium }, timerText = { MaterialTheme.typography.displayLarge }, timerMilliseconds = { MaterialTheme.typography.headlineMedium }, - phaseLabel = { MaterialTheme.typography.headlineSmall }, - phaseTime = { MaterialTheme.typography.headlineMedium }, + phaseLabel = { MaterialTheme.typography.titleMedium }, + phaseTime = { MaterialTheme.typography.titleMedium }, stageDescription = { MaterialTheme.typography.bodyMedium }, checkboxLabel = { MaterialTheme.typography.labelLarge }, ) diff --git a/app/src/main/java/com/darkrockstudios/apps/fasttrack/screens/fasting/FastingViewModel.kt b/app/src/main/java/com/darkrockstudios/apps/fasttrack/screens/fasting/FastingViewModel.kt index be928cac..b06b4464 100644 --- a/app/src/main/java/com/darkrockstudios/apps/fasttrack/screens/fasting/FastingViewModel.kt +++ b/app/src/main/java/com/darkrockstudios/apps/fasttrack/screens/fasting/FastingViewModel.kt @@ -8,9 +8,11 @@ import com.darkrockstudios.apps.fasttrack.FastingNotificationManager import com.darkrockstudios.apps.fasttrack.R import com.darkrockstudios.apps.fasttrack.data.Phase import com.darkrockstudios.apps.fasttrack.data.Stages +import com.darkrockstudios.apps.fasttrack.data.descriptionFor import com.darkrockstudios.apps.fasttrack.data.activefast.ActiveFastRepository import com.darkrockstudios.apps.fasttrack.data.log.FastingLogRepository import com.darkrockstudios.apps.fasttrack.data.settings.SettingsDatasource +import com.darkrockstudios.apps.fasttrack.utils.formatDuration import com.darkrockstudios.apps.fasttrack.widget.WidgetUpdater import io.github.aakira.napier.Napier import kotlinx.coroutines.Dispatchers @@ -20,7 +22,6 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import kotlin.math.abs import kotlin.time.Clock import kotlin.time.Duration import kotlin.time.Duration.Companion.hours @@ -50,131 +51,99 @@ class FastingViewModel( } } + viewModelScope.launch { + settingsDatasource.phaseVisibilityFlow().collect { v -> + _uiState.update { state -> + state.copy( + showFatBurn = v.fatBurn, + showKetosis = v.ketosis, + showAutophagy = v.autophagy, + phaseAutoMode = v.autoMode, + ) + } + } + } + updateUi() setupFastingNotification() } override fun updateUi() { - updateFastingState() - updateTimer() - updateStage() - } - - private fun updateFastingState() { + // One read of the repository and one state emission per tick: separate + // emissions here each trigger their own recomposition of the dial + rows. val isFasting = repository.isFasting() - _uiState.update { it.copy(isFasting = isFasting) } - } - - private fun updateStage() { val fastStart = repository.getFastStart() - var stageTitle = "" - var stageDescription = "" - var energyMode = "" - - if (repository.isFasting() && fastStart != null) { - val elapsedTime = clock.now().minus(fastStart) - val elapsedHours = elapsedTime.inWholeHours.toInt() - - var stageIndex = Stages.stage.indexOfLast { it.hours <= elapsedHours } - if (stageIndex < 0) { - stageIndex = 0 - } - - val stage = Stages.stage[stageIndex] + val fastEnd = repository.getFastEnd() - val curPhase = Stages.getCurrentPhase(elapsedTime) - energyMode = if (curPhase.fatBurning) { - appContext.getString( - R.string.fasting_energy_mode, - appContext.getString(R.string.fasting_energy_mode_fat) + _uiState.update { state -> + if (fastStart != null) { + val elapsedTime = fastEnd?.minus(fastStart) ?: clock.now().minus(fastStart) + + // Stage copy is shown only while a fast is actually running. + val stage = if (isFasting) computeStage(elapsedTime) else EMPTY_STAGE + + val fatBurn = getPhaseTimeAndStageState(Stages.PHASE_FAT_BURN, elapsedTime) + val ketosis = getPhaseTimeAndStageState(Stages.PHASE_KETOSIS, elapsedTime) + val autophagy = getPhaseTimeAndStageState(Stages.PHASE_AUTOPHAGY, elapsedTime) + + state.copy( + isFasting = isFasting, + elapsedTime = elapsedTime, + elapsedHours = elapsedTime.inWholeHours.toDouble(), + fastStartTime = fastStart, + lastFastEndTime = fastEnd, + timerText = formatDuration(appContext, elapsedTime), + milliseconds = "", + stageTitle = stage.title, + stageDescription = stage.description, + energyMode = stage.energyMode, + fatBurnTime = fatBurn.first, + fatBurnStageState = fatBurn.second, + ketosisTime = ketosis.first, + ketosisStageState = ketosis.second, + autophagyTime = autophagy.first, + autophagyStageState = autophagy.second, ) } else { - appContext.getString( - R.string.fasting_energy_mode, - appContext.getString(R.string.fasting_energy_mode_glucose) - ) - } - - stageTitle = appContext.getString(stage.title) - stageDescription = appContext.getString(stage.description) - } - - _uiState.update { - it.copy( - stageTitle = stageTitle, - stageDescription = stageDescription, - energyMode = energyMode - ) - } - } - - private fun updateTimer() { - val fastStart = repository.getFastStart() - val fastEnd = repository.getFastEnd() - - if (fastStart != null) { - val elapsedTime = fastEnd?.minus(fastStart) ?: clock.now().minus(fastStart) - - updateTimerView(elapsedTime) - updatePhases(elapsedTime) - - _uiState.update { it.copy(elapsedTime = elapsedTime, fastStartTime = fastStart) } - } else { - _uiState.update { - it.copy( + state.copy( + isFasting = isFasting, elapsedTime = null, + elapsedHours = 0.0, fastStartTime = null, - elapsedHours = 0.0 + lastFastEndTime = fastEnd, + stageTitle = "", + stageDescription = "", + energyMode = "", ) } } } - private fun updateTimerView(elapsedTime: Duration) { - elapsedTime.toComponents { hours, minutes, seconds, nanoseconds -> - val secondsStr = "%02d".format(seconds) - val minutesStr = "%02d".format(minutes) - val timerText = "$hours:$minutesStr:$secondsStr" - val millisecondsText = "%02d".format(nanoseconds / 10000000) - - _uiState.update { - it.copy( - timerText = timerText, - milliseconds = millisecondsText - ) - } - } - } + private data class StageStrings(val title: String, val description: String, val energyMode: String) - private fun updatePhases(elapsedTime: Duration) { - val currentStage = Stages.getCurrentPhase(elapsedTime) + private val EMPTY_STAGE = StageStrings("", "", "") - _uiState.update { it.copy(elapsedHours = elapsedTime.inWholeHours.toDouble()) } + private fun computeStage(elapsedTime: Duration): StageStrings { + val elapsedHours = elapsedTime.inWholeHours.toInt() - val fatBurnTimeAndState = getPhaseTimeAndStageState(Stages.PHASE_FAT_BURN, elapsedTime) - - val ketosisTimeAndState = if (currentStage.fatBurning) { - getPhaseTimeAndStageState(Stages.PHASE_KETOSIS, elapsedTime) - } else { - Pair("--:--:--", IFastingViewModel.StageState.StartedInactive) - } - - val autophagyTimeAndState = if (currentStage.ketosis) { - getPhaseTimeAndStageState(Stages.PHASE_AUTOPHAGY, elapsedTime) - } else { - Pair("--:--:--", IFastingViewModel.StageState.StartedInactive) - } + var stageIndex = Stages.stage.indexOfLast { it.hours <= elapsedHours } + if (stageIndex < 0) stageIndex = 0 + val stage = Stages.stage[stageIndex] - _uiState.update { - it.copy( - fatBurnTime = fatBurnTimeAndState.first, - fatBurnStageState = fatBurnTimeAndState.second, - ketosisTime = ketosisTimeAndState.first, - ketosisStageState = ketosisTimeAndState.second, - autophagyTime = autophagyTimeAndState.first, - autophagyStageState = autophagyTimeAndState.second + val curPhase = Stages.getCurrentPhase(elapsedTime) + val energyMode = appContext.getString( + R.string.fasting_energy_mode, + appContext.getString( + if (curPhase.fatBurning) R.string.fasting_energy_mode_fat + else R.string.fasting_energy_mode_glucose ) - } + ) + + return StageStrings( + title = appContext.getString(stage.title), + description = appContext.getString(descriptionFor(stage, elapsedTime.inWholeHours)), + energyMode = energyMode, + ) } private fun getPhaseTimeAndStageState( @@ -186,20 +155,13 @@ class FastingViewModel( val stageState: IFastingViewModel.StageState if (elapsedTime.toDouble(DurationUnit.HOURS) > phaseHours) { - val timeSince = elapsedTime.minus(phaseHours.hours) - timeText = timeSince.toComponents { hours, minutes, seconds, _ -> - "%d:%02d:%02d".format(hours, minutes, seconds) - } + // The phase is underway: how long you've been in it + timeText = formatDuration(appContext, elapsedTime.minus(phaseHours.hours)) stageState = IFastingViewModel.StageState.StartedActive } else { - val timeSince = elapsedTime.minus(phaseHours.hours) - timeText = timeSince.toComponents { hours, minutes, seconds, _ -> - "-%d:%02d:%02d".format( - abs(hours), - abs(minutes), - abs(seconds) - ) - } + // The phase is ahead: frame it as anticipation, not deficit + val timeUntil = phaseHours.hours.minus(elapsedTime) + timeText = appContext.getString(R.string.phase_time_until, formatDuration(appContext, timeUntil)) stageState = IFastingViewModel.StageState.StartedInactive } @@ -221,11 +183,13 @@ class FastingViewModel( } } - override fun endFast(timeEnded: Instant?) { + override fun endFast(timeEnded: Instant?, notes: String) { if (repository.isFasting()) { repository.endFast(timeEnded) - viewModelScope.launch(Dispatchers.IO) { saveFastToLog(repository.getFastStart(), repository.getFastEnd()) } + viewModelScope.launch(Dispatchers.IO) { + saveFastToLog(repository.getFastStart(), repository.getFastEnd(), notes) + } Napier.i("Fast ended!") @@ -294,11 +258,12 @@ class FastingViewModel( WidgetUpdater.updateWidgets(appContext) } - private suspend fun saveFastToLog(startTime: Instant?, endTime: Instant?) = withContext(Dispatchers.Default) { - if (startTime != null && endTime != null) { - logRepository.logFast(startTime, endTime) - } else { - Napier.e("No start time when ending fast!") + private suspend fun saveFastToLog(startTime: Instant?, endTime: Instant?, notes: String) = + withContext(Dispatchers.Default) { + if (startTime != null && endTime != null) { + logRepository.logFast(startTime, endTime, notes) + } else { + Napier.e("No start time when ending fast!") + } } - } } diff --git a/app/src/main/java/com/darkrockstudios/apps/fasttrack/screens/fasting/IFastingViewModel.kt b/app/src/main/java/com/darkrockstudios/apps/fasttrack/screens/fasting/IFastingViewModel.kt index a47691b2..e58b93d7 100644 --- a/app/src/main/java/com/darkrockstudios/apps/fasttrack/screens/fasting/IFastingViewModel.kt +++ b/app/src/main/java/com/darkrockstudios/apps/fasttrack/screens/fasting/IFastingViewModel.kt @@ -13,12 +13,13 @@ interface IFastingViewModel { val isFasting: Boolean = false, val elapsedTime: Duration? = null, val fastStartTime: Instant? = null, + val lastFastEndTime: Instant? = null, val stageTitle: String = "", val stageDescription: String = "", val energyMode: String = "", - val fatBurnTime: String = "--:--:--", - val ketosisTime: String = "--:--:--", - val autophagyTime: String = "--:--:--", + val fatBurnTime: String = "—", + val ketosisTime: String = "—", + val autophagyTime: String = "—", val fatBurnStageState: StageState = StageState.NotStarted, val ketosisStageState: StageState = StageState.NotStarted, val autophagyStageState: StageState = StageState.NotStarted, @@ -26,6 +27,10 @@ interface IFastingViewModel { val milliseconds: String = "00", val timerText: String = "00:00:00", val showGradientBackground: Boolean = true, + val showFatBurn: Boolean = true, + val showKetosis: Boolean = true, + val showAutophagy: Boolean = true, + val phaseAutoMode: Boolean = false, ) val uiState: StateFlow @@ -33,7 +38,7 @@ interface IFastingViewModel { fun onCreate() fun updateUi() fun startFast(timeStartedMills: Instant? = null) - fun endFast(timeEnded: Instant? = null) + fun endFast(timeEnded: Instant? = null, notes: String = "") fun setupAlerts() fun debugIncreaseFastingTimeByOneHour() } diff --git a/app/src/main/java/com/darkrockstudios/apps/fasttrack/screens/fasting/TimeLine.kt b/app/src/main/java/com/darkrockstudios/apps/fasttrack/screens/fasting/TimeLine.kt index f74a93f2..f2ed8a6d 100644 --- a/app/src/main/java/com/darkrockstudios/apps/fasttrack/screens/fasting/TimeLine.kt +++ b/app/src/main/java/com/darkrockstudios/apps/fasttrack/screens/fasting/TimeLine.kt @@ -1,33 +1,57 @@ package com.darkrockstudios.apps.fasttrack.screens.fasting +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.CubicBezierEasing +import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.RepeatMode -import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.infiniteRepeatable -import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.tween import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.detectTapGestures -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.StrokeCap -import androidx.compose.ui.graphics.drawscope.Fill import androidx.compose.ui.graphics.drawscope.Stroke -import androidx.compose.ui.graphics.lerp +import androidx.compose.ui.graphics.drawscope.rotate +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.dp -import com.darkrockstudios.apps.fasttrack.data.Phase -import com.darkrockstudios.apps.fasttrack.data.Stages -import kotlin.math.abs +import androidx.compose.ui.unit.sp +import com.darkrockstudios.apps.fasttrack.data.FastingJourney +import com.darkrockstudios.apps.fasttrack.data.JourneyStage +import com.darkrockstudios.apps.fasttrack.ui.theme.LocalDarkTheme +import kotlin.math.atan2 +import kotlin.math.cos import kotlin.math.min -import kotlin.time.Duration.Companion.hours +import kotlin.math.roundToInt +import kotlin.math.sin +import kotlin.math.sqrt +/** + * Legacy phase colors, still used by the Log screen. + */ val gaugeColors = listOf( Color.White, Color.Green, @@ -36,155 +60,375 @@ val gaugeColors = listOf( Color.Magenta ) +/** Journey stage colors tuned for dark backgrounds: dawn → fire → clarity → renewal. */ +private val journeyColorsDark = listOf( + Color(0xFFF5F0E1), // Fueling Up: warm ivory + Color(0xFFF0E4C0), // Settling In: pale sand + Color(0xFFEDD9A0), // Finding Balance: soft gold + Color(0xFFE8CD7E), // Inner Alchemy: deep gold + Color(0xFF7BE495), // The Burn Begins: spring green + Color(0xFFFFD54F), // Clear Waters: amber + Color(0xFFFF7A6B), // Deep Renewal: coral + Color(0xFFF48FB1), // Strength Rising: rose + Color(0xFFCE8FFF), // Fine Tuning: violet + Color(0xFFA98BFF), // Rebirth: deep violet +) + +/** Journey stage colors tuned for light backgrounds. */ +private val journeyColorsLight = listOf( + Color(0xFFCFA24E), // Fueling Up: warm gold + Color(0xFFD49A3F), // Settling In: deeper gold + Color(0xFFD98F2F), // Finding Balance: amber gold + Color(0xFFDE8420), // Inner Alchemy: amber + Color(0xFF2E9E5B), // The Burn Begins: deep green + Color(0xFFE6A817), // Clear Waters: goldenrod + Color(0xFFDE5B4C), // Deep Renewal: terracotta + Color(0xFFC2559E), // Strength Rising: magenta rose + Color(0xFF9450C8), // Fine Tuning: royal violet + Color(0xFF7A3FB8), // Rebirth: deep violet +) + +/** Accent color of a journey stage, matching the dial's gradient. */ +@Composable +fun journeyStageColor(stageIndex: Int): Color { + val colors = if (LocalDarkTheme.current) journeyColorsDark else journeyColorsLight + return colors.getOrElse(stageIndex) { colors.last() } +} + +// The ring is an open arc: 270 degrees of sweep with the gap facing down, +// like a vessel that is being filled. +private const val START_ANGLE = 135f +private const val TOTAL_SWEEP = 270f + +// Golden-ratio derived proportions (phi = 1.618...) +private const val PHI = 1.618034f +private const val STROKE_FRACTION = 1f / (PHI * PHI * PHI * PHI) / 2.4f // of min dimension +private const val TRACK_ALPHA_DARK = 0.16f +private const val TRACK_ALPHA_LIGHT = 0.24f + +// Beyond this many hours the knob rests at the arc's end; the dial reads +// identically for a 4-day or a 40-day fast (the center timer carries the days). +private val FINAL_STAGE = FastingJourney.stages.last() +private val FINAL_STAGE_VISUAL_END_HOURS = FINAL_STAGE.startHours + 24f + /** - * Fasting Stages view + * Fasting journey dial: a circular mandala-like gauge. The ten journey stages + * wrap around an open ring as a continuous color gradient with an emoji + * milestone bubble at each stage's heart; fine radial ticks give it a subtle + * fractal texture. On first composition the lit arc blooms from zero up to the + * current position, where a glowing knob breathes gently. Milestones wake up + * one by one as the arc reaches them. + * + * [content] is rendered centered inside the ring (the timer lives there). + * Tapping a milestone bubble, the knob, or anywhere on the band reports the + * corresponding [JourneyStage] via [onStageClick]. */ @Composable fun TimeLine( elapsedHours: Double, modifier: Modifier = Modifier, - onPhaseClick: (Phase) -> Unit = {} + onStageClick: (JourneyStage) -> Unit = {}, + content: @Composable () -> Unit = {} ) { - val padding = 16.dp - val spacing = 4.dp - val barSize = 16.dp - val needleSize = 3.dp - val needleRadius = 4.dp - val slantOffset = 8.dp - + val isDark = LocalDarkTheme.current + val ringColors = if (isDark) journeyColorsDark else journeyColorsLight + val trackAlpha = if (isDark) TRACK_ALPHA_DARK else TRACK_ALPHA_LIGHT val outlineColor = MaterialTheme.colorScheme.onBackground - - val curPhase = Stages.getCurrentPhase(elapsedHours.hours) - - // Continuous blink animation for current phase - val infiniteTransition = rememberInfiniteTransition(label = "phase_blink") - val blinkProgress by infiniteTransition.animateFloat( - initialValue = 0f, - targetValue = 1f, - animationSpec = infiniteRepeatable( - animation = tween(durationMillis = 500), - repeatMode = RepeatMode.Reverse - ), - label = "blink_progress" - ) + val bubbleColor = MaterialTheme.colorScheme.surfaceVariant + + val stages = FastingJourney.stages + val segmentSweep = TOTAL_SWEEP / stages.size + val curIndex = FastingJourney.indexFor(elapsedHours) + val targetNeedle = needleSweep(elapsedHours, segmentSweep) + + // Intro bloom: the lit arc sweeps from zero to the current position once, + // with a long decelerating tail so it settles rather than stops. + val intro = remember { Animatable(0f) } + LaunchedEffect(Unit) { + intro.animateTo( + targetValue = 1f, + animationSpec = tween( + durationMillis = 2100, + easing = CubicBezierEasing(0.22f, 1f, 0.36f, 1f) + ) + ) + } - Canvas( - modifier = modifier - .fillMaxWidth() - .height(padding + barSize) - .pointerInput(curPhase) { - detectTapGestures { offset -> - val paddingPx = padding.toPx() - val spacingPx = spacing.toPx() - val barSizePx = barSize.toPx() - val slantOffsetPx = slantOffset.toPx() - - val phaseWidth = (size.width - (2 * paddingPx) - (Stages.phases.size - 1) * spacingPx) / Stages.phases.size - val totalWidth = (Stages.phases.size * phaseWidth) + ((Stages.phases.size - 1) * spacingPx) + slantOffsetPx - val startOffset = (size.width - totalWidth) / 2f - - val startY = paddingPx - val yOk = abs(offset.y - startY) <= (barSizePx / 2f) - if (yOk) { - Stages.phases.forEachIndexed { index, phase -> - val startX = startOffset + (index * phaseWidth) + (index * spacingPx) - val endX = startX + phaseWidth + slantOffsetPx - if (offset.x in startX..endX) { - onPhaseClick(phase) - return@detectTapGestures - } + // Slow breathing pulse for the comet head — only while a fast is running. + // When idle nobody drives the animation, so the Canvas stops invalidating + // every frame (no wasted battery on a screen that isn't visibly moving). + val pulsing = elapsedHours > 0 + val breathAnim = remember { Animatable(0f) } + LaunchedEffect(pulsing) { + if (pulsing) { + breathAnim.animateTo( + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 2600, easing = FastOutSlowInEasing), + repeatMode = RepeatMode.Reverse + ) + ) + } else { + breathAnim.snapTo(0f) + } + } + val breath = breathAnim.value + + // The sweep gradient's color stops depend only on the palette and geometry, + // not on time — remember them so a fasting redraw doesn't rebuild the array + // (and Brush) on every animation frame. + val stageStops = remember(ringColors, segmentSweep) { + Array(stages.size + 2) { i -> + when (i) { + 0 -> 0f to ringColors.first() + stages.size + 1 -> 1f to ringColors.last() + else -> ((i - 0.5f) * segmentSweep / 360f) to ringColors[i - 1] + } + } + } + + Box( + modifier = modifier.aspectRatio(1f), + contentAlignment = Alignment.Center + ) { + Canvas( + modifier = Modifier + .fillMaxSize() + .pointerInput(Unit) { + detectTapGestures { offset -> + val minDim = min(size.width, size.height).toFloat() + val stroke = minDim * STROKE_FRACTION + val radius = minDim / 2f - stroke * 1.18f + val center = Offset(size.width / 2f, size.height / 2f) + + val dx = offset.x - center.x + val dy = offset.y - center.y + val dist = sqrt(dx * dx + dy * dy) + // Only accept taps on the ring band itself. The knob always + // sits inside the current stage's segment, so tapping it + // naturally opens the current stage. + if (dist < radius - stroke * 1.7f || dist > radius + stroke * 1.7f) { + return@detectTapGestures } + + var angle = Math.toDegrees(atan2(dy.toDouble(), dx.toDouble())).toFloat() + if (angle < 0f) angle += 360f + var sweep = angle - START_ANGLE + if (sweep < 0f) sweep += 360f + if (sweep > TOTAL_SWEEP) return@detectTapGestures + + val segSweep = TOTAL_SWEEP / FastingJourney.stages.size + val index = min((sweep / segSweep).toInt(), FastingJourney.stages.size - 1) + onStageClick(FastingJourney.stages[index]) } } - } - ) { - val lastPhase = Stages.phases.last() - val lastPhaseHoursWeighted = lastPhase.hours * 1.5f - - val slantOffsetPx = slantOffset.toPx() - val barSizePx = barSize.toPx() - - val phaseWidth = (size.width - (2 * padding.toPx()) - (Stages.phases.size - 1) * spacing.toPx()) / Stages.phases.size - val totalWidth = (Stages.phases.size * phaseWidth) + ((Stages.phases.size - 1) * spacing.toPx()) + slantOffsetPx - - val startOffset = (size.width - totalWidth) / 2f - - Stages.phases.forEachIndexed { index, phase -> - val startX = startOffset + (index * phaseWidth) + (index * spacing.toPx()) - val startY = padding.toPx() - - val rhombusPath = Path().apply { - moveTo(startX + slantOffsetPx, startY - barSizePx / 2) - lineTo(startX + phaseWidth + slantOffsetPx, startY - barSizePx / 2) - lineTo(startX + phaseWidth, startY + barSizePx / 2) - lineTo(startX, startY + barSizePx / 2) - close() - } + ) { + val minDim = min(size.width, size.height) + val stroke = minDim * STROKE_FRACTION + val radius = minDim / 2f - stroke * 1.18f + val center = Offset(size.width / 2f, size.height / 2f) + val arcSize = Size(radius * 2f, radius * 2f) + val arcTopLeft = Offset(center.x - radius, center.y - radius) - if (curPhase == phase) { - drawPath( - path = rhombusPath, - color = gaugeColors[index], - style = Fill - ) - - // Continuously animate between orange and yellow - val baseOutlineColor = Color(0xFFE67E22) // Orange - val blinkColor = Color.Yellow - val currentOutlineColor = lerp(baseOutlineColor, blinkColor, blinkProgress) - - drawPath( - path = rhombusPath, - color = currentOutlineColor, - style = Stroke(width = 3.dp.toPx()) - ) - } else { - drawPath( - path = rhombusPath, - color = gaugeColors[index], - style = Fill - ) - - drawPath( - path = rhombusPath, - color = outlineColor, - style = Stroke(width = 2.dp.toPx()) + val litSweep = (targetNeedle * intro.value).coerceIn(0f, TOTAL_SWEEP) + + // One continuous gradient flowing through the stage colors, + // anchored at each stage segment's midpoint. + val phaseBrush = Brush.sweepGradient(colorStops = stageStops, center = center) + + // Rotate so relative angle 0 is the arc start; the sweep gradient + // rotates with the geometry, keeping its stops aligned to stages. + rotate(degrees = START_ANGLE, pivot = center) { + // Fine radial ticks: quiet fractal texture just inside the band, + // with stronger ticks landing on stage boundaries. + val tickStep = segmentSweep / 3f + val tickCount = (TOTAL_SWEEP / tickStep).toInt() + for (i in 0..tickCount) { + val angle = Math.toRadians((i * tickStep).toDouble()) + val major = i % 3 == 0 + val rOuter = radius - stroke * 0.95f + val rInner = rOuter - (if (major) stroke * 0.62f else stroke * 0.34f) + val dir = Offset(cos(angle).toFloat(), sin(angle).toFloat()) + drawLine( + color = outlineColor.copy(alpha = if (major) 0.22f else 0.10f), + start = center + dir * rInner, + end = center + dir * rOuter, + strokeWidth = (if (major) 1.5.dp else 1.dp).toPx(), + cap = StrokeCap.Round + ) + } + + // Unlit track: the full journey, faintly visible + drawArc( + brush = phaseBrush, + startAngle = 0f, + sweepAngle = TOTAL_SWEEP, + useCenter = false, + topLeft = arcTopLeft, + size = arcSize, + style = Stroke(width = stroke, cap = StrokeCap.Round), + alpha = trackAlpha ) + + if (litSweep > 0f) { + // Soft aura beneath the lit arc, breathing slowly + drawArc( + brush = phaseBrush, + startAngle = 0f, + sweepAngle = litSweep, + useCenter = false, + topLeft = arcTopLeft, + size = arcSize, + style = Stroke(width = stroke * (1.9f + 0.35f * breath), cap = StrokeCap.Round), + alpha = 0.10f + 0.06f * breath + ) + + // The lit arc itself + drawArc( + brush = phaseBrush, + startAngle = 0f, + sweepAngle = litSweep, + useCenter = false, + topLeft = arcTopLeft, + size = arcSize, + style = Stroke(width = stroke, cap = StrokeCap.Round) + ) + + // Comet head: the arc's rounded tip IS the current position — + // no knob, just a breathing glow where the color ends. + val headRad = Math.toRadians(litSweep.toDouble()) + val headCenter = center + + Offset(cos(headRad).toFloat(), sin(headRad).toFloat()) * radius + drawCircle( + color = ringColors[curIndex].copy(alpha = 0.25f + 0.20f * breath), + radius = stroke * (1.30f + 0.30f * breath), + center = headCenter + ) + } } } - // Draw the needle - if (elapsedHours > 0) { - val curPhaseIndex = Stages.phases.indexOf(curPhase) - val nextPhaseHours: Float = if (curPhaseIndex + 1 < Stages.phases.size) { - val nextPhase = Stages.phases[curPhaseIndex + 1] - nextPhase.hours.toFloat() - } else { - lastPhaseHoursWeighted + // Milestone bubbles, one at the heart of each stage segment. + // They wake up in sequence as the intro arc blooms past them. + Layout( + modifier = Modifier.fillMaxSize(), + content = { + stages.forEachIndexed { index, stage -> + MilestoneBubble( + emoji = stage.emoji, + accent = ringColors[index], + background = bubbleColor, + // Nothing is "reached" when no fast is running: the dial rests muted + reached = elapsedHours > 0 && elapsedHours >= stage.startHours, + isCurrent = index == curIndex && elapsedHours > 0, + wakeDelayMillis = 250 + index * 140, + onClick = { onStageClick(stage) } + ) + } } - val phaseLength = (nextPhaseHours - curPhase.hours) - val timeIntoPhase = elapsedHours - curPhase.hours + ) { measurables, constraints -> + val width = constraints.maxWidth + val height = constraints.maxHeight + val minDim = min(width, height).toFloat() + val stroke = minDim * STROKE_FRACTION + val radius = minDim / 2f - stroke * 1.18f + val bubbleSize = (stroke * 2.1f).roundToInt() - val percent = min(timeIntoPhase / phaseLength, 1.0) + val placeables = measurables.map { it.measure(Constraints.fixed(bubbleSize, bubbleSize)) } + + layout(width, height) { + placeables.forEachIndexed { index, placeable -> + val angle = Math.toRadians( + (START_ANGLE + (index + 0.5f) * segmentSweep).toDouble() + ) + val x = width / 2f + radius * cos(angle).toFloat() - bubbleSize / 2f + val y = height / 2f + radius * sin(angle).toFloat() - bubbleSize / 2f + // place (not placeRelative): the canvas dial never mirrors in RTL + placeable.place(x.roundToInt(), y.roundToInt()) + } + } + } - val halfPadding = padding.toPx() / 2f + // Center content lives inside the ring's inscribed square, so the + // timer can never spill over the arc. The ring's inner radius is + // (0.5 - STROKE_FRACTION*1.18); an inscribed square spans that + // diameter / sqrt(2). A small safety margin keeps text off the band. + Box( + modifier = Modifier.fillMaxSize(fraction = INNER_CONTENT_FRACTION), + contentAlignment = Alignment.Center + ) { + content() + } + } +} - val startX = startOffset + (curPhaseIndex * phaseWidth) + (curPhaseIndex * spacing.toPx()) - val x = (startX + (phaseWidth * percent)).toFloat() +// Fraction of the dial width available to centered content (timer + label). +// Derived from the inscribed square of the inner circle, trimmed for margin. +private val INNER_CONTENT_FRACTION = + ((0.5f - STROKE_FRACTION * 1.18f) * 2f / 1.41421f) * 0.94f - drawLine( - color = Color.DarkGray, - start = Offset(x, halfPadding), - end = Offset(x, barSize.toPx() + halfPadding), - strokeWidth = needleSize.toPx(), - cap = StrokeCap.Square - ) +@Composable +private fun MilestoneBubble( + emoji: String, + accent: Color, + background: Color, + reached: Boolean, + isCurrent: Boolean, + wakeDelayMillis: Int, + onClick: () -> Unit, +) { + val alpha by animateFloatAsState( + targetValue = if (reached) 1f else 0.40f, + animationSpec = tween(durationMillis = 700, delayMillis = wakeDelayMillis), + label = "bubble_wake" + ) + // Visual hierarchy: the milestones ahead recede, the current one leads + val scale by animateFloatAsState( + targetValue = when { + isCurrent -> 1.15f + reached -> 1f + else -> 0.78f + }, + animationSpec = tween(durationMillis = 700, delayMillis = wakeDelayMillis), + label = "bubble_scale" + ) - drawCircle( - color = Color.DarkGray, - radius = needleRadius.toPx(), - center = Offset(x, barSize.toPx() + halfPadding + needleRadius.toPx()) - ) - } + BoxWithConstraints( + modifier = Modifier + .graphicsLayer { + this.alpha = alpha + scaleX = scale + scaleY = scale + } + .clip(CircleShape) + .background(background) + .border(1.5.dp, accent.copy(alpha = if (reached) 0.9f else 0.4f), CircleShape) + .clickable(onClick = onClick), + contentAlignment = Alignment.Center + ) { + Text( + text = emoji, + fontSize = (maxWidth.value * 0.46f).sp, + ) } -} \ No newline at end of file +} + +/** + * Sweep (in degrees from the arc start) of the current position. Each journey + * stage occupies an equal segment and the knob interpolates within the current + * stage based on time into it. Past the visual end of the final stage the knob + * rests at the arc's end, so a 5-, 10-, or 40-day fast all read the same: + * a fully lit ring with the knob breathing at the finish. + */ +private fun needleSweep(elapsedHours: Double, segmentSweep: Float): Float { + if (elapsedHours <= 0) return 0f + + val index = FastingJourney.indexFor(elapsedHours) + val stage = FastingJourney.stages[index] + val endHours = stage.endHours?.toFloat() ?: FINAL_STAGE_VISUAL_END_HOURS + val stageLength = endHours - stage.startHours + val timeIntoStage = elapsedHours - stage.startHours + val percent = min(timeIntoStage / stageLength, 1.0).toFloat() + + return (index + percent) * segmentSweep +} diff --git a/app/src/main/java/com/darkrockstudios/apps/fasttrack/screens/info/InfoActivity.kt b/app/src/main/java/com/darkrockstudios/apps/fasttrack/screens/info/InfoActivity.kt index ccd7e1b4..f729d3a3 100644 --- a/app/src/main/java/com/darkrockstudios/apps/fasttrack/screens/info/InfoActivity.kt +++ b/app/src/main/java/com/darkrockstudios/apps/fasttrack/screens/info/InfoActivity.kt @@ -7,7 +7,7 @@ import androidx.appcompat.app.AppCompatActivity import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material3.* import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource @@ -42,7 +42,7 @@ class InfoActivity : AppCompatActivity() { navigationIcon = { IconButton(onClick = { onBackPressed() }) { Icon( - imageVector = Icons.Default.ArrowBack, + imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back" ) } diff --git a/app/src/main/java/com/darkrockstudios/apps/fasttrack/screens/intro/IntroActivity.kt b/app/src/main/java/com/darkrockstudios/apps/fasttrack/screens/intro/IntroActivity.kt index 581c92c2..9ee3dac0 100644 --- a/app/src/main/java/com/darkrockstudios/apps/fasttrack/screens/intro/IntroActivity.kt +++ b/app/src/main/java/com/darkrockstudios/apps/fasttrack/screens/intro/IntroActivity.kt @@ -5,7 +5,6 @@ import android.content.pm.PackageManager import android.graphics.Color import android.os.Build import android.os.Bundle -import android.preference.PreferenceManager import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.activity.result.ActivityResultLauncher @@ -30,10 +29,8 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.core.content.ContextCompat -import androidx.core.content.edit import androidx.core.view.WindowCompat import com.darkrockstudios.apps.fasttrack.R -import com.darkrockstudios.apps.fasttrack.data.Data import com.darkrockstudios.apps.fasttrack.data.settings.SettingsDatasource import com.darkrockstudios.apps.fasttrack.ui.theme.FastTrackTheme import io.github.aakira.napier.Napier @@ -41,71 +38,69 @@ import kotlinx.coroutines.launch import org.koin.android.ext.android.inject class IntroActivity : AppCompatActivity() { - private val storage by lazy { PreferenceManager.getDefaultSharedPreferences(this) } - private val settings by inject() - private lateinit var requestNotificationPermission: ActivityResultLauncher - private var shouldRequestPermission by mutableStateOf(false) + private val settings by inject() + private lateinit var requestNotificationPermission: ActivityResultLauncher + private var shouldRequestPermission by mutableStateOf(false) @OptIn(ExperimentalFoundationApi::class) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() - WindowCompat.getInsetsController(window, window.decorView).isAppearanceLightStatusBars = false + WindowCompat.getInsetsController(window, window.decorView).isAppearanceLightStatusBars = + false - registerNotificationPermissionCallback() + registerNotificationPermissionCallback() setContent { FastTrackTheme(themeMode = settings.getThemeMode()) { IntroScreen( - onComplete = { complete() }, - onNotificationSlideExited = { requestNotificationPermissionIfNeeded() } + onComplete = { complete() }, + onNotificationSlideExited = { requestNotificationPermissionIfNeeded() } ) } } } - private fun registerNotificationPermissionCallback() { - requestNotificationPermission = registerForActivityResult( - ActivityResultContracts.RequestPermission() - ) { isGranted: Boolean -> - if (isGranted) { - Napier.d("Notification permission granted") - } else { - Napier.w("Notification permission denied") - } - shouldRequestPermission = false - } - } + private fun registerNotificationPermissionCallback() { + requestNotificationPermission = registerForActivityResult( + ActivityResultContracts.RequestPermission() + ) { isGranted: Boolean -> + if (isGranted) { + Napier.d("Notification permission granted") + } else { + Napier.w("Notification permission denied") + } + shouldRequestPermission = false + } + } - private fun requestNotificationPermissionIfNeeded() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - when { - ContextCompat.checkSelfPermission( - this, - Manifest.permission.POST_NOTIFICATIONS - ) == PackageManager.PERMISSION_GRANTED -> { - Napier.d("Notification permission already granted") - } + private fun requestNotificationPermissionIfNeeded() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + when { + ContextCompat.checkSelfPermission( + this, + Manifest.permission.POST_NOTIFICATIONS + ) == PackageManager.PERMISSION_GRANTED -> { + Napier.d("Notification permission already granted") + } - shouldRequestPermission -> { - // Already requested, don't request again - Napier.d("Notification permission already requested") - } + shouldRequestPermission -> { + // Already requested, don't request again + Napier.d("Notification permission already requested") + } - else -> { - Napier.d("Requesting notification permission") - shouldRequestPermission = true - requestNotificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS) - } - } - } - } + else -> { + Napier.d("Requesting notification permission") + shouldRequestPermission = true + requestNotificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS) + } + } + } + } private fun complete() { - storage.edit { - putBoolean(Data.KEY_INTRO_SEEN, true) - } + settings.setIntroSeen(true) finish() } } @@ -113,22 +108,22 @@ class IntroActivity : AppCompatActivity() { @OptIn(ExperimentalFoundationApi::class) @Composable fun IntroScreen( - onComplete: () -> Unit, - onNotificationSlideExited: () -> Unit + onComplete: () -> Unit, + onNotificationSlideExited: () -> Unit ) { - val pagerState = rememberPagerState(pageCount = { 6 }) + val pagerState = rememberPagerState(pageCount = { 6 }) val coroutineScope = rememberCoroutineScope() - var hasRequestedPermission by remember { mutableStateOf(false) } + var hasRequestedPermission by remember { mutableStateOf(false) } - // Watch for page changes and request permission when moving past slide 4 - LaunchedEffect(pagerState) { - snapshotFlow { pagerState.currentPage }.collect { page -> - if (page > 4 && !hasRequestedPermission) { - hasRequestedPermission = true - onNotificationSlideExited() - } - } - } + // Watch for page changes and request permission when moving past slide 4 + LaunchedEffect(pagerState) { + snapshotFlow { pagerState.currentPage }.collect { page -> + if (page > 4 && !hasRequestedPermission) { + hasRequestedPermission = true + onNotificationSlideExited() + } + } + } Box( modifier = Modifier.fillMaxSize() @@ -170,13 +165,13 @@ fun IntroScreen( title = stringResource(id = R.string.intro_04_title), description = stringResource(id = R.string.intro_04_description), imageDrawable = R.drawable.intro_04, - backgroundColor = Color.rgb(96, 128, 91) // Pastel Green + backgroundColor = Color.rgb(96, 128, 91) // Pastel Green ) - 5 -> IntroSlide( - title = stringResource(id = R.string.intro_05_title), - description = stringResource(id = R.string.intro_05_description), - imageDrawable = R.drawable.intro_05, + 5 -> IntroSlide( + title = stringResource(id = R.string.intro_05_title), + description = stringResource(id = R.string.intro_05_description), + imageDrawable = R.drawable.intro_05, backgroundColor = Color.rgb(128, 91, 128) // Pastel Magenta ) } @@ -185,19 +180,19 @@ fun IntroScreen( // Navigation controls at the bottom Column( modifier = Modifier - .align(Alignment.BottomCenter) - .safeDrawingPadding() - .padding(16.dp) - .fillMaxWidth() + .align(Alignment.BottomCenter) + .safeDrawingPadding() + .padding(16.dp) + .fillMaxWidth() ) { // Page indicator dots Row( modifier = Modifier - .fillMaxWidth() - .padding(8.dp), + .fillMaxWidth() + .padding(8.dp), horizontalArrangement = Arrangement.Center ) { - repeat(6) { iteration -> + repeat(6) { iteration -> val color = if (pagerState.currentPage == iteration) { androidx.compose.ui.graphics.Color.White } else { @@ -205,9 +200,9 @@ fun IntroScreen( } Box( modifier = Modifier - .padding(4.dp) - .size(8.dp) - .background(color = color, shape = MaterialTheme.shapes.small) + .padding(4.dp) + .size(8.dp) + .background(color = color, shape = MaterialTheme.shapes.small) ) } } @@ -215,8 +210,8 @@ fun IntroScreen( // Navigation buttons Row( modifier = Modifier - .fillMaxWidth() - .padding(8.dp), + .fillMaxWidth() + .padding(8.dp), horizontalArrangement = Arrangement.SpaceBetween ) { // Previous button or Skip button @@ -240,7 +235,7 @@ fun IntroScreen( } // Next button or Done button - if (pagerState.currentPage < 5) { + if (pagerState.currentPage < 5) { Button( onClick = { coroutineScope.launch { @@ -273,14 +268,14 @@ fun IntroSlide( ) { Box( modifier = modifier - .fillMaxSize() - .background(color = androidx.compose.ui.graphics.Color(backgroundColor)) + .fillMaxSize() + .background(color = androidx.compose.ui.graphics.Color(backgroundColor)) ) { Column( modifier = Modifier - .fillMaxSize() - .safeDrawingPadding() - .padding(16.dp), + .fillMaxSize() + .safeDrawingPadding() + .padding(16.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center ) { @@ -288,8 +283,8 @@ fun IntroSlide( painter = painterResource(id = imageDrawable), contentDescription = null, modifier = Modifier - .size(200.dp) - .padding(16.dp) + .size(200.dp) + .padding(16.dp) ) Spacer(modifier = Modifier.height(16.dp)) diff --git a/app/src/main/java/com/darkrockstudios/apps/fasttrack/screens/log/FastEntryItem.kt b/app/src/main/java/com/darkrockstudios/apps/fasttrack/screens/log/FastEntryItem.kt index 1b4698cb..3f0fdee2 100644 --- a/app/src/main/java/com/darkrockstudios/apps/fasttrack/screens/log/FastEntryItem.kt +++ b/app/src/main/java/com/darkrockstudios/apps/fasttrack/screens/log/FastEntryItem.kt @@ -4,7 +4,6 @@ import android.os.VibrationEffect import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.combinedClickable -import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.* @@ -12,73 +11,106 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.CustomAccessibilityAction +import androidx.compose.ui.semantics.customActions +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.darkrockstudios.apps.fasttrack.R import com.darkrockstudios.apps.fasttrack.data.Stages import com.darkrockstudios.apps.fasttrack.data.log.FastingLogEntry import com.darkrockstudios.apps.fasttrack.screens.fasting.gaugeColors -import com.darkrockstudios.apps.fasttrack.utils.formatAs +import com.darkrockstudios.apps.fasttrack.utils.AppDateTime +import com.darkrockstudios.apps.fasttrack.utils.LocalDateStyle +import com.darkrockstudios.apps.fasttrack.utils.formatDuration import com.darkrockstudios.apps.fasttrack.utils.rememberVibrator import com.darkrockstudios.apps.fasttrack.utils.shouldUse24HourFormat import androidx.compose.ui.platform.LocalContext +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toInstant +import kotlinx.datetime.toLocalDateTime import kotlin.math.roundToInt import kotlin.time.DurationUnit import kotlin.time.ExperimentalTime +/** + * A single logbook row. + * + * Interaction model (fluid + least-surprise + discoverable, and safe from the + * pager's horizontal swipe): + * - **Tap** opens the entry for editing — the universal "tap a record to open it" + * convention, so the row is never a dead target. + * - **Long-press** opens a small Edit/Delete menu — a deliberate gesture (no + * accidental deletes) and the accessible, TalkBack-friendly path. + * + * Bulk deletion lives in the screen's overflow menu ("Clear logbook"), behind a + * danger confirmation, rather than being reachable by a stray swipe. + */ @ExperimentalTime @Composable fun FastEntryItem( entry: FastingLogEntry, onEdit: () -> Unit, - onDelete: () -> Unit + onDelete: () -> Unit, ) { var showMenu by remember { mutableStateOf(false) } val vibrator = rememberVibrator() val context = LocalContext.current val use24Hour = shouldUse24HourFormat(context) + val editLabel = stringResource(id = R.string.menu_edit) + val deleteLabel = stringResource(id = R.string.menu_delete) - Box { + Box(modifier = Modifier.padding(bottom = 8.dp)) { Card( - modifier = Modifier.Companion + modifier = Modifier .fillMaxWidth() - .padding(bottom = 8.dp) .combinedClickable( - interactionSource = remember { MutableInteractionSource() }, - onClick = {}, + onClick = onEdit, onLongClick = { vibrator?.vibrate(VibrationEffect.createOneShot(50, VibrationEffect.DEFAULT_AMPLITUDE)) showMenu = true - } - ), + }, + ) + .semantics { + // The long-press menu is invisible to assistive tech; surface both + // actions explicitly in the TalkBack actions menu. + customActions = listOf( + CustomAccessibilityAction(editLabel) { onEdit(); true }, + CustomAccessibilityAction(deleteLabel) { onDelete(); true }, + ) + }, colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer), elevation = CardDefaults.cardElevation(defaultElevation = 4.dp), ) { Column { - val dateStr = remember(entry.start, use24Hour) { - val timePattern = if (use24Hour) "HH:mm" else "h:mm a" - entry.start.formatAs("d MMM uuuu - $timePattern") + // The fast's full window (start → end), the whole point of the entry, + // rendered in the user's chosen date/time style (locale-aware). + val dateStyle = LocalDateStyle.current + val heading = remember(entry.start, entry.length, use24Hour, dateStyle) { + val tz = TimeZone.currentSystemDefault() + val end = entry.start.toInstant(tz).plus(entry.length).toLocalDateTime(tz) + AppDateTime.formatFastRange(entry.start, end, dateStyle, use24Hour) } Text( - text = stringResource(id = R.string.log_entry_started, dateStr), + text = heading, style = MaterialTheme.typography.titleMedium, fontStyle = FontStyle.Italic, color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.padding(top = 8.dp, start = 16.dp, end = 16.dp) + modifier = Modifier.padding(top = 13.dp, start = 16.dp, end = 16.dp) ) Row( modifier = Modifier.Companion .fillMaxWidth() - .padding(16.dp), - horizontalArrangement = Arrangement.spacedBy(16.dp), + .padding(start = 16.dp, end = 16.dp, top = 13.dp), + horizontalArrangement = Arrangement.spacedBy(13.dp), verticalAlignment = Alignment.CenterVertically ) { val lenHours = entry.length.toDouble(DurationUnit.HOURS) - val hours = lenHours.roundToInt() // Determine highest stage reached val highestStage = remember(lenHours) { @@ -114,7 +146,7 @@ fun FastEntryItem( } else 0 Text( - text = "⏱️ " + stringResource(id = R.string.log_entry_length, hours), + text = "⏱️ " + formatDuration(context, entry.length), style = MaterialTheme.typography.headlineSmall.copy(fontSize = 18.sp), color = MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.Bold, @@ -136,6 +168,40 @@ fun FastEntryItem( } } } + + // Optional note, shown as a blockquote so long text stays readable + if (entry.notes.isNotBlank()) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = 16.dp, end = 16.dp, top = 5.dp) + .height(IntrinsicSize.Min), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .width(3.dp) + .fillMaxHeight() + .background( + MaterialTheme.colorScheme.primary, + RoundedCornerShape(2.dp) + ) + ) + Text( + text = entry.notes, + style = MaterialTheme.typography.bodyMedium, + fontStyle = FontStyle.Italic, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 6, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .weight(1f) + .padding(start = 8.dp) + ) + } + } + + Spacer(modifier = Modifier.height(13.dp)) } } @@ -159,4 +225,5 @@ fun FastEntryItem( ) } } -} \ No newline at end of file +} + diff --git a/app/src/main/java/com/darkrockstudios/apps/fasttrack/screens/log/ILogViewModel.kt b/app/src/main/java/com/darkrockstudios/apps/fasttrack/screens/log/ILogViewModel.kt index bf0b5af8..f815c156 100644 --- a/app/src/main/java/com/darkrockstudios/apps/fasttrack/screens/log/ILogViewModel.kt +++ b/app/src/main/java/com/darkrockstudios/apps/fasttrack/screens/log/ILogViewModel.kt @@ -4,16 +4,25 @@ import com.darkrockstudios.apps.fasttrack.data.log.FastingLogEntry import com.darkrockstudios.apps.fasttrack.data.settings.LogViewMode import kotlinx.coroutines.flow.StateFlow import kotlinx.datetime.LocalDate +import kotlin.time.Duration interface ILogViewModel { data class LogUiState( val entries: List = emptyList(), val totalKetosisHours: Int = 0, val totalAutophagyHours: Int = 0, + val totalFasts: Int = 0, + val totalFastedDuration: Duration = Duration.ZERO, + val longestFastDuration: Duration = Duration.ZERO, val showManualAddDialog: Boolean = false, val entryToEdit: FastingLogEntry? = null, val viewMode: LogViewMode = LogViewMode.LIST, val selectedDate: LocalDate? = null, + val showClearAllConfirmation: Boolean = false, + // An empty (past/today) calendar day awaiting "add a fast here?" confirmation. + val emptyDayToAdd: LocalDate? = null, + // Date to preselect in the Manual Add picker (e.g. from an empty calendar day). + val manualAddInitialDate: LocalDate? = null, ) val uiState: StateFlow @@ -25,4 +34,10 @@ interface ILogViewModel { fun loadEntries() fun setViewMode(mode: LogViewMode) fun selectDate(date: LocalDate?) + fun requestClearAll() + fun dismissClearAll() + fun clearAll() + fun requestAddForDate(date: LocalDate) + fun dismissAddForDate() + fun confirmAddForDate() } diff --git a/app/src/main/java/com/darkrockstudios/apps/fasttrack/screens/log/LogCalendarContent.kt b/app/src/main/java/com/darkrockstudios/apps/fasttrack/screens/log/LogCalendarContent.kt index 5a65962a..f27ccd61 100644 --- a/app/src/main/java/com/darkrockstudios/apps/fasttrack/screens/log/LogCalendarContent.kt +++ b/app/src/main/java/com/darkrockstudios/apps/fasttrack/screens/log/LogCalendarContent.kt @@ -3,17 +3,18 @@ package com.darkrockstudios.apps.fasttrack.screens.log import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -22,6 +23,8 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.platform.LocalLocale import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp @@ -29,7 +32,7 @@ import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import com.darkrockstudios.apps.fasttrack.data.Stages import com.darkrockstudios.apps.fasttrack.data.log.FastingLogEntry -import com.darkrockstudios.apps.fasttrack.utils.gaugeColors +import com.darkrockstudios.apps.fasttrack.utils.AppDateTime import com.kizitonwose.calendar.compose.HorizontalCalendar import com.kizitonwose.calendar.compose.rememberCalendarState import com.kizitonwose.calendar.core.CalendarDay @@ -37,12 +40,15 @@ import com.kizitonwose.calendar.core.CalendarMonth import com.kizitonwose.calendar.core.DayPosition import com.kizitonwose.calendar.core.daysOfWeek import com.kizitonwose.calendar.core.firstDayOfWeekFromLocale -import kotlinx.datetime.toJavaLocalDate +import kotlinx.datetime.DateTimeUnit +import kotlinx.datetime.TimeZone +import kotlinx.datetime.plus +import kotlinx.datetime.toInstant import kotlinx.datetime.toKotlinLocalDate +import kotlinx.datetime.toLocalDateTime import java.time.YearMonth -import java.time.format.DateTimeFormatter import java.time.format.TextStyle -import java.util.Locale +import kotlin.time.Duration.Companion.milliseconds import kotlin.time.DurationUnit import kotlin.time.ExperimentalTime import kotlinx.datetime.LocalDate as KxLocalDate @@ -50,16 +56,36 @@ import kotlinx.datetime.LocalDate as KxLocalDate @ExperimentalTime @Composable fun LogCalendarContent( - entries: List, + entries: List, selectedDate: KxLocalDate?, onDateSelected: (KxLocalDate?) -> Unit, - onEdit: (FastingLogEntry) -> Unit, - onDelete: (FastingLogEntry) -> Unit, - contentPadding: PaddingValues, - modifier: Modifier = Modifier, + onAddForEmptyDay: (KxLocalDate) -> Unit, + onEdit: (FastingLogEntry) -> Unit, + onDelete: (FastingLogEntry) -> Unit, + contentPadding: PaddingValues, + modifier: Modifier = Modifier, ) { - val entriesByDate = remember(entries) { - entries.groupBy { it.start.date } + // A fast spans every calendar day from its start day through the last day it + // was still active, so a multi-day fast highlights the whole range (not just + // its start day). This maps each covered day to the fasts active that day. + val coverage = remember(entries) { + val tz = TimeZone.currentSystemDefault() + val byDay = HashMap>() + val endDateById = HashMap() + for (e in entries) { + val startDate = e.start.date + val endInstant = e.start.toInstant(tz).plus(e.length) + // Last day the fast was actually active (a fast ending at 00:00 does + // not claim that day), never earlier than the start day. + val endDate = maxOf(startDate, (endInstant - 1.milliseconds).toLocalDateTime(tz).date) + endDateById[e.id] = endDate + var d = startDate + while (d <= endDate) { + byDay.getOrPut(d) { mutableListOf() }.add(e) + d = d.plus(1, DateTimeUnit.DAY) + } + } + CalendarCoverage(byDay, endDateById) } val today = remember { java.time.LocalDate.now() } @@ -86,13 +112,35 @@ fun LogCalendarContent( state = calendarState, dayContent = { day -> val kxDate = day.date.toKotlinLocalDate() - val dayEntries = entriesByDate[kxDate].orEmpty() + val covering = coverage.byDay[kxDate].orEmpty() + // Pick the longest fast covering this day (handles the rare + // overlap) and describe where the day sits in that fast's range. + val band = covering.maxByOrNull { it.length }?.let { chosen -> + val startDate = chosen.start.date + val endDate = coverage.endDateById[chosen.id] ?: startDate + DayBand( + color = stageColorFor(listOf(chosen)), + isStart = kxDate == startDate, + isEnd = kxDate == endDate, + isSingle = startDate == endDate, + ) + } DayCell( day = day, isToday = day.date == today, - entries = dayEntries, + isFuture = day.date.isAfter(today), + band = band, isSelected = selectedDate == kxDate, - onClick = { onDateSelected(kxDate) }, + onClick = { + if (covering.isNotEmpty()) { + // A day within a fast opens its detail dialog. + onDateSelected(kxDate) + } else { + // An empty past/today day offers to log a fast there + // (future days are disabled, so this never fires for them). + onAddForEmptyDay(kxDate) + } + }, ) }, monthHeader = { month -> MonthHeader(month) }, @@ -101,10 +149,9 @@ fun LogCalendarContent( } val selected = selectedDate - val selectedEntries = if (selected != null) entriesByDate[selected].orEmpty() else emptyList() + val selectedEntries = if (selected != null) coverage.byDay[selected].orEmpty() else emptyList() if (selected != null && selectedEntries.isNotEmpty()) { FastDayDialog( - date = selected, entries = selectedEntries, onDismiss = { onDateSelected(null) }, onEdit = onEdit, @@ -116,15 +163,11 @@ fun LogCalendarContent( @ExperimentalTime @Composable private fun FastDayDialog( - date: KxLocalDate, - entries: List, - onDismiss: () -> Unit, - onEdit: (FastingLogEntry) -> Unit, - onDelete: (FastingLogEntry) -> Unit, + entries: List, + onDismiss: () -> Unit, + onEdit: (FastingLogEntry) -> Unit, + onDelete: (FastingLogEntry) -> Unit, ) { - val formatter = remember { DateTimeFormatter.ofPattern("EEE, d MMM uuuu", Locale.getDefault()) } - val dateLabel = remember(date) { date.toJavaLocalDate().format(formatter) } - Dialog( onDismissRequest = onDismiss, properties = DialogProperties( @@ -137,15 +180,6 @@ private fun FastDayDialog( .fillMaxWidth() .padding(horizontal = 8.dp), ) { - Text( - text = dateLabel, - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.onSurface, - textAlign = TextAlign.Center, - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 8.dp), - ) entries.forEach { entry -> FastEntryItem( entry = entry, @@ -172,7 +206,7 @@ private fun DaysOfWeekRow(daysOfWeek: List) { ) { daysOfWeek.forEach { dow -> Text( - text = dow.getDisplayName(TextStyle.SHORT, Locale.getDefault()), + text = dow.getDisplayName(TextStyle.SHORT, LocalLocale.current.platformLocale), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center, @@ -184,9 +218,9 @@ private fun DaysOfWeekRow(daysOfWeek: List) { @Composable private fun MonthHeader(month: CalendarMonth) { - val formatter = remember { DateTimeFormatter.ofPattern("MMMM uuuu", Locale.getDefault()) } + val title = remember(month.yearMonth) { AppDateTime.formatMonthYear(month.yearMonth) } Text( - text = month.yearMonth.format(formatter), + text = title, style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.SemiBold, @@ -197,20 +231,27 @@ private fun MonthHeader(month: CalendarMonth) { ) } -@ExperimentalTime @Composable private fun DayCell( day: CalendarDay, isToday: Boolean, - entries: List, + isFuture: Boolean, + band: DayBand?, isSelected: Boolean, onClick: () -> Unit, ) { val inMonth = day.position == DayPosition.MonthDate - val hasEntries = entries.isNotEmpty() && inMonth + val cover = band + // Future days are greyed out and inert. Spill days from the adjacent month stay + // dimmed, but a covered one (a fast's head/tail bleeding across the month edge) + // remains tappable so the whole span is reachable — no dead-looking segments. + val enabled = !isFuture && (inMonth || cover != null) - val stageColor = if (hasEntries) stageColorFor(entries) else Color.Transparent - val bgColor = if (hasEntries) stageColor.copy(alpha = 0.45f) else Color.Transparent + val stageColor = cover?.color ?: Color.Transparent + // The whole span is one soft capsule; the true start/end read a touch stronger. + val bandColor = stageColor.copy(alpha = 0.20f) + val isEndpoint = cover != null && (cover.isStart || cover.isEnd || cover.isSingle) + val endpointFill = if (isEndpoint) stageColor.copy(alpha = 0.42f) else Color.Transparent val borderColor = when { isSelected -> MaterialTheme.colorScheme.primary @@ -220,34 +261,100 @@ private fun DayCell( val borderWidth = if (isSelected) 2.dp else 1.dp val dayTextColor = when { - !inMonth -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f) + isFuture -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f) + // Spill days from another month are dimmed; a covered one sits a little + // brighter so its part of the fast reads as present (and tappable). + !inMonth -> MaterialTheme.colorScheme.onSurface.copy(alpha = if (cover != null) 0.55f else 0.3f) else -> MaterialTheme.colorScheme.onSurface } Box( modifier = Modifier .aspectRatio(1f) - .padding(2.dp) - .clip(CircleShape) - .background(bgColor, CircleShape) - .border(borderWidth, borderColor, CircleShape) - .clickable(enabled = inMonth, onClick = onClick), + .clickable(enabled = enabled, onClick = onClick), contentAlignment = Alignment.Center, ) { - Text( - text = day.date.dayOfMonth.toString(), - style = MaterialTheme.typography.bodyMedium, - color = dayTextColor, - fontWeight = if (isToday && inMonth) FontWeight.Bold else FontWeight.Normal, - ) + // Connecting band: one continuous capsule at token height, drawn edge-to-edge + // so neighbouring cells fuse into a single stadium. It is rounded only at the + // fast's true start/end; middle days (and week wraps) run flush to the edge. + if (cover != null && !cover.isSingle) { + val bandShape = when { + cover.isStart -> RoundedCornerShape(topStartPercent = 50, bottomStartPercent = 50) + cover.isEnd -> RoundedCornerShape(topEndPercent = 50, bottomEndPercent = 50) + else -> RectangleShape + } + val bandAlign = when { + cover.isStart -> Alignment.CenterEnd + cover.isEnd -> Alignment.CenterStart + else -> Alignment.Center + } + // Endpoint cells inset the outer side so the rounded cap sits under the + // token circle; middle cells span the full width to bridge the gap. + val bandWidth = if (cover.isStart || cover.isEnd) 0.90f else 1f + Box( + modifier = Modifier + .align(bandAlign) + .fillMaxWidth(bandWidth) + .fillMaxHeight(DAY_TOKEN_FRACTION) + .clip(bandShape) + .background(bandColor), + ) + } + + // The day token: a filled circle at range endpoints (and single-day fasts), + // plus the today/selected ring, with the date number on top. + Box( + modifier = Modifier + .fillMaxSize(DAY_TOKEN_FRACTION) + .clip(CircleShape) + .background(endpointFill, CircleShape) + .border(borderWidth, borderColor, CircleShape), + contentAlignment = Alignment.Center, + ) { + Text( + text = day.date.dayOfMonth.toString(), + style = MaterialTheme.typography.bodyMedium, + color = dayTextColor, + fontWeight = if (isToday && inMonth) FontWeight.Bold else FontWeight.Normal, + ) + } } } +// The day circle / band height as a fraction of the square cell, so the capsule +// endpoints and connector share one diameter (a clean stadium). +private const val DAY_TOKEN_FRACTION = 0.80f + +/** Per-day coverage precomputed for the visible entries. */ +private class CalendarCoverage( + val byDay: Map>, + val endDateById: Map, +) + +/** Where a given day sits within the fast that covers it. */ +private data class DayBand( + val color: Color, + val isStart: Boolean, + val isEnd: Boolean, + val isSingle: Boolean, +) + +// Calm, desaturated calendar tones per phase — warmth/renewal rather than alarm +// (glucose → fat burn → ketosis → autophagy → optimal). Applied at low opacity, so +// they read as an accomplished wash, not a warning. One hue per fast. +private val calendarStageColors = listOf( + Color(0xFF9AA7B3), // Glucose — quiet slate + Color(0xFF6FBF8B), // Fat burn — soft green + Color(0xFFE0A94E), // Ketosis — warm amber + Color(0xFFE08A6B), // Autophagy — soft coral + Color(0xFFB98AD8), // Optimal autophagy — soft violet +) + @ExperimentalTime private fun stageColorFor(entries: List): Color { val longest = entries.maxByOrNull { it.length } ?: return Color.Transparent val lenHours = longest.length.toDouble(DurationUnit.HOURS) val stage = Stages.phases.lastOrNull { lenHours >= it.hours } ?: Stages.phases.first() val stageIndex = Stages.phases.indexOf(stage).coerceAtLeast(0) - return gaugeColors.getOrElse(stageIndex) { Color.Transparent } + return calendarStageColors.getOrElse(stageIndex) { calendarStageColors.last() } } diff --git a/app/src/main/java/com/darkrockstudios/apps/fasttrack/screens/log/LogScreen.Preview.kt b/app/src/main/java/com/darkrockstudios/apps/fasttrack/screens/log/LogScreen.Preview.kt index c6dbc079..d3946765 100644 --- a/app/src/main/java/com/darkrockstudios/apps/fasttrack/screens/log/LogScreen.Preview.kt +++ b/app/src/main/java/com/darkrockstudios/apps/fasttrack/screens/log/LogScreen.Preview.kt @@ -269,4 +269,10 @@ class FakeLogViewModel(state: ILogViewModel.LogUiState) : ILogViewModel { override fun loadEntries() {} override fun setViewMode(mode: LogViewMode) {} override fun selectDate(date: LocalDate?) {} + override fun requestClearAll() {} + override fun dismissClearAll() {} + override fun clearAll() {} + override fun requestAddForDate(date: LocalDate) {} + override fun dismissAddForDate() {} + override fun confirmAddForDate() {} } diff --git a/app/src/main/java/com/darkrockstudios/apps/fasttrack/screens/log/LogScreen.kt b/app/src/main/java/com/darkrockstudios/apps/fasttrack/screens/log/LogScreen.kt index 0720a495..84663fc8 100644 --- a/app/src/main/java/com/darkrockstudios/apps/fasttrack/screens/log/LogScreen.kt +++ b/app/src/main/java/com/darkrockstudios/apps/fasttrack/screens/log/LogScreen.kt @@ -1,16 +1,26 @@ package com.darkrockstudios.apps.fasttrack.screens.log +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ViewList import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.CalendarMonth +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.WarningAmber import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.window.DialogProperties import androidx.lifecycle.Lifecycle @@ -20,8 +30,13 @@ import com.darkrockstudios.apps.fasttrack.R import com.darkrockstudios.apps.fasttrack.data.log.FastingLogEntry import com.darkrockstudios.apps.fasttrack.data.settings.LogViewMode import com.darkrockstudios.apps.fasttrack.screens.log.manualadd.ManualAddDialog +import com.darkrockstudios.apps.fasttrack.utils.AppDateTime +import com.darkrockstudios.apps.fasttrack.utils.LocalDateStyle import com.darkrockstudios.apps.fasttrack.utils.MAX_COLUMN_WIDTH +import com.darkrockstudios.apps.fasttrack.utils.formatDuration import org.koin.compose.viewmodel.koinViewModel +import kotlin.time.Duration +import kotlin.time.Duration.Companion.hours import kotlin.time.ExperimentalTime @ExperimentalTime @@ -68,6 +83,9 @@ fun LogScreen( .padding(top = contentPaddingValues.calculateTopPadding()) ) { LogStatsHeader( + totalFasts = uiState.totalFasts, + totalFastedDuration = uiState.totalFastedDuration, + longestFastDuration = uiState.longestFastDuration, totalKetosisHours = uiState.totalKetosisHours, totalAutophagyHours = uiState.totalAutophagyHours, viewMode = uiState.viewMode, @@ -86,6 +104,7 @@ fun LogScreen( entries = uiState.entries, selectedDate = uiState.selectedDate, onDateSelected = viewModel::selectDate, + onAddForEmptyDay = viewModel::requestAddForDate, onEdit = viewModel::showEditDialog, onDelete = { entryToDelete = it }, contentPadding = bodyContentPadding, @@ -113,75 +132,256 @@ fun LogScreen( if (uiState.showManualAddDialog) { ManualAddDialog( onDismiss = { viewModel.hideManualAddDialog() }, - entryToEdit = uiState.entryToEdit + entryToEdit = uiState.entryToEdit, + initialDate = uiState.manualAddInitialDate, + ) + } + + uiState.emptyDayToAdd?.let { date -> + AddFastForDayDialog( + date = date, + onConfirm = { viewModel.confirmAddForDate() }, + onDismiss = { viewModel.dismissAddForDate() }, + ) + } + + if (uiState.showClearAllConfirmation) { + ClearLogbookSheet( + count = uiState.totalFasts, + onConfirm = { viewModel.clearAll() }, + onDismiss = { viewModel.dismissClearAll() }, ) } } } +/** + * Gentle confirmation shown when an empty (past/today) calendar day is tapped: it + * offers to open Manual Add preseeded with that day, rather than silently doing + * nothing. + */ +@Composable +private fun AddFastForDayDialog( + date: kotlinx.datetime.LocalDate, + onConfirm: () -> Unit, + onDismiss: () -> Unit, +) { + val dateStyle = LocalDateStyle.current + val label = remember(date, dateStyle) { + AppDateTime.formatDate(date, dateStyle) + } + AlertDialog( + onDismissRequest = onDismiss, + icon = { Icon(imageVector = Icons.Default.Add, contentDescription = null) }, + title = { Text(text = stringResource(id = R.string.add_fast_for_day_title, label)) }, + confirmButton = { + TextButton(onClick = onConfirm) { + Text(stringResource(id = R.string.add_fast_for_day_confirm)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(id = R.string.cancel_button)) + } + }, + ) +} + +/** + * Danger confirmation for wiping the whole logbook. Deliberately heavy: an + * error-toned warning glyph, an explicit "cannot be undone", the exact count in + * both the message and the destructive button, and Cancel as the calm way out. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ClearLogbookSheet( + count: Int, + onConfirm: () -> Unit, + onDismiss: () -> Unit, +) { + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = rememberModalBottomSheetState(), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 21.dp) + .padding(bottom = 34.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Box( + modifier = Modifier + .size(56.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.errorContainer), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Default.WarningAmber, + contentDescription = null, + tint = MaterialTheme.colorScheme.onErrorContainer, + modifier = Modifier.size(30.dp), + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = stringResource(id = R.string.clear_logbook_title), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurface, + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(id = R.string.clear_logbook_message, count), + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(modifier = Modifier.height(24.dp)) + Button( + onClick = onConfirm, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + contentColor = MaterialTheme.colorScheme.onError, + ), + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 55.dp), + ) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = null, + modifier = Modifier.padding(end = 8.dp), + ) + Text( + text = stringResource(id = R.string.clear_logbook_confirm, count), + style = MaterialTheme.typography.titleMedium, + ) + } + Spacer(modifier = Modifier.height(8.dp)) + TextButton(onClick = onDismiss, modifier = Modifier.fillMaxWidth()) { + Text(stringResource(id = R.string.cancel_button)) + } + } + } +} + @Composable private fun LogStatsHeader( + totalFasts: Int, + totalFastedDuration: Duration, + longestFastDuration: Duration, totalKetosisHours: Int, totalAutophagyHours: Int, viewMode: LogViewMode, onViewModeChanged: (LogViewMode) -> Unit, ) { + val context = LocalContext.current + + // Local, non-persisted format toggle: durations default to "39d 22h"; + // tapping any stat card flips them to total hours ("432h 40m") for as long + // as this screen stays composed, then returns to the default next time. + var showTotalHours by remember { mutableStateOf(false) } + val toggle = { showTotalHours = !showTotalHours } + + fun durationStat(d: Duration): String = + if (d == Duration.ZERO) "0h" + else formatDuration(context, d, showTotalHours = showTotalHours) + + // View mode switch, left-aligned (the "Lifetime stats" label was redundant) + LogViewModeSwitch( + viewMode = viewMode, + onViewModeChanged = onViewModeChanged, + modifier = Modifier.padding(vertical = 12.dp), + ) + Row( modifier = Modifier .fillMaxWidth() - .padding(vertical = 16.dp), - verticalAlignment = Alignment.CenterVertically, + .padding(bottom = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp) ) { - Text( - stringResource(R.string.log_stats_label), - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.weight(1f), + StatCard( + title = stringResource(id = R.string.stat_total_fasts), + valueText = totalFasts.toString(), + onClick = toggle, + modifier = Modifier.weight(1f) ) - LogViewModeIconToggle( - viewMode = viewMode, - onViewModeChanged = onViewModeChanged, + StatCard( + title = stringResource(id = R.string.stat_total_fasted), + valueText = durationStat(totalFastedDuration), + onClick = toggle, + modifier = Modifier.weight(1f) + ) + StatCard( + title = stringResource(id = R.string.stat_longest_fast), + valueText = durationStat(longestFastDuration), + onClick = toggle, + modifier = Modifier.weight(1f) ) } Row( modifier = Modifier .fillMaxWidth() .padding(bottom = 16.dp), - horizontalArrangement = Arrangement.spacedBy(12.dp) + horizontalArrangement = Arrangement.spacedBy(8.dp) ) { StatCard( title = stringResource(id = R.string.log_total_ketosis), - valueText = stringResource(id = R.string.log_total_hours, totalKetosisHours), + valueText = formatDuration(context, totalKetosisHours.hours, showTotalHours = showTotalHours, withMinutes = false), + onClick = toggle, modifier = Modifier.weight(1f) ) StatCard( title = stringResource(id = R.string.log_total_autophagy), - valueText = stringResource(id = R.string.log_total_hours, totalAutophagyHours), + valueText = formatDuration(context, totalAutophagyHours.hours, showTotalHours = showTotalHours, withMinutes = false), + onClick = toggle, modifier = Modifier.weight(1f) ) } } +/** + * Two-option segmented control for the Log view mode. Both modes are always + * visible with the active one highlighted, so switching back and forth is + * obvious (the old single icon-only toggle was too easy to overlook). + */ @Composable -private fun LogViewModeIconToggle( +private fun LogViewModeSwitch( viewMode: LogViewMode, onViewModeChanged: (LogViewMode) -> Unit, + modifier: Modifier = Modifier, ) { - val isCalendar = viewMode == LogViewMode.CALENDAR - val nextMode = if (isCalendar) LogViewMode.LIST else LogViewMode.CALENDAR - val (icon, labelRes) = if (isCalendar) { - Icons.AutoMirrored.Filled.ViewList to R.string.log_view_mode_list - } else { - Icons.Default.CalendarMonth to R.string.log_view_mode_calendar - } - IconButton( - onClick = { onViewModeChanged(nextMode) }, - modifier = Modifier.size(28.dp), - ) { - Icon( - imageVector = icon, - contentDescription = stringResource(id = labelRes), - tint = MaterialTheme.colorScheme.onSurfaceVariant, + SingleChoiceSegmentedButtonRow(modifier = modifier) { + SegmentedButton( + selected = viewMode == LogViewMode.LIST, + onClick = { onViewModeChanged(LogViewMode.LIST) }, + shape = SegmentedButtonDefaults.itemShape(index = 0, count = 2), + icon = { + Icon( + imageVector = Icons.AutoMirrored.Filled.ViewList, + contentDescription = null, + modifier = Modifier.size(SegmentedButtonDefaults.IconSize), + ) + }, + label = { Text(stringResource(id = R.string.log_view_mode_list)) }, + ) + SegmentedButton( + selected = viewMode == LogViewMode.CALENDAR, + onClick = { onViewModeChanged(LogViewMode.CALENDAR) }, + shape = SegmentedButtonDefaults.itemShape(index = 1, count = 2), + icon = { + Icon( + imageVector = Icons.Default.CalendarMonth, + contentDescription = null, + modifier = Modifier.size(SegmentedButtonDefaults.IconSize), + ) + }, + label = { Text(stringResource(id = R.string.log_view_mode_calendar)) }, ) } } @@ -229,25 +429,30 @@ private fun StatCard( title: String, valueText: String, modifier: Modifier = Modifier, + onClick: (() -> Unit)? = null, ) { ElevatedCard( - modifier = modifier, + modifier = if (onClick != null) modifier.clickable(onClick = onClick) else modifier, ) { Column( modifier = Modifier .fillMaxWidth() - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(6.dp) + .padding(horizontal = 10.dp, vertical = 10.dp), + verticalArrangement = Arrangement.spacedBy(2.dp) ) { Text( text = title, - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, ) Text( text = valueText, - style = MaterialTheme.typography.headlineMedium, - color = MaterialTheme.colorScheme.onSurface + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, ) } } diff --git a/app/src/main/java/com/darkrockstudios/apps/fasttrack/screens/log/LogViewModel.kt b/app/src/main/java/com/darkrockstudios/apps/fasttrack/screens/log/LogViewModel.kt index 8a057c13..e4831839 100644 --- a/app/src/main/java/com/darkrockstudios/apps/fasttrack/screens/log/LogViewModel.kt +++ b/app/src/main/java/com/darkrockstudios/apps/fasttrack/screens/log/LogViewModel.kt @@ -3,7 +3,8 @@ package com.darkrockstudios.apps.fasttrack.screens.log import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.darkrockstudios.apps.fasttrack.data.Stages +import com.darkrockstudios.apps.fasttrack.data.autophagyHours +import com.darkrockstudios.apps.fasttrack.data.ketosisHours import com.darkrockstudios.apps.fasttrack.data.log.FastingLogEntry import com.darkrockstudios.apps.fasttrack.data.log.FastingLogRepository import com.darkrockstudios.apps.fasttrack.data.settings.LogViewMode @@ -16,7 +17,7 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.datetime.LocalDate import kotlin.math.roundToInt -import kotlin.time.DurationUnit +import kotlin.time.Duration import kotlin.time.ExperimentalTime @ExperimentalTime @@ -39,48 +40,35 @@ class LogViewModel( } private fun updateEntries(entries: List) { - val totalKetosisHours = entries.sumOf { calculateKetosis(it) }.roundToInt() - val totalAutophagyHours = entries.sumOf { calculateAutophagy(it) }.roundToInt() + val totalKetosisHours = entries.sumOf { ketosisHours(it.length) }.roundToInt() + val totalAutophagyHours = entries.sumOf { autophagyHours(it.length) }.roundToInt() + val totalFastedDuration = entries.fold(Duration.ZERO) { acc, e -> acc + e.length } + val longestFastDuration = entries.maxOfOrNull { it.length } ?: Duration.ZERO _uiState.update { currentState -> currentState.copy( entries = entries.sortedByDescending { it.start }, totalKetosisHours = totalKetosisHours, - totalAutophagyHours = totalAutophagyHours + totalAutophagyHours = totalAutophagyHours, + totalFasts = entries.size, + totalFastedDuration = totalFastedDuration, + longestFastDuration = longestFastDuration, ) } } - private fun calculateKetosis(entry: FastingLogEntry): Double { - val ketosisStart = Stages.PHASE_KETOSIS.hours.toDouble() - val lenHours = entry.length.toDouble(DurationUnit.HOURS) - return if (lenHours > ketosisStart) { - lenHours - ketosisStart - } else { - 0.0 - } - } - - private fun calculateAutophagy(entry: FastingLogEntry): Double { - val autophagyStart = Stages.PHASE_AUTOPHAGY.hours.toDouble() - val lenHours = entry.length.toDouble(DurationUnit.HOURS) - return if (lenHours > autophagyStart) { - lenHours - autophagyStart - } else { - 0.0 - } - } - override fun deleteFast(item: FastingLogEntry) { viewModelScope.launch(Dispatchers.IO) { - if (repository.delete(item)) { + if (!repository.delete(item)) { Log.w("LogViewModel", "Failed to delete Fast: $item") } } } override fun showManualAddDialog() { - _uiState.update { it.copy(showManualAddDialog = true, entryToEdit = null) } + _uiState.update { + it.copy(showManualAddDialog = true, entryToEdit = null, manualAddInitialDate = null) + } } override fun showEditDialog(entry: FastingLogEntry) { @@ -88,7 +76,9 @@ class LogViewModel( } override fun hideManualAddDialog() { - _uiState.update { it.copy(showManualAddDialog = false, entryToEdit = null) } + _uiState.update { + it.copy(showManualAddDialog = false, entryToEdit = null, manualAddInitialDate = null) + } } override fun setViewMode(mode: LogViewMode) { @@ -99,4 +89,39 @@ class LogViewModel( override fun selectDate(date: LocalDate?) { _uiState.update { it.copy(selectedDate = date) } } + + override fun requestClearAll() { + _uiState.update { it.copy(showClearAllConfirmation = true) } + } + + override fun dismissClearAll() { + _uiState.update { it.copy(showClearAllConfirmation = false) } + } + + override fun clearAll() { + _uiState.update { it.copy(showClearAllConfirmation = false) } + viewModelScope.launch(Dispatchers.IO) { + repository.deleteAllEntries() + // The loadAll() flow emits the now-empty list and refreshes the stats. + } + } + + override fun requestAddForDate(date: LocalDate) { + _uiState.update { it.copy(emptyDayToAdd = date) } + } + + override fun dismissAddForDate() { + _uiState.update { it.copy(emptyDayToAdd = null) } + } + + override fun confirmAddForDate() { + _uiState.update { + it.copy( + showManualAddDialog = true, + entryToEdit = null, + manualAddInitialDate = it.emptyDayToAdd, + emptyDayToAdd = null, + ) + } + } } diff --git a/app/src/main/java/com/darkrockstudios/apps/fasttrack/screens/log/manualadd/IManualAddViewModel.kt b/app/src/main/java/com/darkrockstudios/apps/fasttrack/screens/log/manualadd/IManualAddViewModel.kt index 67fafd58..b8a4c81a 100644 --- a/app/src/main/java/com/darkrockstudios/apps/fasttrack/screens/log/manualadd/IManualAddViewModel.kt +++ b/app/src/main/java/com/darkrockstudios/apps/fasttrack/screens/log/manualadd/IManualAddViewModel.kt @@ -7,26 +7,30 @@ import kotlinx.datetime.LocalDateTime import kotlinx.datetime.TimeZone import kotlinx.datetime.toInstant import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.minutes import kotlin.time.Instant interface IManualAddViewModel { data class ManualAddUiState( - val currentStep: ManualAddStep = ManualAddStep.StartDate, - val selectedDate: LocalDate? = null, + val currentStep: ManualAddStep = ManualAddStep.StartDate, + val selectedDate: LocalDate? = null, val selectedDateTime: LocalDateTime? = null, - val lengthHours: String = "", - val isNextButtonEnabled: Boolean = true, - val isCompleteButtonEnabled: Boolean = false, - val entryToEdit: FastingLogEntry? = null + val lengthHours: String = "", + val lengthMinutes: String = "", + val notes: String = "", + val isNextButtonEnabled: Boolean = true, + val isCompleteButtonEnabled: Boolean = false, + val entryToEdit: FastingLogEntry? = null ) { fun end(): Instant? { return selectedDateTime?.let { start -> - val lengthHours = lengthHours.toLongOrNull() ?: 0 - if (lengthHours > 0) { + val h = lengthHours.toLongOrNull() ?: 0 + val m = lengthMinutes.toLongOrNull() ?: 0 + val length = h.hours + m.minutes + if (length.inWholeMinutes > 0) { val startInstant = start.toInstant(TimeZone.currentSystemDefault()) - val durationMillis = lengthHours.hours.inWholeMilliseconds Instant.fromEpochMilliseconds( - startInstant.toEpochMilliseconds() + durationMillis + startInstant.toEpochMilliseconds() + length.inWholeMilliseconds ) } else { null @@ -40,10 +44,13 @@ interface IManualAddViewModel { fun onDateSelected(dateTimestamp: Long) fun onTimeSelected(hour: Int, minute: Int) fun onLengthChanged(length: String) + fun onMinutesChanged(minutes: String) + fun onNotesChanged(notes: String) fun onEndDateTimeSelected(instant: Instant) fun onAddEntry(): Boolean fun onDismiss() fun initializeWithEntry(entry: FastingLogEntry) + fun initializeWithDate(date: LocalDate) fun onPreviousStep() fun goToStep(step: ManualAddStep) } diff --git a/app/src/main/java/com/darkrockstudios/apps/fasttrack/screens/log/manualadd/ManualAddDialog.Preview.kt b/app/src/main/java/com/darkrockstudios/apps/fasttrack/screens/log/manualadd/ManualAddDialog.Preview.kt index f69db167..6373e5fb 100644 --- a/app/src/main/java/com/darkrockstudios/apps/fasttrack/screens/log/manualadd/ManualAddDialog.Preview.kt +++ b/app/src/main/java/com/darkrockstudios/apps/fasttrack/screens/log/manualadd/ManualAddDialog.Preview.kt @@ -17,10 +17,13 @@ class FakeManualAddViewModel(initialState: IManualAddViewModel.ManualAddUiState override fun onDateSelected(dateTimestamp: Long) {} override fun onTimeSelected(hour: Int, minute: Int) {} override fun onLengthChanged(length: String) {} + override fun onMinutesChanged(minutes: String) {} + override fun onNotesChanged(notes: String) {} override fun onEndDateTimeSelected(instant: Instant) {} override fun onAddEntry() = true override fun onDismiss() {} override fun initializeWithEntry(entry: FastingLogEntry) {} + override fun initializeWithDate(date: LocalDate) {} override fun onPreviousStep() {} override fun goToStep(step: ManualAddStep) {} } @@ -67,6 +70,7 @@ fun ManualAddDialogPreviewStep2() { currentStep = ManualAddStep.SetDuration, selectedDateTime = selectedDateTime, lengthHours = "16", + notes = "Felt great, easy morning.", isCompleteButtonEnabled = true ) ) diff --git a/app/src/main/java/com/darkrockstudios/apps/fasttrack/screens/log/manualadd/ManualAddDialog.kt b/app/src/main/java/com/darkrockstudios/apps/fasttrack/screens/log/manualadd/ManualAddDialog.kt index 8eaff92e..446cc9fa 100644 --- a/app/src/main/java/com/darkrockstudios/apps/fasttrack/screens/log/manualadd/ManualAddDialog.kt +++ b/app/src/main/java/com/darkrockstudios/apps/fasttrack/screens/log/manualadd/ManualAddDialog.kt @@ -1,290 +1,430 @@ package com.darkrockstudios.apps.fasttrack.screens.log.manualadd +import androidx.compose.foundation.background 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.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.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Edit -import androidx.compose.material3.* -import androidx.compose.runtime.* +import androidx.compose.material3.Button +import androidx.compose.material3.DatePicker +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TimePicker +import androidx.compose.material3.rememberDatePickerState +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.material3.rememberTimePickerState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Dialog -import androidx.compose.ui.window.DialogProperties import com.darkrockstudios.apps.fasttrack.R +import com.darkrockstudios.apps.fasttrack.data.log.FastingLogEntry import com.darkrockstudios.apps.fasttrack.screens.fasting.DateTimePickerDialog import com.darkrockstudios.apps.fasttrack.screens.fasting.rememberDateTimePickerDialogState import com.darkrockstudios.apps.fasttrack.screens.preview.getContext +import com.darkrockstudios.apps.fasttrack.utils.AppDateTime +import com.darkrockstudios.apps.fasttrack.utils.LocalDateStyle import com.darkrockstudios.apps.fasttrack.utils.PastAndTodaySelectableDates -import com.darkrockstudios.apps.fasttrack.utils.formatAs import com.darkrockstudios.apps.fasttrack.utils.shouldUse24HourFormat +import kotlinx.datetime.LocalDate import kotlinx.datetime.TimeZone import kotlinx.datetime.atStartOfDayIn import kotlinx.datetime.toInstant import org.koin.compose.viewmodel.koinViewModel import kotlin.time.ExperimentalTime -@OptIn(ExperimentalTime::class) +// ---- Golden-ratio / Fibonacci proportion system --------------------------- +// A single Fibonacci ladder (5, 8, 13, 21, 34) governs every gap, inset, and +// corner radius, so spacings relate to their neighbours by ~phi at every scale +// — a self-similar (fractal) rhythm. Touch targets sit on the 48/55 rungs so the +// visual harmony never costs ergonomics. +private val GapXs = 5.dp +private val GapS = 8.dp +private val GapM = 13.dp +private val GapL = 21.dp +private val GapXl = 34.dp +private const val PHI = 1.618f +private val FieldHeight = 55.dp // Fibonacci rung, comfortably above the 48dp a11y floor +private val RadiusField = 8.dp // nested radii descend the same ladder: field 8 < group 13 +private val RadiusGroup = 13.dp +private val SheetContentMaxWidth = 610.dp // 377 * phi — caps line length on tablets + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalTime::class) @Composable fun ManualAddDialog( onDismiss: () -> Unit, - entryToEdit: com.darkrockstudios.apps.fasttrack.data.log.FastingLogEntry? = null, + entryToEdit: FastingLogEntry? = null, + initialDate: LocalDate? = null, viewModel: IManualAddViewModel = koinViewModel() ) { - // Initialize with entry if editing - LaunchedEffect(entryToEdit) { - entryToEdit?.let { viewModel.initializeWithEntry(it) } + // Seed the flow: an existing entry to edit, or a preselected start date + // (e.g. an empty calendar day the user chose to log). + LaunchedEffect(entryToEdit, initialDate) { + when { + entryToEdit != null -> viewModel.initializeWithEntry(entryToEdit) + initialDate != null -> viewModel.initializeWithDate(initialDate) + } } val uiState by viewModel.uiState.collectAsState() val use24Hour = shouldUse24HourFormat(getContext()) var showEndDateTimePicker by remember { mutableStateOf(false) } + // Open fully: the date/time pickers are tall, so a half-height sheet would clip. + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + + fun dismiss() { + viewModel.onDismiss() + onDismiss() + } - Dialog( - onDismissRequest = { - viewModel.onDismiss() - onDismiss() - }, - properties = DialogProperties(usePlatformDefaultWidth = false), + ModalBottomSheet( + onDismissRequest = { dismiss() }, + sheetState = sheetState, ) { - Card( + Column( modifier = Modifier - .widthIn(max = 600.dp) - .heightIn(max = 800.dp) + .align(Alignment.CenterHorizontally) + .widthIn(max = SheetContentMaxWidth) + .fillMaxWidth() + .imePadding() .verticalScroll(rememberScrollState()) + .padding(horizontal = GapL) + .padding(bottom = GapXl), ) { - Column( - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally - ) { - // Header with title and close button - Row( - modifier = Modifier - .padding(horizontal = 16.dp) - .fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = stringResource( - id = if (entryToEdit != null) R.string.manual_edit_title else R.string.manual_add_title - ), - style = MaterialTheme.typography.headlineSmall - ) - IconButton(onClick = { - viewModel.onDismiss() - onDismiss() - }) { - Icon( - imageVector = Icons.Default.Close, - contentDescription = stringResource(id = R.string.close_button_content_description) - ) - } - } + // Header: title + a golden-emphasis step meter (active segment phi x wider). + Text( + text = stringResource( + id = if (entryToEdit != null) R.string.manual_edit_title else R.string.manual_add_title + ), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + ) + Spacer(modifier = Modifier.height(GapM)) + StepProgress( + stepIndex = uiState.currentStep.ordinal, + stepCount = ManualAddStep.entries.size, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(modifier = Modifier.height(GapL)) - // Initialize picker states with existing values when editing - val initialDateMillis = - uiState.selectedDate?.atStartOfDayIn(TimeZone.currentSystemDefault())?.toEpochMilliseconds() + // Initialize picker states with existing values when editing. + // Material's DatePicker expects UTC-midnight millis, so anchor the + // preselected day in UTC (system zone would preselect the wrong day). + val initialDateMillis = + uiState.selectedDate?.atStartOfDayIn(TimeZone.UTC)?.toEpochMilliseconds() - val datePickerState = rememberDatePickerState( + // Key on the initial date so a preselected day (arriving after first + // composition) actually shows as selected in the picker. + val datePickerState = key(initialDateMillis) { + rememberDatePickerState( initialSelectedDateMillis = initialDateMillis, selectableDates = PastAndTodaySelectableDates() ) + } - val initialHour = uiState.selectedDateTime?.hour ?: 0 - val initialMinute = uiState.selectedDateTime?.minute ?: 0 - val timePickerState = rememberTimePickerState( - initialHour = initialHour, - initialMinute = initialMinute, - is24Hour = use24Hour, - ) + val timePickerState = rememberTimePickerState( + initialHour = uiState.selectedDateTime?.hour ?: 0, + initialMinute = uiState.selectedDateTime?.minute ?: 0, + is24Hour = use24Hour, + ) - when (uiState.currentStep) { - ManualAddStep.StartDate -> { - DatePicker(datePickerState) - } + when (uiState.currentStep) { + ManualAddStep.StartDate -> { + DatePicker(state = datePickerState, modifier = Modifier.fillMaxWidth()) + } - ManualAddStep.StartTime -> { - TimePicker(timePickerState) + ManualAddStep.StartTime -> { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + ) { + TimePicker(state = timePickerState) } + } - ManualAddStep.SetDuration -> { - // Length Input - Column(modifier = Modifier.padding(16.dp)) { - // Show summary of selections with clickable edit options - uiState.selectedDateTime?.let { dateTime -> - Text( - text = stringResource(id = R.string.manual_add_start_date_time_label), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - - Spacer(modifier = Modifier.height(4.dp)) - - // Date row - clickable - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { viewModel.goToStep(ManualAddStep.StartDate) } - .padding(vertical = 8.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = "${dateTime.date}", - style = MaterialTheme.typography.bodyLarge - ) - Icon( - imageVector = Icons.Default.Edit, - contentDescription = stringResource(id = R.string.edit_date), - modifier = Modifier.size(20.dp), - tint = MaterialTheme.colorScheme.primary - ) - } - - HorizontalDivider() - - // Time row - clickable - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { viewModel.goToStep(ManualAddStep.StartTime) } - .padding(vertical = 8.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = dateTime.formatAs(if (use24Hour) "HH:mm" else "h:mm a"), - style = MaterialTheme.typography.bodyLarge - ) - Icon( - imageVector = Icons.Default.Edit, - contentDescription = stringResource(id = R.string.edit_time), - modifier = Modifier.size(20.dp), - tint = MaterialTheme.colorScheme.primary - ) - } - - HorizontalDivider() + ManualAddStep.SetDuration -> { + DurationStep( + uiState = uiState, + use24Hour = use24Hour, + onEditDate = { viewModel.goToStep(ManualAddStep.StartDate) }, + onEditTime = { viewModel.goToStep(ManualAddStep.StartTime) }, + onHoursChanged = viewModel::onLengthChanged, + onMinutesChanged = viewModel::onMinutesChanged, + onNotesChanged = viewModel::onNotesChanged, + onCalculateFromEnd = { showEndDateTimePicker = true }, + ) + } + } - Spacer(modifier = Modifier.height(16.dp)) - } + Spacer(modifier = Modifier.height(GapL)) - OutlinedTextField( - value = uiState.lengthHours, - onValueChange = { viewModel.onLengthChanged(it) }, - label = { Text(stringResource(id = R.string.manual_add_length_hint)) }, - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Number - ), - modifier = Modifier.fillMaxWidth() - ) + ActionBar( + currentStep = uiState.currentStep, + isEditing = entryToEdit != null, + nextEnabled = if (uiState.currentStep.isFinalStep.not()) { + uiState.isNextButtonEnabled + } else { + uiState.isCompleteButtonEnabled + }, + onPrevious = { viewModel.onPreviousStep() }, + onCancel = { dismiss() }, + onNextOrSave = { + when (uiState.currentStep) { + ManualAddStep.StartDate -> + datePickerState.selectedDateMillis?.let { viewModel.onDateSelected(it) } - Spacer(modifier = Modifier.height(8.dp)) + ManualAddStep.StartTime -> + viewModel.onTimeSelected(timePickerState.hour, timePickerState.minute) - // Button to calculate length from end date/time - Button( - onClick = { showEndDateTimePicker = true }, - ) { - Text(stringResource(id = R.string.manual_add_calculate_from_end)) - } + ManualAddStep.SetDuration -> + if (viewModel.onAddEntry()) dismiss() + } + }, + ) + } + } - if (showEndDateTimePicker) { - val dateTimePickerState = rememberDateTimePickerDialogState() - val initialEndInstant = uiState.end() - val minStartInstant = - uiState.selectedDateTime?.toInstant(TimeZone.currentSystemDefault()) + if (showEndDateTimePicker) { + val dateTimePickerState = rememberDateTimePickerDialogState() + val minStartInstant = uiState.selectedDateTime?.toInstant(TimeZone.currentSystemDefault()) + DateTimePickerDialog( + onDismiss = { showEndDateTimePicker = false }, + onDateTimeSelected = { instant -> + viewModel.onEndDateTimeSelected(instant) + showEndDateTimePicker = false + }, + title = stringResource(R.string.manual_add_set_end_title), + finishButton = stringResource(id = R.string.manual_add_set_end_complete), + state = dateTimePickerState, + initialInstant = uiState.end(), + minInstant = minStartInstant + ) + } +} - DateTimePickerDialog( - onDismiss = { showEndDateTimePicker = false }, - onDateTimeSelected = { instant -> - viewModel.onEndDateTimeSelected(instant) - showEndDateTimePicker = false - }, - title = stringResource(R.string.manual_add_set_end_title), - finishButton = stringResource(id = R.string.manual_add_set_end_complete), - state = dateTimePickerState, - initialInstant = initialEndInstant, - minInstant = minStartInstant - ) - } +/** + * A slim, three-segment progress meter. The active step's segment is phi times + * wider than the rest — a golden accent that reads as "you are here" at a glance, + * shrinking the uncertainty that drives wizard drop-off. + */ +@Composable +private fun StepProgress(stepIndex: Int, stepCount: Int, modifier: Modifier = Modifier) { + Row(modifier = modifier, horizontalArrangement = Arrangement.spacedBy(GapXs)) { + val primary = MaterialTheme.colorScheme.primary + val track = MaterialTheme.colorScheme.surfaceVariant + repeat(stepCount) { i -> + Box( + modifier = Modifier + .weight(if (i == stepIndex) PHI else 1f) + .height(GapXs) + .clip(RoundedCornerShape(GapXs)) + .background( + when { + i == stepIndex -> primary + i < stepIndex -> primary.copy(alpha = 0.45f) + else -> track } - } - } + ) + ) + } + } +} - // Buttons - Row( - modifier = Modifier - .padding(16.dp) - .fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween - ) { - // Left side: Previous button (only show if not on first step) - if (uiState.currentStep != ManualAddStep.StartDate) { - TextButton(onClick = { - viewModel.onPreviousStep() - }) { - Text(stringResource(id = R.string.previous_button)) - } - } else { - Spacer(modifier = Modifier.width(1.dp)) // Placeholder for alignment - } +@OptIn(ExperimentalTime::class) +@Composable +private fun DurationStep( + uiState: IManualAddViewModel.ManualAddUiState, + use24Hour: Boolean, + onEditDate: () -> Unit, + onEditTime: () -> Unit, + onHoursChanged: (String) -> Unit, + onMinutesChanged: (String) -> Unit, + onNotesChanged: (String) -> Unit, + onCalculateFromEnd: () -> Unit, +) { + val dateStyle = LocalDateStyle.current + Column(modifier = Modifier.fillMaxWidth()) { + // Start summary — tap either row to jump back and edit that step. + uiState.selectedDateTime?.let { dateTime -> + Text( + text = stringResource(id = R.string.manual_add_start_date_time_label), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(GapXs)) + EditableSummaryRow( + text = AppDateTime.formatDate(dateTime, dateStyle), + editContentDescription = R.string.edit_date, + onClick = onEditDate, + ) + HorizontalDivider() + EditableSummaryRow( + text = AppDateTime.formatTime(dateTime, dateStyle, use24Hour), + editContentDescription = R.string.edit_time, + onClick = onEditTime, + ) + Spacer(modifier = Modifier.height(GapL)) + } - // Right side: Cancel and Next/Complete buttons - Row { - TextButton(onClick = { - viewModel.onDismiss() - onDismiss() - }) { - Text(stringResource(id = R.string.cancel_button)) - } + // Duration group: hours and minutes get equal weight — two peers of the + // same kind, so an even split reads faster than a golden one would. + Text( + text = stringResource(id = R.string.manual_add_duration_label), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(GapXs)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(GapM), + ) { + OutlinedTextField( + value = uiState.lengthHours, + onValueChange = onHoursChanged, + label = { Text(stringResource(id = R.string.manual_add_hours_hint)) }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number, imeAction = ImeAction.Next), + singleLine = true, + shape = RoundedCornerShape(RadiusField), + modifier = Modifier + .weight(1f) + .heightIn(min = FieldHeight), + ) + OutlinedTextField( + value = uiState.lengthMinutes, + onValueChange = onMinutesChanged, + label = { Text(stringResource(id = R.string.manual_add_minutes_hint)) }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number, imeAction = ImeAction.Next), + singleLine = true, + shape = RoundedCornerShape(RadiusField), + modifier = Modifier + .weight(1f) + .heightIn(min = FieldHeight), + ) + } - Spacer(modifier = Modifier.width(8.dp)) + Spacer(modifier = Modifier.height(GapS)) + TextButton(onClick = onCalculateFromEnd, modifier = Modifier.fillMaxWidth()) { + Text(stringResource(id = R.string.manual_add_calculate_from_end)) + } + + Spacer(modifier = Modifier.height(GapL)) + OutlinedTextField( + value = uiState.notes, + onValueChange = onNotesChanged, + label = { Text(stringResource(id = R.string.fast_notes_label)) }, + placeholder = { Text(stringResource(id = R.string.fast_notes_placeholder)) }, + keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences), + minLines = 2, + maxLines = 5, + shape = RoundedCornerShape(RadiusField), + modifier = Modifier.fillMaxWidth(), + ) + } +} - Button( - onClick = { - when (uiState.currentStep) { - ManualAddStep.StartDate -> { - datePickerState.selectedDateMillis?.let { ms -> - viewModel.onDateSelected(ms) - } - } +@Composable +private fun EditableSummaryRow( + text: String, + editContentDescription: Int, + onClick: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(RadiusGroup)) + .clickable(onClick = onClick) + .heightIn(min = 48.dp) + .padding(vertical = GapS), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text(text = text, style = MaterialTheme.typography.bodyLarge) + Icon( + imageVector = Icons.Default.Edit, + contentDescription = stringResource(id = editContentDescription), + modifier = Modifier.size(GapL), + tint = MaterialTheme.colorScheme.primary + ) + } +} - ManualAddStep.StartTime -> { - viewModel.onTimeSelected(timePickerState.hour, timePickerState.minute) - } +@Composable +private fun ActionBar( + currentStep: ManualAddStep, + isEditing: Boolean, + nextEnabled: Boolean, + onPrevious: () -> Unit, + onCancel: () -> Unit, + onNextOrSave: () -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + if (currentStep != ManualAddStep.StartDate) { + TextButton(onClick = onPrevious) { + Text(stringResource(id = R.string.previous_button)) + } + } else { + Spacer(modifier = Modifier.width(1.dp)) + } - ManualAddStep.SetDuration -> { - if (viewModel.onAddEntry()) { - viewModel.onDismiss() - onDismiss() - } - } - } - }, - enabled = if (uiState.currentStep.isFinalStep.not()) uiState.isNextButtonEnabled else uiState.isCompleteButtonEnabled - ) { - Text( - text = if (uiState.currentStep.isFinalStep.not()) { - stringResource(id = R.string.next_button) - } else { - // Show "Save" when editing, "Add" when creating new - if (entryToEdit != null) { - stringResource(id = R.string.manual_add_save_button) - } else { - stringResource(id = R.string.manual_add_complete_button) - } - } - ) - } + Row(verticalAlignment = Alignment.CenterVertically) { + TextButton(onClick = onCancel) { + Text(stringResource(id = R.string.cancel_button)) + } + Spacer(modifier = Modifier.width(GapS)) + Button( + onClick = onNextOrSave, + enabled = nextEnabled, + shape = RoundedCornerShape(RadiusGroup), + modifier = Modifier.heightIn(min = FieldHeight), + ) { + Text( + text = if (currentStep.isFinalStep.not()) { + stringResource(id = R.string.next_button) + } else if (isEditing) { + stringResource(id = R.string.manual_add_save_button) + } else { + stringResource(id = R.string.manual_add_complete_button) } - } + ) } } } diff --git a/app/src/main/java/com/darkrockstudios/apps/fasttrack/screens/log/manualadd/ManualAddViewModel.kt b/app/src/main/java/com/darkrockstudios/apps/fasttrack/screens/log/manualadd/ManualAddViewModel.kt index 620c8a86..f399ab42 100644 --- a/app/src/main/java/com/darkrockstudios/apps/fasttrack/screens/log/manualadd/ManualAddViewModel.kt +++ b/app/src/main/java/com/darkrockstudios/apps/fasttrack/screens/log/manualadd/ManualAddViewModel.kt @@ -4,16 +4,21 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.darkrockstudios.apps.fasttrack.data.log.FastingLogEntry import com.darkrockstudios.apps.fasttrack.data.log.FastingLogRepository -import io.github.aakira.napier.Napier.w import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import kotlinx.datetime.* +import kotlinx.datetime.LocalDate +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toInstant +import kotlinx.datetime.toLocalDateTime +import kotlin.time.Duration import kotlin.time.Duration.Companion.hours -import kotlin.time.DurationUnit +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Instant class ManualAddViewModel( private val repository: FastingLogRepository @@ -22,14 +27,22 @@ class ManualAddViewModel( private val _uiState = MutableStateFlow(IManualAddViewModel.ManualAddUiState()) override val uiState: StateFlow = _uiState.asStateFlow() + // A precise length that must be saved verbatim, minutes and all, because the + // duration field only shows whole hours. Set when editing an existing entry + // and when the length is computed from an end date/time; cleared only when + // the user types directly into the hours field (an explicit whole-hour value). + private var exactLength: Duration? = null + override fun onDateSelected(dateTimestamp: Long) { - val instant = Instant.fromEpochMilliseconds(dateTimestamp) - val localDateTime = instant.toLocalDateTime(TimeZone.currentSystemDefault()) + // Material's DatePicker reports the picked day as UTC-midnight millis, so + // read the calendar date back in UTC (using the system zone would shift the + // day by one for users far enough east/west of UTC). + val localDateTime = Instant.fromEpochMilliseconds(dateTimestamp).toLocalDateTime(TimeZone.UTC) val selectedDate = LocalDate( year = localDateTime.year, month = localDateTime.month, - dayOfMonth = localDateTime.dayOfMonth + day = localDateTime.day ) _uiState.update { currentState -> @@ -46,7 +59,7 @@ class ManualAddViewModel( val selectedDateTime = LocalDateTime( year = selectedDate.year, month = selectedDate.month, - dayOfMonth = selectedDate.dayOfMonth, + day = selectedDate.day, hour = hour, minute = minute, second = 0, @@ -62,37 +75,52 @@ class ManualAddViewModel( } override fun onLengthChanged(length: String) { - try { - val lengthValue = if (length.isNotEmpty()) length.toLong() else null - val isCompleteButtonEnabled = _uiState.value.selectedDateTime != null && - length.isNotEmpty() && - (length.toLongOrNull() ?: 0) > 0 - - _uiState.update { currentState -> - currentState.copy( - lengthHours = length, - isCompleteButtonEnabled = isCompleteButtonEnabled - ) - } - } catch (e: NumberFormatException) { - w("Failed to parse length input") + // The user is typing an exact hours+minutes value, so the fields now carry + // the full precision and any preserved length is superseded. + exactLength = null + _uiState.update { state -> + state.copy( + lengthHours = length, + isCompleteButtonEnabled = durationEntered(length, state.lengthMinutes, state.selectedDateTime), + ) } } - override fun onEndDateTimeSelected(instant: Instant) { - val currentState = _uiState.value - val startDateTime = currentState.selectedDateTime + override fun onMinutesChanged(minutes: String) { + exactLength = null + _uiState.update { state -> + state.copy( + lengthMinutes = minutes, + isCompleteButtonEnabled = durationEntered(state.lengthHours, minutes, state.selectedDateTime), + ) + } + } - if (startDateTime != null) { - val startInstant = startDateTime.toInstant(TimeZone.currentSystemDefault()) - val durationMillis = instant.toEpochMilliseconds() - startInstant.toEpochMilliseconds() + /** A savable duration is present when the start is set and hours+minutes > 0. */ + private fun durationEntered(hours: String, minutes: String, start: LocalDateTime?): Boolean { + val total = (hours.toLongOrNull() ?: 0).hours + (minutes.toLongOrNull() ?: 0).minutes + return start != null && total > Duration.ZERO + } - // Convert milliseconds to hours, rounded to nearest whole number - val hours = (durationMillis / (1000.0 * 60 * 60)).toLong() + override fun onNotesChanged(notes: String) { + _uiState.update { it.copy(notes = notes) } + } - // Only update if the end time is after the start time - if (hours > 0) { - onLengthChanged(hours.toString()) + override fun onEndDateTimeSelected(instant: Instant) { + val startDateTime = _uiState.value.selectedDateTime ?: return + val startInstant = startDateTime.toInstant(TimeZone.currentSystemDefault()) + val duration = instant.minus(startInstant) + + // Only accept an end after the start. Split the exact duration across the + // hours and minutes fields so the display matches what will be saved. + if (duration > Duration.ZERO) { + exactLength = duration + _uiState.update { + it.copy( + lengthHours = duration.inWholeHours.toString(), + lengthMinutes = (duration.inWholeMinutes % 60).toString(), + isCompleteButtonEnabled = true, + ) } } } @@ -100,18 +128,23 @@ class ManualAddViewModel( override fun onAddEntry(): Boolean { val currentState = _uiState.value val selectedDateTime = currentState.selectedDateTime - val lengthHours = currentState.lengthHours.toLongOrNull() ?: 0 val entryToEdit = currentState.entryToEdit + val notes = currentState.notes.trim() - return if (selectedDateTime != null && lengthHours > 0) { - val length = lengthHours.hours + // Save the precise length when we have one (edited entry or an end-time + // calculation); otherwise build it from the hours + minutes fields. + val fieldLength = (currentState.lengthHours.toLongOrNull() ?: 0).hours + + (currentState.lengthMinutes.toLongOrNull() ?: 0).minutes + val length = exactLength ?: fieldLength + + return if (selectedDateTime != null && length > Duration.ZERO) { viewModelScope.launch(Dispatchers.IO) { if (entryToEdit != null) { // Update existing entry - repository.updateLogEntry(entryToEdit, selectedDateTime, length) + repository.updateLogEntry(entryToEdit, selectedDateTime, length, notes) } else { // Add new entry - repository.addLogEntry(selectedDateTime, length) + repository.addLogEntry(selectedDateTime, length, notes) } } true @@ -122,32 +155,46 @@ class ManualAddViewModel( override fun onDismiss() { // Reset state when dialog is dismissed + exactLength = null _uiState.update { IManualAddViewModel.ManualAddUiState() } } override fun initializeWithEntry(entry: FastingLogEntry) { + exactLength = entry.length val selectedDate = LocalDate( year = entry.start.year, month = entry.start.month, - dayOfMonth = entry.start.dayOfMonth + day = entry.start.day ) - val lengthHours = entry.length.toDouble(DurationUnit.HOURS).toLong().toString() - _uiState.update { it.copy( currentStep = ManualAddStep.SetDuration, selectedDate = selectedDate, selectedDateTime = entry.start, - lengthHours = lengthHours, + lengthHours = entry.length.inWholeHours.toString(), + lengthMinutes = (entry.length.inWholeMinutes % 60).toString(), + notes = entry.notes, isCompleteButtonEnabled = true, entryToEdit = entry ) } } + override fun initializeWithDate(date: LocalDate) { + // Fresh entry, but with the day preselected (e.g. tapped on the calendar): + // open on the date step with that day chosen so the user just confirms it. + exactLength = null + _uiState.update { + IManualAddViewModel.ManualAddUiState( + currentStep = ManualAddStep.StartDate, + selectedDate = date, + ) + } + } + override fun onPreviousStep() { val currentState = _uiState.value val previousStep = when (currentState.currentStep) { diff --git a/app/src/main/java/com/darkrockstudios/apps/fasttrack/screens/main/AboutDialog.kt b/app/src/main/java/com/darkrockstudios/apps/fasttrack/screens/main/AboutDialog.kt new file mode 100644 index 00000000..a79d27b8 --- /dev/null +++ b/app/src/main/java/com/darkrockstudios/apps/fasttrack/screens/main/AboutDialog.kt @@ -0,0 +1,236 @@ +package com.darkrockstudios.apps.fasttrack.screens.main + +import androidx.compose.foundation.Image +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Code +import androidx.compose.material.icons.filled.Language +import androidx.compose.material.icons.filled.Share +import androidx.compose.material.icons.filled.Star +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import com.darkrockstudios.apps.fasttrack.R + +private const val GITHUB_URL = "https://github.com/Darkrock-Studios" +private const val WEBSITE_URL = "https://darkrock.studio/" +private const val DISCORD_URL = "https://discord.gg/ju2RQa5x8W" + +/** + * The About dialog in Compose, replacing the retired MaterialAbout library + * (the last dependency that needed Jetifier). Cover photo with the studio + * avatar overlapping its lower edge, link chips, then the app block with + * rate and share actions. Spacing follows the app's Fibonacci scale. + */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun AboutDialog( + versionName: String, + onOpenUrl: (String) -> Unit, + onRateApp: () -> Unit, + onShareApp: () -> Unit, + onDismiss: () -> Unit, +) { + Dialog(onDismissRequest = onDismiss) { + val cardColor = CardDefaults.cardColors().containerColor + + Card(modifier = Modifier.widthIn(max = 420.dp)) { + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + // Cover photo with the avatar overlapping its lower edge + Box( + modifier = Modifier + .fillMaxWidth() + .height(188.dp) + ) { + Image( + painter = painterResource(id = R.mipmap.profile_cover), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxWidth() + .height(144.dp) + .align(Alignment.TopCenter) + ) + + IconButton( + onClick = onDismiss, + modifier = Modifier + .align(Alignment.TopEnd) + .padding(8.dp), + colors = IconButtonDefaults.iconButtonColors( + containerColor = Color.Black.copy(alpha = 0.35f), + contentColor = Color.White, + ), + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = stringResource(id = R.string.close_button_content_description), + ) + } + + Image( + painter = painterResource(id = R.drawable.darkrockstudios_logo), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .align(Alignment.BottomCenter) + .size(89.dp) + .clip(CircleShape) + .border(3.dp, cardColor, CircleShape) + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = stringResource(id = R.string.about_name), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = stringResource(id = R.string.about_subtitle), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(modifier = Modifier.height(13.dp)) + + Text( + text = stringResource(id = R.string.about_brief), + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + modifier = Modifier.padding(horizontal = 34.dp), + ) + + Spacer(modifier = Modifier.height(13.dp)) + + // Link chips wrap on narrow screens instead of overflowing + FlowRow( + modifier = Modifier.padding(horizontal = 21.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally), + ) { + AssistChip( + onClick = { onOpenUrl(GITHUB_URL) }, + label = { Text(stringResource(id = R.string.about_github)) }, + leadingIcon = { + Icon( + imageVector = Icons.Default.Code, + contentDescription = null, + modifier = Modifier.size(AssistChipDefaults.IconSize), + ) + }, + ) + AssistChip( + onClick = { onOpenUrl(WEBSITE_URL) }, + label = { Text(stringResource(id = R.string.about_website)) }, + leadingIcon = { + Icon( + imageVector = Icons.Default.Language, + contentDescription = null, + modifier = Modifier.size(AssistChipDefaults.IconSize), + ) + }, + ) + AssistChip( + onClick = { onOpenUrl(DISCORD_URL) }, + label = { Text(stringResource(id = R.string.about_discord)) }, + leadingIcon = { + Icon( + painter = painterResource(id = R.drawable.ic_discord), + contentDescription = null, + modifier = Modifier.size(AssistChipDefaults.IconSize), + ) + }, + ) + } + + Spacer(modifier = Modifier.height(13.dp)) + + HorizontalDivider(modifier = Modifier.padding(horizontal = 21.dp)) + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 21.dp, vertical = 13.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Image( + painter = painterResource(id = R.drawable.app_icon), + contentDescription = null, + modifier = Modifier.size(55.dp), + ) + Spacer(modifier = Modifier.width(13.dp)) + Column { + Text( + text = stringResource(id = R.string.app_name), + style = MaterialTheme.typography.titleMedium, + ) + Text( + text = "v$versionName", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(start = 21.dp, end = 21.dp, bottom = 21.dp) + ) { + Button( + onClick = onRateApp, + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 48.dp), + ) { + Icon( + imageVector = Icons.Default.Star, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(stringResource(id = R.string.about_rate)) + } + + Spacer(modifier = Modifier.height(8.dp)) + + OutlinedButton( + onClick = onShareApp, + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 48.dp), + ) { + Icon( + imageVector = Icons.Default.Share, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(stringResource(id = R.string.action_share)) + } + } + } + } + } +} diff --git a/app/src/main/java/com/darkrockstudios/apps/fasttrack/screens/main/MainActivity.kt b/app/src/main/java/com/darkrockstudios/apps/fasttrack/screens/main/MainActivity.kt index 8a51a561..4d39afe1 100644 --- a/app/src/main/java/com/darkrockstudios/apps/fasttrack/screens/main/MainActivity.kt +++ b/app/src/main/java/com/darkrockstudios/apps/fasttrack/screens/main/MainActivity.kt @@ -1,31 +1,34 @@ package com.darkrockstudios.apps.fasttrack.screens.main import android.app.ComponentCaller +import android.content.ActivityNotFoundException import android.content.Intent import android.os.Bundle import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge -import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatActivity import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.core.net.toUri import androidx.core.view.WindowCompat +import com.darkrockstudios.apps.fasttrack.BuildConfig import com.darkrockstudios.apps.fasttrack.FastingNotificationManager import com.darkrockstudios.apps.fasttrack.R -import com.darkrockstudios.apps.fasttrack.data.Stages import com.darkrockstudios.apps.fasttrack.data.activefast.ActiveFastRepository +import com.darkrockstudios.apps.fasttrack.data.settings.DateStyle import com.darkrockstudios.apps.fasttrack.data.settings.SettingsDatasource import com.darkrockstudios.apps.fasttrack.data.settings.ThemeMode +import com.darkrockstudios.apps.fasttrack.utils.LocalDateStyle import com.darkrockstudios.apps.fasttrack.screens.fasting.ExternalRequests import com.darkrockstudios.apps.fasttrack.screens.fasting.StartFastRequest import com.darkrockstudios.apps.fasttrack.screens.info.InfoActivity import com.darkrockstudios.apps.fasttrack.screens.intro.IntroActivity import com.darkrockstudios.apps.fasttrack.screens.settings.SettingsActivity import com.darkrockstudios.apps.fasttrack.ui.theme.FastTrackTheme -import com.vansuita.materialabout.builder.AboutBuilder +import io.github.aakira.napier.Napier import org.koin.android.ext.android.inject import kotlin.time.ExperimentalTime @@ -34,7 +37,10 @@ import kotlin.time.ExperimentalTime class MainActivity : AppCompatActivity() { private var startFastRequestState by mutableStateOf(null) private var stopFastRequestState by mutableStateOf(false) + private var shareRequestState by mutableStateOf(false) + private var showAboutState by mutableStateOf(false) private var themeModeState by mutableStateOf(ThemeMode.SYSTEM) + private var dateStyleState by mutableStateOf(DateStyle.OPTIMIZED_COMPACT) private val settings by inject() private val fastingRepository by inject() @@ -46,6 +52,7 @@ class MainActivity : AppCompatActivity() { .isAppearanceLightStatusBars = false themeModeState = settings.getThemeMode() + dateStyleState = settings.getDateStyle() handleStartFastExtra(intent) if (!settings.getIntroSeen()) { @@ -54,19 +61,32 @@ class MainActivity : AppCompatActivity() { setContent { FastTrackTheme(themeMode = themeModeState) { + CompositionLocalProvider(LocalDateStyle provides dateStyleState) { MainScreen( - repository = fastingRepository, - onShareClick = { shareText() }, + onShareClick = { shareRequestState = true }, onInfoClick = { startActivity(Intent(this, InfoActivity::class.java)) }, - onAboutClick = { showAbout() }, + onAboutClick = { showAboutState = true }, onSettingsClick = { startActivity(Intent(this, SettingsActivity::class.java)) }, externalRequests = ExternalRequests( startFastRequest = startFastRequestState, stopFastRequested = stopFastRequestState, + shareRequested = shareRequestState, consumeStartFastRequest = { startFastRequestState = null }, consumeStopFastRequest = { stopFastRequestState = false }, + consumeShareRequest = { shareRequestState = false }, ), ) + + if (showAboutState) { + AboutDialog( + versionName = BuildConfig.VERSION_NAME, + onOpenUrl = { url -> openUrl(url) }, + onRateApp = { rateApp() }, + onShareApp = { shareApp() }, + onDismiss = { showAboutState = false }, + ) + } + } } } } @@ -77,6 +97,10 @@ class MainActivity : AppCompatActivity() { if (currentMode != themeModeState) { themeModeState = currentMode } + val currentDateStyle = settings.getDateStyle() + if (currentDateStyle != dateStyleState) { + dateStyleState = currentDateStyle + } setupFastingNotification() } @@ -91,9 +115,11 @@ class MainActivity : AppCompatActivity() { } } + // Only the single-arg override handles the intent: the platform's + // (Intent, ComponentCaller) default delegates to it, so handling here too + // would process every new intent twice. override fun onNewIntent(intent: Intent, caller: ComponentCaller) { super.onNewIntent(intent, caller) - handleStartFastExtra(intent) } override fun onNewIntent(intent: Intent) { @@ -110,64 +136,31 @@ class MainActivity : AppCompatActivity() { } } - private fun shareText() { - val elapsedHours: Long - val elapsedMinutes: Int - - val elapsedTime = fastingRepository.getElapsedFastTime() - elapsedTime.toComponents { hours, minutes, _, _ -> - elapsedHours = hours - elapsedMinutes = minutes + private fun openUrl(url: String) { + try { + startActivity(Intent(Intent.ACTION_VIEW, url.toUri())) + } catch (e: ActivityNotFoundException) { + Napier.w("No activity available to open url: $url", e) } + } - val curPhase = Stages.getCurrentPhase(elapsedTime) - val shareText = if (fastingRepository.isFasting()) { - val energyModeStr = - if (curPhase.fatBurning) { - getString(R.string.fasting_energy_mode_fat) - } else { - getString(R.string.fasting_energy_mode_glucose) - } - getString(R.string.share_text, elapsedHours, elapsedMinutes, energyModeStr) - } else { - getString(R.string.share_text_past_tense, elapsedHours, elapsedMinutes) + private fun rateApp() { + try { + startActivity(Intent(Intent.ACTION_VIEW, "market://details?id=$packageName".toUri())) + } catch (_: ActivityNotFoundException) { + // No Play Store on this device; fall back to the web listing + openUrl("https://play.google.com/store/apps/details?id=$packageName") } + } - val sendIntent: Intent = Intent().apply { - action = Intent.ACTION_SEND - putExtra(Intent.EXTRA_TEXT, shareText) + private fun shareApp() { + val shareText = + "${getString(R.string.app_name)} — https://play.google.com/store/apps/details?id=$packageName" + val intent = Intent(Intent.ACTION_SEND).apply { type = "text/plain" + putExtra(Intent.EXTRA_TEXT, shareText) } - - val shareIntent = Intent.createChooser(sendIntent, null) - startActivity(shareIntent) - } - - private fun showAbout() { - val view = AboutBuilder.with(this) - .setPhoto(R.drawable.darkrockstudios_logo) - .setCover(R.mipmap.profile_cover) - .setName(R.string.about_name) - .setSubTitle(R.string.about_subtitle) - .setBrief(R.string.about_brief) - .setAppIcon(R.drawable.app_icon) - .setAppName(R.string.app_name) - .addGitHubLink("Darkrock-Studios") - .addWebsiteLink("https://darkrock.studio/") - .addLink( - R.drawable.ic_discord, - R.string.about_discord, - "https://discord.gg/ju2RQa5x8W".toUri() - ) - .addFiveStarsAction() - .setVersionNameAsAppSubTitle() - .addShareAction(R.string.app_name) - .setWrapScrollView(true) - .setLinksAnimated(true) - .setShowAsCard(true) - .build() - - AlertDialog.Builder(this).setView(view).create().show() + startActivity(Intent.createChooser(intent, null)) } companion object { diff --git a/app/src/main/java/com/darkrockstudios/apps/fasttrack/screens/main/MainScreen.kt b/app/src/main/java/com/darkrockstudios/apps/fasttrack/screens/main/MainScreen.kt index 02cb7008..5139a5c2 100644 --- a/app/src/main/java/com/darkrockstudios/apps/fasttrack/screens/main/MainScreen.kt +++ b/app/src/main/java/com/darkrockstudios/apps/fasttrack/screens/main/MainScreen.kt @@ -1,32 +1,68 @@ package com.darkrockstudios.apps.fasttrack.screens.main +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandHorizontally +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkHorizontally import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.calculateEndPadding +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.DeleteSweep import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Share -import androidx.compose.material3.* +import androidx.compose.material.icons.outlined.Info +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.NavigationRail +import androidx.compose.material3.NavigationRailItem +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo -import androidx.compose.runtime.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveableStateHolder +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp import com.darkrockstudios.apps.fasttrack.R -import com.darkrockstudios.apps.fasttrack.data.activefast.ActiveFastRepository import com.darkrockstudios.apps.fasttrack.screens.fasting.ExternalRequests import com.darkrockstudios.apps.fasttrack.screens.fasting.FastingScreen import com.darkrockstudios.apps.fasttrack.screens.log.LogScreen +import com.darkrockstudios.apps.fasttrack.screens.log.LogViewModel import com.darkrockstudios.apps.fasttrack.screens.profile.ProfileScreen import com.darkrockstudios.apps.fasttrack.utils.Utils import kotlinx.coroutines.launch +import org.koin.compose.viewmodel.koinViewModel import kotlin.time.ExperimentalTime enum class ScreenPages { @@ -50,7 +86,6 @@ enum class ScreenPages { @ExperimentalTime @Composable fun MainScreen( - repository: ActiveFastRepository, onShareClick: () -> Unit, onInfoClick: () -> Unit, onAboutClick: () -> Unit, @@ -63,8 +98,10 @@ fun MainScreen( pageCount = { ScreenPages.entries.size }) val coroutineScope = rememberCoroutineScope() - var showMenu by remember { mutableStateOf(false) } - val shareEnabled = remember { mutableStateOf(repository.getFastStart() != null) } + // Same activity-scoped LogViewModel instance the Log page uses, so the + // contextual "Clear logbook" overflow action drives its confirmation state. + val logViewModel: LogViewModel = koinViewModel() + val logState by logViewModel.uiState.collectAsState() val fastingTitle = stringResource(id = R.string.title_fasting) val logTitle = stringResource(id = R.string.title_log) @@ -73,83 +110,9 @@ fun MainScreen( val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass val compactHeight = windowSizeClass.minHeightDp < windowSizeClass.minWidthDp - val currentTitle = remember(pagerState.currentPage, fastingTitle, logTitle, profileTitle) { - when (ScreenPages.fromOrdinal(pagerState.currentPage)) { - ScreenPages.Fasting -> fastingTitle - ScreenPages.Log -> logTitle - ScreenPages.Profile -> profileTitle - } - } - - LaunchedEffect(repository.isFasting()) { - shareEnabled.value = repository.getFastStart() != null - } - + // No top app bar: the bottom navigation already names the screen, and the + // reclaimed space belongs to the content. Actions float in the top-right. Scaffold( - topBar = { - TopAppBar( - colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.primaryContainer, - titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer, - actionIconContentColor = MaterialTheme.colorScheme.onPrimaryContainer, - ), - modifier = Modifier.Companion - .fillMaxWidth() - .background(MaterialTheme.colorScheme.primary), - title = { - Text( - text = currentTitle, - style = MaterialTheme.typography.headlineMedium, - ) - }, - actions = { - IconButton( - onClick = onShareClick, - enabled = shareEnabled.value - ) { - Icon( - imageVector = Icons.Default.Share, - contentDescription = stringResource(id = R.string.action_share), - ) - } - - IconButton(onClick = onInfoClick) { - Icon( - imageVector = Icons.Default.Info, - contentDescription = stringResource(id = R.string.action_info), - ) - } - - IconButton(onClick = { showMenu = !showMenu }) { - Icon( - imageVector = Icons.Default.MoreVert, - contentDescription = stringResource(id = R.string.more_options_button_description), - ) - } - - DropdownMenu( - expanded = showMenu, - onDismissRequest = { showMenu = false } - ) { - DropdownMenuItem( - text = { Text(stringResource(id = R.string.action_about)) }, - onClick = { - onAboutClick() - showMenu = false - }, - ) - - DropdownMenuItem( - text = { Text(stringResource(id = R.string.action_settings)) }, - onClick = { - onSettingsClick() - showMenu = false - }, - ) - } - } - ) - }, bottomBar = { if (compactHeight.not()) { NavigationBar( @@ -208,14 +171,15 @@ fun MainScreen( } } ) { paddingValues -> - if (compactHeight) { + Box(modifier = Modifier.fillMaxSize()) { + if (compactHeight) { - Row( - modifier = Modifier - .padding(top = paddingValues.calculateTopPadding()) - .fillMaxSize() - ) { - NavigationRail { + Row( + modifier = Modifier + .padding(top = paddingValues.calculateTopPadding()) + .fillMaxSize() + ) { + NavigationRail { NavigationRailItem( icon = { Icon( @@ -275,11 +239,7 @@ fun MainScreen( externalRequests, ) } - } else { - Box( - modifier = Modifier - .fillMaxSize() - ) { + } else { Content( Modifier.fillMaxSize(), contentPaddingValues = paddingValues, @@ -287,6 +247,141 @@ fun MainScreen( externalRequests, ) } + + FloatingTopActions( + showShare = pagerState.currentPage == ScreenPages.Fasting.ordinal, + showClearLog = pagerState.currentPage == ScreenPages.Log.ordinal, + clearLogEnabled = logState.totalFasts > 0, + onClearLogClick = { logViewModel.requestClearAll() }, + onShareClick = onShareClick, + onInfoClick = onInfoClick, + onAboutClick = onAboutClick, + onSettingsClick = onSettingsClick, + modifier = Modifier + .align(Alignment.TopEnd) + .padding( + top = paddingValues.calculateTopPadding() + 4.dp, + end = 8.dp, + ) + ) + } + } +} + +/** + * The old top app bar, distilled to a small translucent pill in the + * top-right corner: Info stays exposed; About and Settings live behind + * the overflow menu. Share is contextual — it captures the fasting hero, + * so it only appears (animated) while the Fasting page is active. + */ +@Composable +private fun FloatingTopActions( + showShare: Boolean, + showClearLog: Boolean, + clearLogEnabled: Boolean, + onClearLogClick: () -> Unit, + onShareClick: () -> Unit, + onInfoClick: () -> Unit, + onAboutClick: () -> Unit, + onSettingsClick: () -> Unit, + modifier: Modifier = Modifier, +) { + var showMenu by remember { mutableStateOf(false) } + + Surface( + modifier = modifier, + shape = RoundedCornerShape(24.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.55f), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + AnimatedVisibility( + visible = showShare, + enter = expandHorizontally() + fadeIn(), + exit = shrinkHorizontally() + fadeOut(), + ) { + IconButton(onClick = onShareClick) { + Icon( + imageVector = Icons.Default.Share, + contentDescription = stringResource(id = R.string.action_share), + ) + } + } + + IconButton(onClick = onInfoClick) { + Icon( + imageVector = Icons.Default.Info, + contentDescription = stringResource(id = R.string.action_info), + ) + } + + Box { + IconButton(onClick = { showMenu = !showMenu }) { + Icon( + imageVector = Icons.Default.MoreVert, + contentDescription = stringResource(id = R.string.more_options_button_description), + ) + } + + DropdownMenu( + expanded = showMenu, + onDismissRequest = { showMenu = false } + ) { + DropdownMenuItem( + text = { Text(stringResource(id = R.string.action_about)) }, + leadingIcon = { + Icon( + imageVector = Icons.Outlined.Info, + contentDescription = null, + ) + }, + onClick = { + onAboutClick() + showMenu = false + }, + ) + + DropdownMenuItem( + text = { Text(stringResource(id = R.string.action_settings)) }, + leadingIcon = { + Icon( + imageVector = Icons.Default.Settings, + contentDescription = null, + ) + }, + onClick = { + onSettingsClick() + showMenu = false + }, + ) + + // Contextual, destructive: only on the Log page, and only when + // there is something to clear. Set apart by a divider and error + // tone so it can't be mistaken for the routine actions above. + if (showClearLog) { + HorizontalDivider() + DropdownMenuItem( + text = { + Text( + text = stringResource(id = R.string.menu_clear_logbook), + color = MaterialTheme.colorScheme.error, + ) + }, + leadingIcon = { + Icon( + imageVector = Icons.Default.DeleteSweep, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + ) + }, + enabled = clearLogEnabled, + onClick = { + onClearLogClick() + showMenu = false + }, + ) + } + } + } } } } diff --git a/app/src/main/java/com/darkrockstudios/apps/fasttrack/screens/preview/DummyFastingLogRepository.kt b/app/src/main/java/com/darkrockstudios/apps/fasttrack/screens/preview/DummyFastingLogRepository.kt index b2dd8061..ec1bd9d5 100644 --- a/app/src/main/java/com/darkrockstudios/apps/fasttrack/screens/preview/DummyFastingLogRepository.kt +++ b/app/src/main/java/com/darkrockstudios/apps/fasttrack/screens/preview/DummyFastingLogRepository.kt @@ -2,6 +2,7 @@ package com.darkrockstudios.apps.fasttrack.screens.preview import com.darkrockstudios.apps.fasttrack.data.log.FastingLogEntry import com.darkrockstudios.apps.fasttrack.data.log.FastingLogRepository +import com.darkrockstudios.apps.fasttrack.data.log.ImportResult import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow import kotlinx.datetime.LocalDateTime @@ -12,11 +13,17 @@ import kotlin.time.Instant * Dummy implementation of FastingLogRepository for preview purposes */ class DummyFastingLogRepository(private val entries: List = emptyList()) : FastingLogRepository { - override fun logFast(startTime: Instant, endTime: Instant) {} + override fun logFast(startTime: Instant, endTime: Instant, notes: String) {} override fun loadAll(): Flow> = flow {} override fun delete(item: FastingLogEntry) = true - override fun addLogEntry(start: LocalDateTime, length: Duration) {} - override fun updateLogEntry(entry: FastingLogEntry, start: LocalDateTime, length: Duration) = true + override fun deleteAllEntries() = entries.size + override fun addLogEntry(start: LocalDateTime, length: Duration, notes: String) {} + override fun updateLogEntry(entry: FastingLogEntry, start: LocalDateTime, length: Duration, notes: String) = true override suspend fun exportLog(): String = "" override suspend fun importLog(cvsExport: String) = true + override suspend fun importEasyFastBackup(zipBytes: ByteArray) = ImportResult(0, 0, ok = true) + override suspend fun exportIcs(): String = "" + override suspend fun exportActivityStreams(): String = "" + override suspend fun importIcs(icsText: String) = ImportResult(0, 0, ok = true) + override suspend fun importActivityStreams(jsonText: String) = ImportResult(0, 0, ok = true) } diff --git a/app/src/main/java/com/darkrockstudios/apps/fasttrack/screens/preview/DummySettingsDatasource.kt b/app/src/main/java/com/darkrockstudios/apps/fasttrack/screens/preview/DummySettingsDatasource.kt index a9615aaa..7fea44f2 100644 --- a/app/src/main/java/com/darkrockstudios/apps/fasttrack/screens/preview/DummySettingsDatasource.kt +++ b/app/src/main/java/com/darkrockstudios/apps/fasttrack/screens/preview/DummySettingsDatasource.kt @@ -1,6 +1,8 @@ package com.darkrockstudios.apps.fasttrack.screens.preview +import com.darkrockstudios.apps.fasttrack.data.settings.DateStyle import com.darkrockstudios.apps.fasttrack.data.settings.LogViewMode +import com.darkrockstudios.apps.fasttrack.data.settings.PhaseVisibility import com.darkrockstudios.apps.fasttrack.data.settings.SettingsDatasource import com.darkrockstudios.apps.fasttrack.data.settings.ThemeMode import kotlinx.coroutines.flow.Flow @@ -40,7 +42,22 @@ class DummySettingsDatasource( override fun setThemeMode(mode: ThemeMode) {} + override fun getDateStyle(): DateStyle = DateStyle.OPTIMIZED_COMPACT + + override fun setDateStyle(style: DateStyle) {} + override fun getLogViewMode(): LogViewMode = LogViewMode.LIST override fun setLogViewMode(mode: LogViewMode) {} + + override fun getShowFatBurn(): Boolean = true + override fun setShowFatBurn(enabled: Boolean) {} + override fun getShowKetosis(): Boolean = true + override fun setShowKetosis(enabled: Boolean) {} + override fun getShowAutophagy(): Boolean = true + override fun setShowAutophagy(enabled: Boolean) {} + override fun getPhaseAutoMode(): Boolean = false + override fun setPhaseAutoMode(enabled: Boolean) {} + override fun phaseVisibilityFlow(): Flow = + flowOf(PhaseVisibility(fatBurn = true, ketosis = true, autophagy = true, autoMode = false)) } \ No newline at end of file diff --git a/app/src/main/java/com/darkrockstudios/apps/fasttrack/screens/profile/ProfileViewModel.kt b/app/src/main/java/com/darkrockstudios/apps/fasttrack/screens/profile/ProfileViewModel.kt index de78c19a..e12e5508 100644 --- a/app/src/main/java/com/darkrockstudios/apps/fasttrack/screens/profile/ProfileViewModel.kt +++ b/app/src/main/java/com/darkrockstudios/apps/fasttrack/screens/profile/ProfileViewModel.kt @@ -56,18 +56,26 @@ class ProfileViewModel( val weightPoundsStr = if (profile.weightKg == 0.0) "" else "%.01f".format(pounds) val weightKgStr = if (profile.weightKg == 0.0) "" else "%.01f".format(profile.weightKg) - val bmi = calculateBmi(profile) - val bmiCategory = when { - bmi < 18.5 -> appContext.getString(R.string.profile_bmi_category_underweight) - bmi < 25.0 -> appContext.getString(R.string.profile_bmi_category_normal) - bmi < 30.0 -> appContext.getString(R.string.profile_bmi_category_overweight) - bmi < 40.0 -> appContext.getString(R.string.profile_bmi_category_obese) - bmi >= 40.0 -> appContext.getString(R.string.profile_bmi_category_morbidly_obese) - else -> "" + // Only surface BMI/BMR once the profile is complete; an empty profile + // has a BMI of 0.0, which would otherwise read as "Underweight". + val bmiValue: String + val bmrValue: String + if (profile.isValid()) { + val bmi = calculateBmi(profile) + val bmiCategory = when { + bmi < 18.5 -> appContext.getString(R.string.profile_bmi_category_underweight) + bmi < 25.0 -> appContext.getString(R.string.profile_bmi_category_normal) + bmi < 30.0 -> appContext.getString(R.string.profile_bmi_category_overweight) + bmi < 40.0 -> appContext.getString(R.string.profile_bmi_category_obese) + else -> appContext.getString(R.string.profile_bmi_category_morbidly_obese) + } + bmiValue = appContext.getString(R.string.profile_bmi_value, bmi, bmiCategory) + bmrValue = appContext.getString(R.string.profile_bmr_value, calculateBmr(profile)) + } else { + bmiValue = "" + bmrValue = "" } - val bmr = calculateBmr(profile) - _uiState.update { it.copy( isMetric = isMetric, @@ -78,8 +86,8 @@ class ProfileViewModel( weightLbs = weightPoundsStr, age = if (profile.ageYears == 0) "" else profile.ageYears.toString(), gender = profile.gender, - bmiValue = appContext.getString(R.string.profile_bmi_value, bmi, bmiCategory), - bmrValue = appContext.getString(R.string.profile_bmr_value, bmr) + bmiValue = bmiValue, + bmrValue = bmrValue ) } } diff --git a/app/src/main/java/com/darkrockstudios/apps/fasttrack/screens/settings/SettingsActivity.kt b/app/src/main/java/com/darkrockstudios/apps/fasttrack/screens/settings/SettingsActivity.kt index 5e8f975d..865c5bc2 100644 --- a/app/src/main/java/com/darkrockstudios/apps/fasttrack/screens/settings/SettingsActivity.kt +++ b/app/src/main/java/com/darkrockstudios/apps/fasttrack/screens/settings/SettingsActivity.kt @@ -24,6 +24,8 @@ import com.darkrockstudios.apps.fasttrack.FastingNotificationManager import com.darkrockstudios.apps.fasttrack.R import com.darkrockstudios.apps.fasttrack.data.activefast.ActiveFastRepository import com.darkrockstudios.apps.fasttrack.data.log.FastingLogRepository +import com.darkrockstudios.apps.fasttrack.data.log.ImportResult +import com.darkrockstudios.apps.fasttrack.data.log.LogExportFormat import com.darkrockstudios.apps.fasttrack.data.settings.SettingsDatasource import com.darkrockstudios.apps.fasttrack.data.settings.ThemeMode import com.darkrockstudios.apps.fasttrack.ui.theme.FastTrackTheme @@ -31,9 +33,13 @@ import io.github.aakira.napier.Napier import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import kotlinx.datetime.TimeZone +import kotlinx.datetime.number +import kotlinx.datetime.toLocalDateTime import org.koin.android.ext.android.inject import java.io.File import java.util.Locale +import kotlin.time.Clock class SettingsActivity : AppCompatActivity() { private val settings by inject() @@ -73,7 +79,7 @@ class SettingsActivity : AppCompatActivity() { onMetricSystemSettingChanged = { enabled -> handleMetricSystemSettingChange(enabled) }, themeModeState = themeModeState, onThemeModeChanged = { mode -> handleThemeModeChange(mode) }, - onExportClick = { onExportLogBook() }, + onExportClick = { format -> onExportLogBook(format) }, onImportClick = { onImportLogBook() } ) } @@ -176,66 +182,86 @@ class SettingsActivity : AppCompatActivity() { getContent = registerForActivityResult(ActivityResultContracts.GetContent()) { uri: Uri? -> uri?.let { lifecycle.coroutineScope.launch(Dispatchers.Default) { - try { - val inputStream = contentResolver.openInputStream(it) - val csvContent = - inputStream?.bufferedReader()?.use { reader -> reader.readText() } - - if (csvContent != null) { - val success = logRepository.importLog(csvContent) - val messageResId = - if (success) R.string.import_success else R.string.import_failed - withContext(Dispatchers.Main) { - Toast.makeText( - this@SettingsActivity, - getString(messageResId), - Toast.LENGTH_SHORT - ).show() - } + // Auto-detect the file: EasyFast ZIP, iCalendar, ActivityStreams, or CSV + val message = try { + val bytes = contentResolver.openInputStream(it)?.use { s -> s.readBytes() } + if (bytes == null) { + getString(R.string.import_failed) + } else if (isZip(bytes)) { + importResultMessage(logRepository.importEasyFastBackup(bytes)) } else { - withContext(Dispatchers.Main) { - Toast.makeText( - this@SettingsActivity, - getString(R.string.import_failed), - Toast.LENGTH_SHORT - ) - .show() + val text = bytes.toString(Charsets.UTF_8).removePrefix("\uFEFF") + val head = text.trimStart() + when { + head.startsWith("BEGIN:VCALENDAR", ignoreCase = true) -> + importResultMessage(logRepository.importIcs(text)) + + (head.startsWith("{") || head.startsWith("[")) && + text.contains("activitystreams") -> + importResultMessage(logRepository.importActivityStreams(text)) + + else -> { + val ok = logRepository.importLog(text) + getString(if (ok) R.string.import_success else R.string.import_failed) + } } } } catch (e: Exception) { Napier.w("Failed to import Log", e) - withContext(Dispatchers.Main) { - Toast.makeText( - this@SettingsActivity, - getString(R.string.import_failed), - Toast.LENGTH_SHORT - ).show() - } + getString(R.string.import_failed) + } + + withContext(Dispatchers.Main) { + Toast.makeText(this@SettingsActivity, message, Toast.LENGTH_LONG).show() } } } } } - private fun onExportLogBook() { + /** ZIP local-file-header magic bytes (PK). */ + private fun isZip(bytes: ByteArray): Boolean = + bytes.size >= 4 && + bytes[0] == 0x50.toByte() && bytes[1] == 0x4B.toByte() && + bytes[2] == 0x03.toByte() && bytes[3] == 0x04.toByte() + + /** Turn an [ImportResult] into a user-facing toast message. */ + private fun importResultMessage(result: ImportResult): String = + if (result.ok) { + getString(R.string.import_easyfast_result, result.imported, result.skippedOverlapping) + } else { + getString(R.string.import_failed) + } + + private fun onExportLogBook(format: LogExportFormat) { lifecycle.coroutineScope.launch { - val csvLog = logRepository.exportLog() + val content = when (format) { + LogExportFormat.CSV -> logRepository.exportLog() + LogExportFormat.ICS -> logRepository.exportIcs() + LogExportFormat.ACTIVITY_STREAMS -> logRepository.exportActivityStreams() + } - val csvFile = File(cacheDir, "fastingLogbook.csv") + // Locale-independent timestamp: fastingLogbook-YYYY-MM-DD-HHMM. + val now = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()) + val stamp = String.format( + Locale.ROOT, "%04d-%02d-%02d-%02d%02d", + now.year, now.month.number, now.day, now.hour, now.minute + ) + val exportFile = File(cacheDir, "fastingLogbook-$stamp.${format.extension}") try { - csvFile.writeText(csvLog) + exportFile.writeText(content) val fileUri = FileProvider.getUriForFile( this@SettingsActivity, "${packageName}.fileprovider", - csvFile + exportFile ) val sendIntent: Intent = Intent().apply { action = Intent.ACTION_SEND putExtra(Intent.EXTRA_STREAM, fileUri) - type = "text/csv" + type = format.mimeType addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) } @@ -252,6 +278,7 @@ class SettingsActivity : AppCompatActivity() { } private fun onImportLogBook() { - getContent.launch("text/*") + // Allow both FastTrack CSV and EasyFast backup ZIP files + getContent.launch("*/*") } } diff --git a/app/src/main/java/com/darkrockstudios/apps/fasttrack/screens/settings/SettingsScreen.kt b/app/src/main/java/com/darkrockstudios/apps/fasttrack/screens/settings/SettingsScreen.kt index 14f1f5ad..c528cc9f 100644 --- a/app/src/main/java/com/darkrockstudios/apps/fasttrack/screens/settings/SettingsScreen.kt +++ b/app/src/main/java/com/darkrockstudios/apps/fasttrack/screens/settings/SettingsScreen.kt @@ -4,19 +4,29 @@ import androidx.annotation.StringRes import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.ArrowDropDown import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.compose.material3.LocalContentColor import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp import com.darkrockstudios.apps.fasttrack.R +import com.darkrockstudios.apps.fasttrack.data.log.LogExportFormat +import com.darkrockstudios.apps.fasttrack.data.settings.DateStyle import com.darkrockstudios.apps.fasttrack.data.settings.SettingsDatasource import com.darkrockstudios.apps.fasttrack.data.settings.ThemeMode +import com.darkrockstudios.apps.fasttrack.utils.AppDateTime import com.darkrockstudios.apps.fasttrack.utils.MAX_COLUMN_WIDTH +import com.darkrockstudios.apps.fasttrack.utils.shouldUse24HourFormat +import kotlinx.datetime.LocalDateTime @Composable @@ -31,7 +41,7 @@ fun SettingsScreen( onMetricSystemSettingChanged: (Boolean) -> Unit, themeModeState: ThemeMode, onThemeModeChanged: (ThemeMode) -> Unit, - onExportClick: () -> Unit, + onExportClick: (LogExportFormat) -> Unit, onImportClick: () -> Unit ) { Scaffold( @@ -46,7 +56,7 @@ fun SettingsScreen( navigationIcon = { IconButton(onClick = onBack) { Icon( - Icons.Filled.ArrowBack, + Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(id = R.string.back) ) } @@ -83,10 +93,16 @@ private fun SettingsList( onMetricSystemSettingChanged: (Boolean) -> Unit, themeModeState: ThemeMode, onThemeModeChanged: (ThemeMode) -> Unit, - onExportClick: () -> Unit, + onExportClick: (LogExportFormat) -> Unit, onImportClick: () -> Unit ) { var fancyBackground by remember { mutableStateOf(settings.getShowFancyBackground()) } + var dateStyle by remember { mutableStateOf(settings.getDateStyle()) } + var phaseAutoMode by remember { mutableStateOf(settings.getPhaseAutoMode()) } + var showFatBurn by remember { mutableStateOf(settings.getShowFatBurn()) } + var showKetosis by remember { mutableStateOf(settings.getShowKetosis()) } + var showAutophagy by remember { mutableStateOf(settings.getShowAutophagy()) } + var showExportFormatDialog by remember { mutableStateOf(false) } Box(modifier = Modifier.fillMaxSize()) { LazyColumn( @@ -141,12 +157,71 @@ private fun SettingsList( onChange = onMetricSystemSettingChanged ) } + item(key = "phases_header") { + SettingsSectionHeader(title = R.string.settings_section_phases) + } + item(key = "phase_auto_mode") { + SettingsItem( + headline = R.string.settings_phase_auto_title, + details = R.string.settings_phase_auto_subtitle, + value = phaseAutoMode, + onChange = { checked -> + phaseAutoMode = checked + settings.setPhaseAutoMode(checked) + } + ) + } + item(key = "phase_fat_burn") { + SettingsItem( + headline = R.string.settings_phase_fatburn_title, + details = R.string.settings_phase_fatburn_subtitle, + value = showFatBurn, + enabled = !phaseAutoMode, + onChange = { checked -> + showFatBurn = checked + settings.setShowFatBurn(checked) + } + ) + } + item(key = "phase_ketosis") { + SettingsItem( + headline = R.string.settings_phase_ketosis_title, + details = R.string.settings_phase_ketosis_subtitle, + value = showKetosis, + enabled = !phaseAutoMode, + onChange = { checked -> + showKetosis = checked + settings.setShowKetosis(checked) + } + ) + } + item(key = "phase_autophagy") { + SettingsItem( + headline = R.string.settings_phase_autophagy_title, + details = R.string.settings_phase_autophagy_subtitle, + value = showAutophagy, + enabled = !phaseAutoMode, + onChange = { checked -> + showAutophagy = checked + settings.setShowAutophagy(checked) + } + ) + } item(key = "theme_mode") { ThemeModeSettingsItem( themeMode = themeModeState, onThemeModeChanged = onThemeModeChanged ) } + item(key = "date_format") { + DateFormatSettingsItem( + dateStyle = dateStyle, + onDateStyleChanged = { style -> + dateStyle = style + settings.setDateStyle(style) + } + ) + } item(key = "logbook_header") { SettingsSectionHeader(title = R.string.settings_section_logbook) } @@ -154,7 +229,7 @@ private fun SettingsList( SettingsActionItem( headline = R.string.action_export, details = R.string.action_export_description, - onClick = onExportClick + onClick = { showExportFormatDialog = true } ) } item(key = "import_logbook") { @@ -165,6 +240,35 @@ private fun SettingsList( ) } } + + if (showExportFormatDialog) { + AlertDialog( + onDismissRequest = { showExportFormatDialog = false }, + title = { Text(stringResource(id = R.string.export_choose_format)) }, + text = { + Column { + LogExportFormat.entries.forEach { format -> + Text( + text = stringResource(id = format.labelRes), + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier + .fillMaxWidth() + .clickable { + showExportFormatDialog = false + onExportClick(format) + } + .padding(vertical = 12.dp), + ) + } + } + }, + confirmButton = { + TextButton(onClick = { showExportFormatDialog = false }) { + Text(stringResource(id = R.string.cancel_button)) + } + }, + ) + } } } @@ -189,26 +293,32 @@ private fun SettingsItem( @StringRes headline: Int, @StringRes details: Int, value: Boolean, - onChange: (enabled: Boolean) -> Unit + onChange: (enabled: Boolean) -> Unit, + enabled: Boolean = true, ) { + // Dim the whole row when disabled (e.g. individual phase toggles under Auto) + val contentAlpha = if (enabled) 1f else 0.38f ListItem( headlineContent = { Text( text = stringResource(id = headline), style = MaterialTheme.typography.labelLarge, - fontWeight = FontWeight.Bold + fontWeight = FontWeight.Bold, + color = LocalContentColor.current.copy(alpha = contentAlpha) ) }, supportingContent = { Text( text = stringResource(id = details), - style = MaterialTheme.typography.bodySmall + style = MaterialTheme.typography.bodySmall, + color = LocalContentColor.current.copy(alpha = contentAlpha) ) }, trailingContent = { Switch( checked = value, - onCheckedChange = onChange + onCheckedChange = onChange, + enabled = enabled ) } ) @@ -273,6 +383,93 @@ private fun ThemeModeSettingsItem( ) } +@StringRes +private fun dateStyleLabel(style: DateStyle): Int = when (style) { + DateStyle.OPTIMIZED_COMPACT -> R.string.dateformat_optimized_compact + DateStyle.OPTIMIZED_WEEKDAY -> R.string.dateformat_optimized_weekday + DateStyle.SYSTEM_SHORT -> R.string.dateformat_system_short + DateStyle.SYSTEM_MEDIUM -> R.string.dateformat_system_medium + DateStyle.SYSTEM_LONG -> R.string.dateformat_system_long + DateStyle.ISO -> R.string.dateformat_iso +} + +@Composable +private fun DateFormatSettingsItem( + dateStyle: DateStyle, + onDateStyleChanged: (DateStyle) -> Unit, +) { + val is24Hour = shouldUse24HourFormat(LocalContext.current) + // A representative moment (current year, evening, non-zero minutes) so each + // option's live sample shows off its real appearance rather than a mask. + val sample = remember { LocalDateTime(java.time.Year.now().value, 6, 1, 20, 5) } + var showDialog by remember { mutableStateOf(false) } + + ListItem( + headlineContent = { + Text( + text = stringResource(id = R.string.settings_dateformat_title), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Bold, + ) + }, + supportingContent = { + // The current value is shown as its own live sample — pick by looking. + Text( + text = AppDateTime.formatDateTime(sample, dateStyle, is24Hour), + style = MaterialTheme.typography.bodySmall, + ) + }, + modifier = Modifier.clickable { showDialog = true }, + ) + + if (showDialog) { + AlertDialog( + onDismissRequest = { showDialog = false }, + title = { Text(stringResource(id = R.string.settings_dateformat_title)) }, + text = { + Column(modifier = Modifier.verticalScroll(rememberScrollState())) { + DateStyle.entries.forEach { style -> + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { + onDateStyleChanged(style) + showDialog = false + } + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + RadioButton( + selected = style == dateStyle, + onClick = { + onDateStyleChanged(style) + showDialog = false + }, + ) + Column(modifier = Modifier.padding(start = 8.dp)) { + Text( + text = stringResource(id = dateStyleLabel(style)), + style = MaterialTheme.typography.bodyLarge, + ) + Text( + text = AppDateTime.formatDateTime(sample, style, is24Hour), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + }, + confirmButton = { + TextButton(onClick = { showDialog = false }) { + Text(stringResource(id = R.string.cancel_button)) + } + }, + ) + } +} + @Composable private fun SettingsActionItem( @StringRes headline: Int, diff --git a/app/src/main/java/com/darkrockstudios/apps/fasttrack/utils/AppDateTime.kt b/app/src/main/java/com/darkrockstudios/apps/fasttrack/utils/AppDateTime.kt new file mode 100644 index 00000000..b11e1f93 --- /dev/null +++ b/app/src/main/java/com/darkrockstudios/apps/fasttrack/utils/AppDateTime.kt @@ -0,0 +1,135 @@ +package com.darkrockstudios.apps.fasttrack.utils + +import android.text.format.DateFormat +import androidx.compose.runtime.staticCompositionLocalOf +import com.darkrockstudios.apps.fasttrack.data.settings.DateStyle +import kotlinx.datetime.LocalDate +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.toJavaLocalDate +import kotlinx.datetime.toJavaLocalDateTime +import java.time.YearMonth +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.util.Locale +import java.util.concurrent.ConcurrentHashMap + +/** + * The active [DateStyle] for the current composition. Provided once near the app + * root and refreshed when the user changes it in Settings — no OS-locale listeners, + * and every UI date/time reads it here. + */ +val LocalDateStyle = staticCompositionLocalOf { DateStyle.OPTIMIZED_COMPACT } + +/** + * One place that turns instants into user-facing text. Everything is locale-aware: + * field order comes from the locale (via CLDR skeletons / localized formats), not a + * hardcoded pattern, so "Jun 1" (en-US), "1 Jun" (en-GB) and "6月1日" (zh) all fall + * out correctly. The [is24Hour] flag comes from the user's system setting. + * + * Formatters are immutable and expensive to build, so they are cached and reused. + * Machine surfaces (CSV / iCal / JSON export) do NOT use this — they stay ISO. + */ +object AppDateTime { + + private val formatterCache = ConcurrentHashMap() + + private fun skeletonFormatter(locale: Locale, skeleton: String): DateTimeFormatter = + formatterCache.getOrPut("s|${locale.toLanguageTag()}|$skeleton") { + DateTimeFormatter.ofPattern(DateFormat.getBestDateTimePattern(locale, skeleton), locale) + } + + private fun patternFormatter(locale: Locale, pattern: String): DateTimeFormatter = + formatterCache.getOrPut("p|${locale.toLanguageTag()}|$pattern") { + DateTimeFormatter.ofPattern(pattern, locale) + } + + private fun localizedDateFormatter(locale: Locale, style: FormatStyle): DateTimeFormatter = + formatterCache.getOrPut("d|${locale.toLanguageTag()}|$style") { + DateTimeFormatter.ofLocalizedDate(style).withLocale(locale) + } + + private fun currentYear(): Int = YearMonth.now().year + + private fun dateFormatter(style: DateStyle, year: Int, locale: Locale): DateTimeFormatter = + when (style) { + DateStyle.OPTIMIZED_COMPACT -> + skeletonFormatter(locale, if (year == currentYear()) "MMMd" else "yMMMd") + + DateStyle.OPTIMIZED_WEEKDAY -> + skeletonFormatter(locale, if (year == currentYear()) "MMMEd" else "yMMMEd") + + DateStyle.SYSTEM_SHORT -> localizedDateFormatter(locale, FormatStyle.SHORT) + DateStyle.SYSTEM_MEDIUM -> localizedDateFormatter(locale, FormatStyle.MEDIUM) + DateStyle.SYSTEM_LONG -> localizedDateFormatter(locale, FormatStyle.LONG) + DateStyle.ISO -> patternFormatter(locale, "uuuu-MM-dd") + } + + private fun timeFormatter(style: DateStyle, minute: Int, is24Hour: Boolean, locale: Locale): DateTimeFormatter { + val skeleton = when { + style == DateStyle.ISO -> return patternFormatter(locale, "HH:mm") + is24Hour -> "Hm" + // Optimized styles drop ":00" (12h only; "20" alone would be ambiguous). + (style == DateStyle.OPTIMIZED_COMPACT || style == DateStyle.OPTIMIZED_WEEKDAY) && minute == 0 -> "h" + else -> "hm" + } + return skeletonFormatter(locale, skeleton) + } + + private fun datePart(dt: LocalDateTime, style: DateStyle, locale: Locale): String = + dt.toJavaLocalDateTime().format(dateFormatter(style, dt.year, locale)) + + private fun timePart(dt: LocalDateTime, style: DateStyle, is24Hour: Boolean, locale: Locale): String = + dt.toJavaLocalDateTime().format(timeFormatter(style, dt.minute, is24Hour, locale)) + + /** Date only (respects the style; Optimized omits the current year). */ + fun formatDate(dt: LocalDateTime, style: DateStyle, locale: Locale = Locale.getDefault()): String = + datePart(dt, style, locale) + + /** Date only, from a calendar date. */ + fun formatDate(date: LocalDate, style: DateStyle, locale: Locale = Locale.getDefault()): String = + date.toJavaLocalDate().format(dateFormatter(style, date.year, locale)) + + /** Time only. */ + fun formatTime(dt: LocalDateTime, style: DateStyle, is24Hour: Boolean, locale: Locale = Locale.getDefault()): String = + timePart(dt, style, is24Hour, locale) + + /** A single date + time, e.g. for a settings sample. */ + fun formatDateTime( + dt: LocalDateTime, + style: DateStyle, + is24Hour: Boolean, + locale: Locale = Locale.getDefault(), + ): String { + val date = datePart(dt, style, locale) + val time = timePart(dt, style, is24Hour, locale) + return if (style == DateStyle.ISO) "$date $time" else "$date, $time" + } + + /** Localized month + year for a calendar header, e.g. "June 2025" / "2025年6月". */ + fun formatMonthYear(yearMonth: YearMonth, locale: Locale = Locale.getDefault()): String = + yearMonth.format(skeletonFormatter(locale, "yMMMM")) + + /** + * A fast's window as one heading. Same-day fasts show the date once and the times + * as a range; multi-day fasts date both ends. ISO stays fully explicit on each end. + */ + fun formatFastRange( + start: LocalDateTime, + end: LocalDateTime, + style: DateStyle, + is24Hour: Boolean, + locale: Locale = Locale.getDefault(), + ): String { + if (style == DateStyle.ISO) { + return "${formatDateTime(start, style, is24Hour, locale)} → ${formatDateTime(end, style, is24Hour, locale)}" + } + val startDate = datePart(start, style, locale) + val startTime = timePart(start, style, is24Hour, locale) + val endTime = timePart(end, style, is24Hour, locale) + return if (start.date == end.date) { + "$startDate · $startTime – $endTime" + } else { + "$startDate, $startTime → ${datePart(end, style, locale)}, $endTime" + } + } +} diff --git a/app/src/main/java/com/darkrockstudios/apps/fasttrack/utils/Csv.kt b/app/src/main/java/com/darkrockstudios/apps/fasttrack/utils/Csv.kt new file mode 100644 index 00000000..c7f91a6e --- /dev/null +++ b/app/src/main/java/com/darkrockstudios/apps/fasttrack/utils/Csv.kt @@ -0,0 +1,66 @@ +package com.darkrockstudios.apps.fasttrack.utils + +/** + * Minimal RFC-4180 CSV helpers, used by logbook import/export. + * + * Notes fields may contain commas, quotes, and newlines, so naive + * `split(",")` / `split("\n")` cannot be used — these handle quoting properly. + */ + +/** Escape a single field for CSV output, quoting only when necessary. */ +fun csvEscape(field: String): String { + val needsQuoting = field.any { it == ',' || it == '"' || it == '\n' || it == '\r' } + if (!needsQuoting) return field + val escaped = field.replace("\"", "\"\"") + return "\"$escaped\"" +} + +/** + * Parse CSV text into rows of fields. Handles quoted fields containing + * commas, doubled-quote escapes (`""`), and embedded CR/LF. Accepts both + * LF and CRLF row terminators. + */ +fun parseCsv(text: String): List> { + val rows = mutableListOf>() + var row = mutableListOf() + val field = StringBuilder() + var inQuotes = false + var i = 0 + + fun endField() { + row.add(field.toString()) + field.setLength(0) + } + + fun endRow() { + endField() + rows.add(row) + row = mutableListOf() + } + + while (i < text.length) { + val c = text[i] + if (inQuotes) { + when { + c == '"' && i + 1 < text.length && text[i + 1] == '"' -> { + field.append('"'); i++ + } + c == '"' -> inQuotes = false + else -> field.append(c) + } + } else { + when (c) { + '"' -> inQuotes = true + ',' -> endField() + '\r' -> { /* ignore; the following \n (if any) ends the row */ } + '\n' -> endRow() + else -> field.append(c) + } + } + i++ + } + // Flush trailing field/row (unless the text ended exactly on a newline) + if (field.isNotEmpty() || row.isNotEmpty()) endRow() + + return rows +} diff --git a/app/src/main/java/com/darkrockstudios/apps/fasttrack/utils/DateTimeUtils.kt b/app/src/main/java/com/darkrockstudios/apps/fasttrack/utils/DateTimeUtils.kt index e0c24351..fd44d4d4 100644 --- a/app/src/main/java/com/darkrockstudios/apps/fasttrack/utils/DateTimeUtils.kt +++ b/app/src/main/java/com/darkrockstudios/apps/fasttrack/utils/DateTimeUtils.kt @@ -1,9 +1,14 @@ package com.darkrockstudios.apps.fasttrack.utils -import kotlinx.datetime.* +import kotlinx.datetime.LocalDateTime import kotlinx.datetime.TimeZone +import kotlinx.datetime.toInstant +import kotlinx.datetime.toJavaLocalDateTime +import kotlinx.datetime.toLocalDateTime import java.time.format.DateTimeFormatter -import java.util.* +import java.util.Locale +import kotlin.time.Instant + fun Instant.formatAs( pattern: String, diff --git a/app/src/main/java/com/darkrockstudios/apps/fasttrack/utils/DurationFormatter.kt b/app/src/main/java/com/darkrockstudios/apps/fasttrack/utils/DurationFormatter.kt new file mode 100644 index 00000000..99226c79 --- /dev/null +++ b/app/src/main/java/com/darkrockstudios/apps/fasttrack/utils/DurationFormatter.kt @@ -0,0 +1,60 @@ +package com.darkrockstudios.apps.fasttrack.utils + +import android.content.Context +import com.darkrockstudios.apps.fasttrack.R +import kotlin.time.Duration + +/** + * Humanized durations are written anywhere in FastTrack. + * + * - under a minute: "Just started" (localized) + * - under an hour: "37m" + * - under a day: "5h 12m" (or "5h" when the minutes are zero) + * - a day and more: "2d 12h" (or "2d" when the hours are zero) + * - [showTotalHours]: "60h 30m" instead of days — the dial's toggled format + * - [withMinutes] = false: hour-granular ("39h", "1d 15h") for surfaces that + * refresh hourly (widget, notification) where minutes would mislead + */ +fun formatDuration( + context: Context, + duration: Duration, + showTotalHours: Boolean = false, + withMinutes: Boolean = true, +): String { + val totalMinutes = duration.inWholeMinutes.coerceAtLeast(0) + val hours = totalMinutes / 60 + val minutes = totalMinutes % 60 + return when { + withMinutes && totalMinutes < 1 -> context.getString(R.string.duration_less_than_minute) + withMinutes && hours == 0L -> "${minutes}m" + // Hour-granular surfaces (notification/widget) in the first hour: "0h" would + // mislead, so read it as "<1h" until the first whole hour is reached. + !withMinutes && hours == 0L -> "<1h" + hours < 24 || showTotalHours -> + if (minutes == 0L || !withMinutes) "${hours}h" else "${hours}h ${minutes}m" + + else -> { + val days = hours / 24 + val remHours = hours % 24 + if (remHours == 0L) "${days}d" else "${days}d ${remHours}h" + } + } +} + +/** + * Full, minute-precise humanized duration for the logbook CSV, e.g. + * "5d 3h 27m", "3h 27m", "27m". Days and hours appear only when a larger unit + * requires them; minutes are always the terminal unit. Machine-neutral units + * (matches the app's d/h/m convention), independent of locale. + */ +fun formatDurationFull(duration: Duration): String { + val totalMinutes = duration.inWholeMinutes.coerceAtLeast(0) + val days = totalMinutes / 1440 + val hours = (totalMinutes % 1440) / 60 + val minutes = totalMinutes % 60 + return when { + days > 0 -> "${days}d ${hours}h ${minutes}m" + hours > 0 -> "${hours}h ${minutes}m" + else -> "${minutes}m" + } +} diff --git a/app/src/main/java/com/darkrockstudios/apps/fasttrack/utils/ShareFast.kt b/app/src/main/java/com/darkrockstudios/apps/fasttrack/utils/ShareFast.kt new file mode 100644 index 00000000..517f2df1 --- /dev/null +++ b/app/src/main/java/com/darkrockstudios/apps/fasttrack/utils/ShareFast.kt @@ -0,0 +1,44 @@ +package com.darkrockstudios.apps.fasttrack.utils + +import android.content.ClipData +import android.content.Context +import android.content.Intent +import android.graphics.Bitmap +import androidx.core.content.FileProvider +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.File +import java.io.FileOutputStream + +/** + * Share a snapshot of the fasting progress as an image plus a caption. + * + * [bitmap] is already opaque (the hero paints the brand gradient into its own + * bounds before capture), so it can be written straight to a PNG. We compress + * it directly rather than routing through a software Canvas — that avoids the + * "software rendering doesn't support hardware bitmaps" pitfall entirely. + */ +suspend fun shareFastImage( + context: Context, + bitmap: Bitmap, + caption: String, +) = withContext(Dispatchers.IO) { + // cacheDir root is exposed via file_paths.xml () + val file = File(context.cacheDir, "fast_share.png") + FileOutputStream(file).use { bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) } + + val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file) + + val send = Intent(Intent.ACTION_SEND).apply { + type = "image/png" + putExtra(Intent.EXTRA_STREAM, uri) + putExtra(Intent.EXTRA_TEXT, caption) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + // clipData ensures the read grant reaches the chosen target reliably + clipData = ClipData.newUri(context.contentResolver, "Fast Track", uri) + } + + withContext(Dispatchers.Main) { + context.startActivity(Intent.createChooser(send, null)) + } +} diff --git a/app/src/main/java/com/darkrockstudios/apps/fasttrack/widget/FastingWidgetContent.kt b/app/src/main/java/com/darkrockstudios/apps/fasttrack/widget/FastingWidgetContent.kt index f34c6086..e84c8561 100644 --- a/app/src/main/java/com/darkrockstudios/apps/fasttrack/widget/FastingWidgetContent.kt +++ b/app/src/main/java/com/darkrockstudios/apps/fasttrack/widget/FastingWidgetContent.kt @@ -17,6 +17,7 @@ import androidx.glance.text.TextStyle import com.darkrockstudios.apps.fasttrack.R import com.darkrockstudios.apps.fasttrack.data.Stages import com.darkrockstudios.apps.fasttrack.data.activefast.ActiveFastRepository +import com.darkrockstudios.apps.fasttrack.utils.formatDuration import com.darkrockstudios.apps.fasttrack.utils.getColorFor import org.koin.compose.koinInject import kotlin.time.ExperimentalTime @@ -117,7 +118,7 @@ private fun SmallWidgetContent( // Time display Text( - text = context.getString(R.string.app_widget_time_compact, elapsedTime.inWholeHours), + text = formatDuration(context, elapsedTime, withMinutes = false), style = TextStyle( fontWeight = FontWeight.Bold, color = GlanceTheme.colors.onBackground, @@ -190,7 +191,7 @@ private fun MediumWidgetContent( Column { // Time display Text( - text = context.getString(R.string.app_widget_time, elapsedTime.inWholeHours), + text = formatDuration(context, elapsedTime, withMinutes = false), style = TextStyle( fontWeight = FontWeight.Bold, color = GlanceTheme.colors.onBackground, @@ -299,7 +300,7 @@ private fun FullWidgetContent( // Large time display Text( - text = context.getString(R.string.app_widget_time, elapsedTime.inWholeHours), + text = formatDuration(context, elapsedTime, withMinutes = false), style = TextStyle( fontWeight = FontWeight.Bold, color = GlanceTheme.colors.onBackground, diff --git a/app/src/main/java/com/darkrockstudios/apps/fasttrack/widget/WidgetUpdater.kt b/app/src/main/java/com/darkrockstudios/apps/fasttrack/widget/WidgetUpdater.kt index 5467c3e1..62fa40c6 100644 --- a/app/src/main/java/com/darkrockstudios/apps/fasttrack/widget/WidgetUpdater.kt +++ b/app/src/main/java/com/darkrockstudios/apps/fasttrack/widget/WidgetUpdater.kt @@ -5,17 +5,22 @@ import androidx.glance.GlanceId import androidx.glance.appwidget.GlanceAppWidgetManager import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch /** * Utility class to update the Fasting widget when fasting state changes */ object WidgetUpdater { + // One long-lived scope reused across calls, rather than allocating a fresh, + // unmanaged CoroutineScope on every fast start/stop/tick. + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + /** * Updates all instances of the Fasting widget */ fun updateWidgets(context: Context) { - CoroutineScope(Dispatchers.IO).launch { + scope.launch { val manager = GlanceAppWidgetManager(context) val glanceIds = manager.getGlanceIds(FastingWidget::class.java) glanceIds.forEach { glanceId -> diff --git a/app/src/main/res/raw-es/info.md b/app/src/main/res/raw-es/info.md index 4b9e6e65..7bc7623a 100644 --- a/app/src/main/res/raw-es/info.md +++ b/app/src/main/res/raw-es/info.md @@ -1,34 +1,34 @@ # Ayuno Intermitente -**Rápido intermitente** es actualmente un campo de estudio muy activo, algunas partes de esto están bien establecidas y otras todavía están siendo estudiadas. Así que tome todo esto con un grano de sal, y consulte las fuentes principales enlazadas a continuación. +El ayuno intermitente es un campo de estudio muy activo: algunas de sus bases están bien establecidas, mientras que otras siguen bajo investigación. Por eso conviene tomar lo siguiente con cautela y consultar las fuentes primarias que se enlazan al final. -## Cómo y por qué ayunar funciona: +## Cómo y por qué funciona el ayuno -Se cree que en nuestro desarrollo temprano como recolectores de cazadores, nuestra dieta era fiesta o hambre. Tal vez una tribu pequeña recibiría una gran muerte una vez por semana o menos. Por lo tanto, nuestros cuerpos están bien diseñados para sacar el máximo provecho de ello y almacenar todo lo posible para su uso posterior. +Se cree que, en nuestra etapa temprana como cazadores-recolectores, nuestra dieta alternaba entre la abundancia y la escasez: quizá una tribu pequeña conseguía una presa grande una vez por semana, o incluso con menos frecuencia. Por eso nuestro cuerpo evolucionó para aprovechar al máximo cada ingesta y almacenar todo lo posible para más adelante. -Adquirir glúcidos en grandes cantidades habría sido difícil. Por ejemplo, el forraje para las bayas, que serían altas en el azúcar, requeriría mucho esfuerzo sólo por una pequeña cantidad de azúcar. +Conseguir carbohidratos en grandes cantidades era difícil. Recolectar bayas, por ejemplo — ricas en azúcar —, exigía mucho esfuerzo a cambio de muy poca energía. -### Así, nuestros cuerpos desarrollaron dos vías primarias de energía: +### Por eso desarrollamos dos rutas energéticas principales -1. **Glucos:** Todos los carbohidratos se descomponen en glucosa al consumir. La glucosa puede ser consumida directamente por las células para obtener energía. El exceso de glucosa se almacena como glicogeno en varios lugares alrededor del cuerpo, como el hígado y el músculo esquelético. Una vez que sus reservas de glicogeno estén llenas, el resto de glucosa se convertirá en un tejido adipose. Cuando sea necesario, el glicogeno almacenado se puede descomponer en glucosa para ser consumido por las células. -2. **Cuerpos de cetona:** Estos se derivan de la metabolización de grasa y las células pueden consumirlos en lugar de glucosa para obtener energía. El exceso de calorías de los hidratos de carbono o de la proteína (_que se descomponen en glucosa y aminoácidos respectivamente_) se almacenarán como tejido adiposo (_grasa_), y cuando sea necesario, puede dividirse en cuerpos de ketone que serán utilizados para la energía por sus células. +1. **Glucosa.** Todos los carbohidratos se descomponen en glucosa al ser consumidos. Las células pueden usarla directamente como energía. El exceso se almacena como glucógeno en distintos tejidos, sobre todo en el hígado y el músculo esquelético. Una vez llenas esas reservas, el excedente de glucosa se convierte en tejido adiposo. Cuando el cuerpo lo necesita, el glucógeno almacenado vuelve a convertirse en glucosa para que las células lo consuman. +2. **Cuerpos cetónicos.** Provienen de la metabolización de la grasa, y las células pueden usarlos como energía en lugar de glucosa. El exceso de calorías proveniente de carbohidratos o proteínas (que se descomponen en glucosa y aminoácidos, respectivamente) se almacena como tejido adiposo, y cuando hace falta, ese tejido se descompone en cuerpos cetónicos que las células utilizan como combustible. -En los días de la recolección de cazadores a menudo hubiéramos despojado nuestra energía de los cuerpos de ketone así como de la glucosa. +En la época de los cazadores-recolectores, era habitual obtener energía tanto de cuerpos cetónicos como de glucosa. -Sin embargo, hoy en día en las sociedades occidentales modernas, los hidratos de carbono son tan fáciles de llegar que la gente puede pasar la mayor parte de su vida sin que sus cuerpos tengan que recurrir a los cuerpos de ketone para obtener energía. +Sin embargo, en las sociedades occidentales modernas los carbohidratos están tan al alcance de la mano que muchas personas pasan la mayor parte de su vida sin que su cuerpo necesite recurrir a los cuerpos cetónicos. -Un área activa de investigación trata sobre qué procesos tiene el cuerpo que sólo están activos cuando el cuerpo está consumiendo principalmente cuerpos de cetona. +Un área de investigación activa estudia qué procesos del cuerpo solo se activan cuando este consume principalmente cuerpos cetónicos. -## Etapas de Rápida: +## Etapas del ayuno -1. **Quema de glucosa:** Al principio tu cuerpo consumirá la glucosa que ya está libre en tu sangre. Usted puede haber oído que durante el ejercicio cardiovascular se tarda 20 minutos en agotar toda su glucosa. Para los atletas resistentes como los corredores marathon, se aconseja que consuman más glucosa cada 20 minutos si es posible. Como ocurre con todos los números aquí, se trata de una estimación áspera, una buena regla general si se quiere. Esto se verá afectado por muchos factores: tu propio cuerpo, la actividad que estás haciendo, cuánta glucosa ya tenías debido a tu dieta, etc. Pero es una estimación bastante buena, así que iremos con ella. -2. **Descomposición de glucógeno:** A medida que la glucosa en tu sangre se agota, tu cuerpo lo reabastecerá al romper tus tiendas de glicogeno. Las personas que siguen las dietas bajas de carbono tendrán tiendas de glicogeno más pequeñas, por lo que esta fase puede variar ampliamente. Alguien con una dieta baja de glúcido puede completar esta etapa en tan solo unas horas, mientras que alguien con una dieta alta de glúcido que no hace ejercicio regularmente podría tomar entre 24 y 48 horas para agotar sus tiendas. El ejercicio durante esta etapa lo acelerará forzando a su cuerpo a quemar glucosa más rápido. -3. **Desglose de Tejidos Adipose:** A medida que tus tiendas de glicogeno empiezan a agotarse, tu cuerpo empezará a cambiar para romper el tejido adiposo (_grasa_) en cuerpos de ketone. Este es el comienzo de la "quema de grasa". -4. **Ketosis:** Esta es la primera etapa que parece mostrar más beneficios que la simple restricción de calorías. La ketosis es el estado de los cuerpos de ketona siendo la fuente primaria de energía en tu sangre. Aunque todavía no se conoce el mecanismo de acción, este Estado parece tener beneficios significativos para las enfermedades relacionadas con la inflamación y la inflamación, como la gota. -5. **Autofagia:** El cuerpo tiene otro mecanismo para recolectar energía. En esta etapa su cuerpo comenzará a consumir células viejas o dañadas para obtener energía. Este ámbito de la investigación está todavía en pañales, pero parece que se trata de un mecanismo importante para que el cuerpo resuelva problemas. Los cánceres son simplemente células dañadas que no saben cuándo dejar de dividir. Si un cuerpo entra regularmente a la autofagia, parece que esas células dañadas se descomponen por energía antes de que tengan tiempo para convertirse en un problema. Otra área de investigación está en torno a la Enfermedad del heimer. Sabemos que la causa del heimer es una acumulación de amiloides de placa en el cerebro, por lo que cualquier forma de prevenir esas acumulaciones debería evitar que la enfermedad se arraigue en primer lugar. La autofagia puede hacer justamente eso, consumiendo la placa antes de que se convierta en un problema. Adicionalmente, algunos lugares que almacenan glicogeno y tejidos adiposos están bien en pequeñas cantidades, pero se convierten en un problema cuando esas tiendas nunca se agotan. La cirrosis del hígado, por ejemplo, puede ser causada por depósitos de grasa en el hígado impidiendo su función normal. Si a menudo bebes alcohol y no sigues una dieta de glúcido bajo, el glucógeno almacenado en el hígado rara vez se agotará. -6. **Autofagia óptima:** Este es probablemente el área de investigación más reciente, así que toma esto con un grano de sal. Pero cuando su cuerpo ha estado en autofagia durante un período de tiempo suficiente, parece que se activan más sistemas de “reparación”. El mecanismo de acción aún no es conocido por ello. Pero los estudios han demostrado que se reparan daños al ADN, como la longitud de los telómetros en los extremos de su ADN, efectivamente revertir el daño de ADN relacionado con la edad y reducir su edad biológica. Parece no ser sólo la autofagia que activa estos sistemas, estresar el cuerpo de diversas maneras puede desencadenarlos. Los atletas de resistencia pueden activar estos sistemas, que pueden tener sentido ya que parece que nuestros antepasados eran cazadores de resistencia, corriendo durante horas mientras acechaban presas. +1. **Quema de glucosa.** Al principio, el cuerpo consume la glucosa que ya circula libre en la sangre. Quizá hayas oído que, durante el ejercicio cardiovascular, se tardan unos 20 minutos en agotarla por completo. Por eso a los atletas de resistencia, como los corredores de maratón, se les recomienda ingerir más glucosa cada 20 minutos si es posible. Como con todas las cifras aquí mencionadas, se trata de una estimación aproximada —una regla general útil, no una ley fija—, ya que factores como tu propio metabolismo, la actividad realizada o cuánta glucosa tenías disponible según tu dieta pueden alterarla. Aun así, es una estimación razonable, así que la usaremos como referencia. +2. **Descomposición del glucógeno.** A medida que la glucosa en sangre se agota, el cuerpo la repone descomponiendo sus reservas de glucógeno. Quienes siguen dietas bajas en carbohidratos tienen reservas más pequeñas, por lo que esta etapa puede variar mucho: alguien con una dieta baja en carbohidratos puede completarla en solo un par de horas, mientras que alguien con una dieta alta en carbohidratos y sedentario puede tardar entre 24 y 48 horas en agotarlas. El ejercicio durante esta fase la acelera, ya que obliga al cuerpo a quemar glucosa más rápido. +3. **Descomposición del tejido adiposo.** Cuando las reservas de glucógeno empiezan a agotarse, el cuerpo comienza a descomponer el tejido adiposo (grasa) en cuerpos cetónicos. Aquí empieza propiamente la "quema de grasa". +4. **Cetosis.** Es la primera etapa que parece ofrecer beneficios más allá de la simple restricción calórica. La cetosis es el estado en el que los cuerpos cetónicos se convierten en la principal fuente de energía en sangre. Aunque todavía no se conoce el mecanismo exacto, este estado parece tener beneficios significativos frente a enfermedades relacionadas con la inflamación, como la gota. +5. **Autofagia.** El cuerpo cuenta con otro mecanismo para obtener energía: en esta etapa comienza a consumir células viejas o dañadas. Esta línea de investigación aún está en pañales, pero todo indica que se trata de un mecanismo clave para que el organismo se autorregule. El cáncer, en esencia, son células dañadas que no saben cuándo dejar de dividirse; si un cuerpo entra regularmente en autofagia, esas células dañadas parecen descomponerse para obtener energía antes de convertirse en un problema. Otra línea de estudio gira en torno al alzhéimer: sabemos que su causa es la acumulación de placas de amiloide en el cerebro, así que cualquier mecanismo que prevenga esa acumulación debería frenar la enfermedad antes de que se instale. La autofagia podría hacer justamente eso, consumiendo la placa antes de que se vuelva un problema. Además, algunos depósitos de glucógeno y grasa son inofensivos en pequeñas cantidades, pero se convierten en un problema cuando nunca se agotan. La cirrosis hepática, por ejemplo, puede deberse a depósitos de grasa que impiden el funcionamiento normal del hígado: si bebes alcohol con frecuencia y no sigues una dieta baja en carbohidratos, el glucógeno almacenado en el hígado rara vez llega a agotarse. +6. **Autofagia óptima.** Probablemente la línea de investigación más reciente, así que conviene tomarla con reservas. Cuando el cuerpo permanece en autofagia durante el tiempo suficiente, parecen activarse sistemas de "reparación" adicionales, cuyo mecanismo tampoco se conoce todavía. Sin embargo, estudios han mostrado reparación del daño en el ADN —por ejemplo, en la longitud de los telómeros—, revirtiendo efectivamente el daño genético asociado a la edad y reduciendo la edad biológica. Al parecer no solo la autofagia activa estos sistemas: someter al cuerpo a distintos tipos de estrés físico también puede desencadenarlos. Los atletas de resistencia podrían activarlos, lo cual tendría sentido si consideramos que nuestros ancestros eran cazadores de resistencia que corrían durante horas mientras acechaban a sus presas. -## Fuentes: +## Fuentes -- [Cómo ralentizar el envejecimiento (e incluso revertirlo)](https://www.youtube.com/watch?v=QRt7LjqJ45k) (_Youtube_) -- [Efectos de Disensión Intermitente en Salud, Envejecimiento y Enfermedad](https://www.nejm.org/action/showFullText?downloadfile=showFullText&downloadfile=showFullText&doi=10.1056/nejmra1905136) (_Revista de Nueva Inglaterra de Medicina_) \ No newline at end of file +- [Cómo ralentizar el envejecimiento (e incluso revertirlo)](https://www.youtube.com/watch?v=QRt7LjqJ45k) (*YouTube*) +- [Effects of Intermittent Fasting on Health, Aging, and Disease](https://www.nejm.org/action/showFullText?downloadfile=showFullText&downloadfile=showFullText&doi=10.1056/nejmra1905136) (*New England Journal of Medicine*) \ No newline at end of file diff --git a/app/src/main/res/raw-es/tips.md b/app/src/main/res/raw-es/tips.md index 2e79bd52..25380c5c 100644 --- a/app/src/main/res/raw-es/tips.md +++ b/app/src/main/res/raw-es/tips.md @@ -1,37 +1,37 @@ # Consejos -¡La limpieza puede ser difícil! He aquí algunos punteros para ayudar. +¡Ayunar puede ser todo un reto! Aquí tienes algunas pautas que te ayudarán a llevarlo mejor. -## Iniciar lentamente: +## Empieza con calma -- Pruebe un par de ayunas de 16 horas para empezar, luego mueva a 30 horas de ayuno, y así sucesivamente. -- Cuanto más rápido vayas, más fácil obtendrán las cosas. -- Cuando empiezas a hacer un ayuno de varios días, generalmente el primer día es más difícil que el segundo día. +- Prueba primero un par de ayunos de 16 horas y, una vez los domines, progresa hacia ayunos de 30 horas, y así sucesivamente. +- Cuanto más ayunes, más fácil se volverá todo. +- Al iniciar un ayuno de varios días, el primero suele costar más que el segundo. -## Alimentos de Calorie Cero +## Alimentos y bebidas sin calorías -- **Beba mucha agua:** Además de ser bueno para ti, llenar tu estómago con un montón de volumen te ayudará a sentirte lleno durante un tiempo. -- **Beber cero bebidas calorías:** - - ¡Las aguas de seltzer saboreadas son excelentes para esto! - - Té no endulzado (_Caliente o hielo_) - - Agua con zumo de limón o limón +- **Bebe agua en abundancia:** además de sentarte bien, llenar el estómago de líquido te ayudará a sentirte saciado durante más tiempo. +- **Recurre a bebidas sin calorías:** + - Las aguas con gas aromatizadas son una opción magnífica. + - Infusiones o té sin azúcar, tanto calientes como fríos. + - Agua con unas gotas de limón o de lima. -## Alimentos muy bajos de Calorie +## Alimentos muy bajos en calorías -Usted puede comer un par de calorías durante su ayuno y aún así ser igual de eficaz. Intenta mantenerlo por debajo de 100 -calorías al día. Para este fin, esta es una receta que encuentro muy efectiva en "sentirse comida" mientras que solo es de 5 -calorías por porción: +Puedes permitirte unas pocas calorías durante el ayuno sin perder por ello su efectividad. Procura no superar las 100 calorías diarias. -**Caldo picante:** +Con ese fin, aquí tienes una receta que resulta muy eficaz para engañar al hambre, con apenas 5 calorías por ración: -- 1 tsp de pollo -- 8oz (1 taza) agua de ebullición -- Rayo de salsa de habanero caliente -- Zumo de cal +**Caldo picante** -Para Vegan o Vegan más rápido, usted puede sustituir el pollo de illon por el de la verdura. +- 1 cucharadita de caldo de pollo en polvo +- 1 taza (240 ml) de agua hirviendo +- Unas gotas de salsa picante de habanero +- Un chorrito de zumo de lima -## Supresores de Appetitos +Si sigues una dieta vegana, o simplemente prefieres una versión apta para veganos, basta con sustituir el caldo de pollo por caldo de verduras. -- **Coffe:** más que llenar tu estómago un poco para sentirte lleno, reprimirá activamente tu apetito por un tiempo -- **Salsa caliente:** Normalmente estas son calorías cero, y también ayudarán a evitar los sentimientos de hambre \ No newline at end of file +## Supresores del apetito + +- **Café:** además de aportar cierta sensación de saciedad, ayuda a frenar el apetito durante un tiempo. +- **Salsa picante:** por lo general no aporta calorías y también contribuye a mantener el hambre a raya. \ No newline at end of file diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 5cc2cfd9..5f91b99a 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -1,7 +1,7 @@ Fast Track - Share + Teilen Info Über Exportieren Sie das Logbuch @@ -171,7 +171,7 @@ steht noch am Anfang, aber es scheint, dass dies ein wichtiger Mechanismus für den Körper ist, um Probleme zu stoppen, bevor sie auftreten. Zum Beispiel sind die Krebsraten bei Menschen, die regelmäßig in die Autophagie eintreten, niedriger. Darüber hinaus haben einige Studien auch niedrigere Raten von Alzheimer gezeigt. - Body-Mass-Index + Body-Maß-Index Eine grobe Schätzung des gesunden Gewichts, das ein durchschnittlicher Erwachsener haben sollte. Es wird einfach berechnet, indem das Gewicht durch die Höhe geteilt wird.\n\nOft als ungenau kritisiert, ist es wahr, dass es Faktoren gibt, die dazu führen können, dass es irreführend ist. Zum @@ -183,7 +183,7 @@ im völligen Ruhezustand verbrennen wird. Wenn Sie den ganzen Tag im Bett liegen würden, wäre dies die Anzahl der Kalorien, die Sie ohne Gewichtszunahme essen könnten.\n\nDiese Zahl ist entscheidend für Menschen, die durch Einschränkung der täglichen Kalorienaufnahme diäten. - Cool! + Alles klar! Info Tipps Fasteninformationen @@ -237,4 +237,74 @@ Lebenslange Statistiken Liste Kalender + Gerade gestartet + Notizen (optional) + Wie ist es gelaufen? Etwas, das du dir zu diesem Fasten merken möchtest? + Du hast gerade ein Fasten beendet — gut gemacht! + 📝 + Fasten: %1$s + in %1$s + Zeit seit deinem letzten Fasten: %1$s + Ich faste seit %1$s! Mein Körper nutzt gerade %2$s als Energiequelle. + Ich habe %1$s gefastet! + %1$d–%2$d h + %1$d h+ + Jetzt starten + Ich habe früher begonnen… + Jetzt beenden + Ich habe früher aufgehört… + Fastenphasen + Automatisch: nur aktive Phasen zeigen + Zeigt jede Phase, sobald sie beginnt. Überschreibt die einzelnen Schalter unten. + Fettverbrennung anzeigen + Zeigt den Countdown zur Fettverbrennung im Fasten-Bildschirm. + Ketose anzeigen + Zeigt den Countdown zur Ketose im Fasten-Bildschirm. + Autophagie anzeigen + Zeigt den Countdown zur Autophagie im Fasten-Bildschirm. + Blutzucker steigt + Blutzucker sinkt + Blutzucker stabilisiert sich + Gluconeogenese + Fettverbrennung + Ketose + Autophagie + Wachstumshormon-Schub + Insulinsensitivität + Regeneration der Immunzellen + Du hast gerade gegessen, und dein Blutkreislauf merkt es. Der Blutzucker steigt — bei gesunden Menschen auf einen Höchstwert unter ~140 mg/dl innerhalb von 30–60 min — und deine Bauchspeicheldrüse antwortet mit Insulin, dem Hormon, das diesen Zucker in die Zellen bringt und den Überschuss als Glykogen einlagert.\n\nDas ist der Sättigungszustand: Energie im Überfluss, Speichern hat Vorrang, die Fettverbrennung ist ausgeschaltet.\n\nJetzt heißt es nur verdauen. Die Uhr läuft. + Die Aufnahme klingt ab, und das Blatt wendet sich. Insulin sinkt, Glukagon steigt, und deine Leber beginnt, gespeichertes Glykogen freizusetzen, um den Blutzucker um ~70–100 mg/dl stabil zu halten.\n\nDu bist vom Sättigungszustand in den postresorptiven Zustand gewechselt — die stille Übergabe, bei der Körper aufhört zu speichern und anfängt zu verbrauchen.\n\nDieses erste Hungergefühl ist vor allem der fallende Insulinspiegel. Es geht vorbei. + Jetzt ist deine Leber der einzige Lieferant: Sie gibt tröpfchenweise Glukose aus dem Glykogen ab (~100 g Reserve), um dich stabil zu halten. Allein dein Gehirn verbraucht ~120 g Glukose pro Tag, diese Stabilität ist also keine Kleinigkeit.\n\nKein Einbruch, keine Spitze — die Glykogenolyse hält die Linie und hält die Unterzuckerung fern.\n\nDie Konzentration fühlt sich hier oft ungewöhnlich klar an. Das ist keine Einbildung, sondern gleichmäßiger Brennstoff. + Das Glykogen wird knapp, also übernimmt die Leber ein zweites Handwerk: Glukose von Grund auf herzustellen — aus Laktat, Glyzerin, Aminosäuren — ein Vorgang namens Gluconeogenese. Nach ~16 h liefert sie bereits rund die Hälfte deiner Glukose (Cahill, Annu Rev Nutr 2006).\n\nDas ist metabolische Flexibilität in sichtbarer Form: Dein Körper lässt das Gehirn nicht hungern.\n\nDer Hunger und das leichte Tief, das du spürst, sind der umgelegte Schalter. Als Nächstes kommt das Fett. + Das Insulin ist endlich niedrig genug, um deine Fettdepots zu öffnen. Die Lipolyse setzt Fettsäuren frei, die Leber beginnt, sie in Ketone umzuwandeln, und du wechselst vom Zucker- zum Fettverbrenner.\n\nJede Stunde hier verbessert deine Insulinsensitivität ein Stück und baut Fettreserven ab — der metabolische Kern, warum Fasten wirkt.\n\nEin metallischer Geschmack oder leichte Kopfschmerzen sind erste Ketone. Das Feuer brennt. + Die Ketone im Blut überschreiten ~0,5 mmol/l — ernährungsbedingte Ketose — und steigen weiter. Dein Gehirn kann Fettsäuren nicht direkt verwerten, läuft aber wunderbar mit diesem Ketonstrom, der verhindert, dass Muskeln für Glukose abgebaut werden.\n\nViele berichten von einer ungewöhnlichen geistigen Klarheit und einem Hunger, der sich einfach abschaltet — Ketone selbst dämpfen den Appetit.\n\nVierundzwanzig Stunden. Dein Körper bevorzugt jetzt den Brennstoff, den er selbst herstellt. + Ein ganzer Tag, und die Selbstreinigung verstärkt sich: Autophagie — Zellen zerlegen beschädigte Proteine und Organellen und recyceln die Teile (der Mechanismus, der Ohsumi 2016 den Nobelpreis einbrachte). Beim Menschen wird der zeitliche Ablauf noch erforscht, doch das Fasten aktiviert diesen Weg deutlich.\n\nStell es dir als Wartung vor, die man nicht kaufen kann — eine Reinigung, die mit Langlebigkeit und geringerem Krankheitsrisiko verbunden ist.\n\nMüdigkeit oder Kopfschmerzen? Salz und Wasser. Hier wird tiefe Arbeit geleistet. + Zwei Tage, und das Wachstumshormon schießt nach oben — ein ~2-tägiges Fasten kann seine Ausschüttung um etwa das 5-Fache steigern (Ho, J Clin Invest 1988). Seine Aufgabe jetzt: Muskelmasse schützen und den Fettstoffwechsel stärker antreiben.\n\nSo verbrennst du weiter Fett und bewahrst zugleich den Muskel, den du zu verlieren glaubtest — eine wirklich elegante Anpassung.\n\nDieser unerwartete Energieschub, den viele hier beschreiben? Das ist das Hormon. + Wenn das Insulin tagelang niedrig bleibt, sprechen deine Zellen wieder neu darauf an — die Insulinsensitivität stellt sich auf ein gesünderes Niveau zurück, das Gegenteil der Resistenz, die dem Typ-2-Diabetes vorausgeht.\n\nDer Blutzucker bleibt in einem ruhigen, engen Bereich, und die Sättigung stellt sich leicht ein.\n\nDu justierst ein System, das die meisten Menschen nie bewusst berühren. + Drei Tage: seltene Höhen. Längeres Fasten kann den Körper dazu bringen, gealterte Immunzellen abzubauen und aus hämatopoetischen Stammzellen neue zu bilden, während die IGF-1- und PKA-Signale sinken (Cheng, Cell Stem Cell 2014).\n\nDie Entzündung klingt weiter ab; manche beschreiben ein ruhiges, klares Wohlbefinden.\n\nWie weit du von hier aus auch gehst, du gehst erneuert — und brich dein Fasten sanft. Sei stolz. + %1$d Fasten importiert.\nÜberlappende Einträge ÜBERSPRUNGEN: %2$d + Exportformat + CSV (Tabelle) + iCalendar (.ics) + ActivityStreams 2.0 (JSON-LD) + Fasten gesamt + Gesamte Fastenzeit + Längstes Fasten + Dauer + Stunden + Minuten + Ein Fasten für %1$s hinzufügen? + Fasten hinzufügen + Logbuch leeren… + Gesamtes Logbuch löschen? + Dies löscht endgültig alle %1$d protokollierten Fasten. Dies kann nicht rückgängig gemacht werden. + Alle %1$d löschen + Die Autophagie ist in vollem Gange! Mach weiter so! + Datums- & Zeitformat + Kompakt + Kompakt + Wochentag + System kurz + System mittel + System lang + ISO 8601 diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 49addb9f..7083bb86 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -5,7 +5,7 @@ Información Acerca de Exportar Registro - Produce un archivo CVS de tu libro de registro para la copia de seguridad + Produce un archivo CSV de tu libro de registro para la copia de seguridad Importar Logbook Poblar tu libro de registro desde una exportación anterior Registro importado con éxito @@ -16,7 +16,7 @@ Bitácora Mostrar notificación de limpieza - Proporciona información en tiempo real durante un rápido + Muestra información en tiempo real durante el ayuno Alertas de etapa Recibe notificaciones al entrar en nuevas etapas de ayuno @@ -34,27 +34,27 @@ Esta es una nueva área de estudio que está mostrando muchos beneficios más allá de la simple pérdida de peso.\n\nPara explorar lo que sabemos sobre el ayuno y lo que la investigación está diciendo, ¡consulta la sección de Información de esta aplicación! - ¡Eso es todo! - Fast Track can show you helpful notifications to keep you motivated during your - fast.\n\nYou’ll see your current fasting time, what stage you’re in, and what energy mode your body is - using.\n\nThis helps you stay on track without having to open the app! + Mantente motivado con notificaciones + Fast Track puede mostrarte notificaciones útiles para mantenerte motivado durante tu + ayuno.\n\nVerás tu tiempo de ayuno actual, en qué etapa te encuentras y qué modo de energía está usando tu + cuerpo.\n\n¡Esto te ayuda a mantenerte en el camino sin tener que abrir la aplicación! ¡Eso es todo! ¡Ahora empieza a ayunar! Adam Brown Desarrollador móvil Escalador, montañista, ingeniero de software y defensor del código abierto. - Discordia + Discord Ayuno Registro Perfil Rápido - Comenzar Rápido - Comienza Rápido Ahora - Terminar Rápido + Comenzar Ayuno + Comienza Ayuno Ahora + Terminar Ayuno Modo de energía: %1$s Glucosa Cuerpos cetónicos - ¿Comenzar rápido? + ¿Comenzar ayuno? Ahora Ya Comenzó No @@ -113,10 +113,10 @@ Establecer Fin Rápido Calcular Autofagia: - Cetoacidosis: + Cetosis: Quema de Grasa: Ayuno - Su cuerpo está consumiendo sus almacenes de glucosa, ¡siga adelante! + Tu cuerpo está consumiendo sus reservas de glucosa. ¡Sigue adelante! Quema de grasa ¡Tus reservas de glucosa están agotadas! Tu cuerpo ahora está quemando grasa para obtener energía. Un entrenamiento en las próximas horas sería óptimo. @@ -128,11 +128,11 @@ Autofagia ¡Tu cuerpo ha comenzado la autofagia! Autofagia Óptima - ¡La autofagia está en pleno apogeo! ¡Sigue así! Entre ahora y 72 + La autofagia está a pleno rendimiento! ¡Buen trabajo, sigue así! Entre ahora y 72 horas, la autofagia será muy efectiva. Quema de Glucosa Quema de grasa - Cetoacidosis + Cetosis Autofagia Autofagia Óptima ¡He estado ayunando durante %1$d horas y %2$d minutos! Mi cuerpo está consumiendo @@ -145,7 +145,7 @@ Etapa Rápida: Quema de Grasas Has agotado tus reservas de glucógeno y tu cuerpo ahora está consumiendo grasa para obtener energía. - Fase Rápida: Cetoacidosis + Fase Rápida: Cetosis Tu cuerpo ha descompuesto suficientes reservas de grasa que has entrado en cetosis. Fase Rápida: Autofagia @@ -154,10 +154,10 @@ Etapa rápida: Autofagia óptima Tu cuerpo ha estado en autofagia durante mucho tiempo, y tu salud celular está viendo grandes beneficios. - Estado de Fasting - Ongoing notification showing your current fasting status and - progress. - Rápido: %1$s horas + Estado de ayuno + Notificación permanente que muestra tu estado de ayuno actual y + progreso. + Ayuno: %1$s horas Modo Energía: %1$s Quema de grasa En esta etapa, tu cuerpo ha agotado sus reservas de glucosa. La glucosa @@ -166,7 +166,7 @@ adiposo (grasa) en cuerpos cetónicos. Además de poder consumir glucosa para obtener energía, tu cuerpo también puede consumir cuerpos cetónicos para obtener energía, y a medida que las reservas de glucosa se agotan, tu cuerpo cambiará su modo de energía a cuerpos cetónicos. - Cetoacidosis + Cetosis En esta etapa, tu cuerpo ha estado descomponiendo grasa el tiempo suficiente como para que esté siendo alimentado principalmente por cuerpos cetónicos en lugar de glucosa.\n\nAdemás de los beneficios de pérdida de grasa que esto implica, los estudios han demostrado @@ -245,4 +245,74 @@ Estadísticas de por vida Lista Calendario + Recién empezado + Notas (opcional) + ¿Qué tal ha ido? ¿Algo que quieras recordar de este ayuno? + Acabas de terminar un ayuno — ¡bien hecho! + 📝 + Ayuno: %1$s + en %1$s + Tiempo desde tu último ayuno: %1$s + ¡Llevo ayunando %1$s! Ahora mismo mi cuerpo usa %2$s como fuente de energía. + ¡He ayunado %1$s! + %1$d–%2$d h + %1$d h+ + Empezar ahora + Empecé antes… + Terminar ahora + Terminé antes… + Fases del ayuno + Automático: mostrar solo las fases activas + Muestra cada fase cuando empieza. Anula los ajustes individuales de abajo. + Mostrar Quema de grasa + Muestra la cuenta atrás de Quema de grasa en la pantalla de ayuno. + Mostrar Cetosis + Muestra la cuenta atrás de Cetosis en la pantalla de ayuno. + Mostrar Autofagia + Muestra la cuenta atrás de Autofagia en la pantalla de ayuno. + Sube el azúcar en sangre + Baja el azúcar en sangre + El azúcar en sangre se estabiliza + Gluconeogénesis + Quema de grasa + Cetosis + Autofagia + Pico de hormona del crecimiento + Sensibilidad a la insulina + Regeneración de células inmunitarias + Acabas de comer y tu torrente sanguíneo lo nota. La glucosa sube — en personas sanas alcanza un pico por debajo de ~140 mg/dL en 30–60 min — y tu páncreas responde con insulina, la hormona que lleva ese azúcar a las células y guarda el excedente como glucógeno.\n\nEs el estado alimentado: hay energía de sobra, la prioridad es almacenar y la quema de grasa está apagada.\n\nSolo queda digerir. El reloj ha empezado a correr. + La absorción se va agotando y la marea cambia. La insulina baja, el glucagón sube y tu hígado empieza a liberar glucógeno para mantener la glucosa en torno a ~70–100 mg/dL.\n\nHas pasado del estado alimentado al posabsortivo: el relevo silencioso en el que el cuerpo deja de almacenar y empieza a gastar.\n\nEse primer atisbo de hambre es, sobre todo, la insulina que baja. Se pasa. + Ahora tu hígado es el único proveedor: va soltando glucosa del glucógeno (~100 g de reserva) para mantenerte estable. Solo tu cerebro consume ~120 g de glucosa al día, así que esta estabilidad no es poca cosa.\n\nNi bajones ni picos: la glucogenólisis aguanta el tipo y mantiene a raya la hipoglucemia.\n\nAquí la concentración se siente inusualmente limpia. No es imaginación; es combustible constante. + El glucógeno escasea, así que el hígado se saca un segundo oficio: fabricar glucosa desde cero — lactato, glicerol, aminoácidos —, un proceso llamado gluconeogénesis. Hacia las ~16 h ya aporta casi la mitad de tu glucosa (Cahill, Annu Rev Nutr 2006).\n\nEs la flexibilidad metabólica hecha visible: tu cuerpo no va a dejar que al cerebro le falte energía.\n\nEl hambre y ese ligero bajón que notas son el interruptor cambiando de posición. Ahora toca la grasa. + Por fin la insulina está lo bastante baja como para abrir tus reservas de grasa. La lipólisis libera ácidos grasos, el hígado empieza a convertirlos en cetonas y pasas de quemar azúcar a quemar grasa.\n\nCada hora aquí mejora un poco tu sensibilidad a la insulina y reduce tus reservas de grasa: el núcleo metabólico de por qué funciona el ayuno.\n\nUn sabor metálico o un leve dolor de cabeza son las primeras cetonas. El fuego está encendido. + Las cetonas en sangre superan ~0,5 mmol/L — cetosis nutricional — y siguen subiendo. Tu cerebro no puede oxidar ácidos grasos directamente, pero funciona de maravilla con este flujo de cetonas, que evita sacrificar músculo para fabricar glucosa.\n\nMucha gente nota una claridad mental inusual y un hambre que se apaga sin más: las propias cetonas reducen el apetito.\n\nVeinticuatro horas. Tu cuerpo ya prefiere el combustible que él mismo fabrica. + Un día entero y la autolimpieza se intensifica: la autofagia — tus células desmontan proteínas y orgánulos dañados y reciclan las piezas — (el mecanismo que le valió a Ohsumi el Nobel de 2016). En humanos aún se están afinando los tiempos, pero el ayuno activa esta vía con claridad.\n\nPiénsalo como un mantenimiento que no se puede comprar: una limpieza asociada a la longevidad y a un menor riesgo de enfermedad.\n\n¿Cansancio o dolor de cabeza? Sal y agua. Se está haciendo un trabajo profundo. + Dos días y la hormona del crecimiento se dispara: un ayuno de ~2 días puede multiplicar su secreción por unas 5 veces (Ho, J Clin Invest 1988). Su cometido ahora: proteger la masa muscular y acelerar el metabolismo de la grasa.\n\nAsí sigues quemando grasa mientras conservas el músculo que esperarías perder: una adaptación realmente elegante.\n\n¿Ese subidón de energía inesperado que describe la gente aquí? Es la hormona hablando. + Con la insulina baja durante días, tus células vuelven a responderle: la sensibilidad a la insulina se reajusta hacia un nivel más sano, lo contrario de la resistencia que precede a la diabetes tipo 2.\n\nLa glucosa se mantiene en una franja tranquila y estrecha, y la saciedad llega con facilidad.\n\nEstás afinando un sistema que casi nadie ajusta a propósito. + Tres días: territorio poco común. El ayuno prolongado puede llevar al cuerpo a eliminar células inmunitarias envejecidas y regenerar otras nuevas a partir de células madre hematopoyéticas, a medida que baja la señalización de IGF-1 y PKA (Cheng, Cell Stem Cell 2014).\n\nLa inflamación sigue cediendo; algunas personas describen una sensación de bienestar serena y lúcida.\n\nHasta donde decidas llegar, llegas renovado. Y rompe el ayuno con calma. Siéntete orgulloso. + %1$d ayunos importados.\nEntradas superpuestas OMITIDAS: %2$d + Formato de exportación + CSV (hoja de cálculo) + iCalendar (.ics) + ActivityStreams 2.0 (JSON-LD) + Ayunos totales + Tiempo total en ayunas + Ayuno más largo + Duración + Horas + Minutos + ¿Añadir un ayuno para %1$s? + Añadir ayuno + Vaciar libreta… + ¿Borrar toda la libreta? + Esto elimina permanentemente todos los %1$d ayunos registrados. Esta acción no se puede deshacer. + Borrar todos (%1$d) + La autofagia está a pleno rendimiento! ¡Buen trabajo, sigue así! + Formato de fecha y hora + Compacto + Compacto + día de semana + Sistema corto + Sistema medio + Sistema largo + ISO 8601 diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 6dc76bfe..29d1b011 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -5,7 +5,7 @@ Info À propos Exporter le carnet de bord - Produire un fichier CVS de votre livre de bord pour la sauvegarde + Produire un fichier CSV de votre carnet de bord pour la sauvegarde Importer le registre Remplir votre journal de bord à partir d\'une exportation précédente Journal importé avec succès @@ -34,16 +34,16 @@ C\'est un nouveau domaine d\'étude qui montre de nombreux avantages au-delà de la simple perte de poids.\n\nPour explorer ce que nous savons sur le jeûne et ce que la recherche dit, consultez la section Infos de cette application ! - C\'est tout ! - Fast Track can show you helpful notifications to keep you motivated during your - fast.\n\nYou’ll see your current fasting time, what stage you’re in, and what energy mode your body is - using.\n\nThis helps you stay on track without having to open the app! + Restez motivé avec les notifications + Fast Track peut afficher des notifications utiles pour vous motiver pendant votre + jeûne.\n\nVous verrez votre temps de jeûne actuel, à quelle étape vous en êtes, et quel mode d\'énergie votre + corps utilise.\n\nCela vous aide à rester sur la bonne voie sans avoir à ouvrir l\'application ! Voilà! Maintenant, commencez à jeûner! Adam Brown Développeur Mobile Grimpeur, alpiniste, ingénieur logiciel et défenseur de l\'open source. - Discord. + Discord Jeûne Journal Profil @@ -76,7 +76,7 @@ * Démarré : %1$s %1$d heures - %1$d heures + Cétose : %1$d heures Autophagie : %1$d heures Âge Années @@ -247,4 +247,74 @@ Statistiques à vie Liste Calendrier + Tout juste commencé + Notes (facultatif) + Comment ça s\'est passé ? Quelque chose à retenir de ce jeûne ? + Tu viens de terminer un jeûne — bravo ! + 📝 + Jeûne : %1$s + dans %1$s + Temps depuis ton dernier jeûne : %1$s + Je jeûne depuis %1$s ! Mon corps utilise actuellement %2$s comme source d\'énergie. + J\'ai jeûné %1$s ! + %1$d–%2$d h + %1$d h+ + Commencer maintenant + J\'ai commencé plus tôt… + Terminer maintenant + J\'ai arrêté plus tôt… + Phases du jeûne + Auto : n\'afficher que les phases actives + Affiche chaque phase dès qu\'elle commence. Remplace les réglages individuels ci-dessous. + Afficher Combustion des graisses + Affiche le compte à rebours de la combustion des graisses sur l\'écran de jeûne. + Afficher Cétose + Affiche le compte à rebours de la cétose sur l\'écran de jeûne. + Afficher Autophagie + Affiche le compte à rebours de l\'autophagie sur l\'écran de jeûne. + La glycémie monte + La glycémie baisse + La glycémie se stabilise + Néoglucogenèse + Combustion des graisses + Cétose + Autophagie + Pic d\'hormone de croissance + Sensibilité à l\'insuline + Régénération des cellules immunitaires + Tu viens de manger, et ton sang le sait. La glycémie grimpe — chez les personnes en bonne santé, elle culmine sous ~140 mg/dl en 30–60 min — et ton pancréas répond par l\'insuline, l\'hormone qui fait entrer ce sucre dans les cellules et met le surplus de côté sous forme de glycogène.\n\nC\'est l\'état nourri : l\'énergie abonde, le stockage est prioritaire, la combustion des graisses est à l\'arrêt.\n\nIl n\'y a plus qu\'à digérer. Le chrono est lancé. + L\'absorption s\'essouffle, et la tendance s\'inverse. L\'insuline reflue, le glucagon monte, et ton foie commence à libérer le glycogène stocké pour maintenir la glycémie autour de ~70–100 mg/dl.\n\nTu es passé de l\'état nourri à l\'état post-absorptif — le relais silencieux où le corps cesse de stocker et se met à dépenser.\n\nCette première pointe de faim, c\'est surtout l\'insuline qui baisse. Ça passe. + Ton foie est désormais le seul fournisseur : il distille du glucose à partir du glycogène (~100 g en réserve) pour te garder stable. Ton cerveau à lui seul brûle ~120 g de glucose par jour, alors cette stabilité n\'a rien d\'anodin.\n\nNi chute ni pic — la glycogénolyse tient la ligne et éloigne l\'hypoglycémie.\n\nIci, la concentration paraît souvent étonnamment nette. Ce n\'est pas ton imagination ; c\'est un carburant régulier. + Le glycogène s\'amenuise, alors le foie se lance dans un second métier : fabriquer du glucose de toutes pièces — lactate, glycérol, acides aminés — un processus appelé néoglucogenèse. Vers ~16 h, il fournit déjà près de la moitié de ton glucose (Cahill, Annu Rev Nutr 2006).\n\nC\'est la flexibilité métabolique rendue visible : ton corps ne laissera pas le cerveau manquer.\n\nLa faim et le léger creux que tu ressens, c\'est l\'interrupteur qu\'on bascule. Ensuite, place aux graisses. + L\'insuline est enfin assez basse pour ouvrir tes réserves de graisse. La lipolyse libère des acides gras, le foie commence à les transformer en cétones, et tu passes de brûleur de sucre à brûleur de graisse.\n\nChaque heure ici améliore un peu ta sensibilité à l\'insuline et puise dans tes réserves de graisse — le cœur métabolique de l\'efficacité du jeûne.\n\nUn goût métallique ou un léger mal de tête, ce sont les premières cétones. Le feu est allumé. + Les cétones sanguines franchissent ~0,5 mmol/l — la cétose nutritionnelle — et continuent de grimper. Ton cerveau ne peut pas oxyder directement les acides gras, mais il fonctionne à merveille avec ce flux de cétones, qui évite de sacrifier du muscle pour fabriquer du glucose.\n\nBeaucoup rapportent une clarté mentale inhabituelle et une faim qui s\'éteint d\'elle-même — les cétones émoussent l\'appétit.\n\nVingt-quatre heures. Ton corps préfère désormais le carburant qu\'il fabrique lui-même. + Une journée entière, et l\'auto-nettoyage s\'intensifie : l\'autophagie — les cellules démontent protéines et organites endommagés et en recyclent les pièces (le mécanisme qui a valu à Ohsumi le prix Nobel 2016). Chez l\'humain, la chronologie s\'étudie encore, mais le jeûne active clairement cette voie.\n\nVois-la comme un entretien qui ne s\'achète pas — un nettoyage associé à la longévité et à un risque de maladie plus faible.\n\nFatigue ou mal de tête ? Sel et eau. Un travail profond est en cours. + Deux jours, et l\'hormone de croissance s\'envole — un jeûne de ~2 jours peut multiplier sa sécrétion par environ 5 (Ho, J Clin Invest 1988). Sa mission maintenant : protéger le muscle maigre et pousser plus fort le métabolisme des graisses.\n\nTu continues donc à brûler de la graisse tout en préservant le muscle que tu croyais perdre — une adaptation vraiment élégante.\n\nCe regain d\'énergie inattendu que beaucoup décrivent ici ? C\'est l\'hormone qui parle. + Avec une insuline maintenue basse pendant des jours, tes cellules y redeviennent sensibles — la sensibilité à l\'insuline se réajuste vers un niveau plus sain, l\'inverse de la résistance qui précède le diabète de type 2.\n\nLa glycémie reste dans une plage calme et étroite ; la satiété vient facilement.\n\nTu règles un système que la plupart des gens n\'ajustent jamais volontairement. + Trois jours : un air rare. Le jeûne prolongé peut amener le corps à éliminer des cellules immunitaires vieillissantes et à en régénérer de nouvelles à partir de cellules souches hématopoïétiques, à mesure que la signalisation d\'IGF-1 et de PKA diminue (Cheng, Cell Stem Cell 2014).\n\nL\'inflammation continue de s\'apaiser ; certains décrivent un bien-être calme et lucide.\n\nAussi loin que tu ailles à partir d\'ici, tu en ressors renouvelé — et romps ton jeûne en douceur. Sois fier. + %1$d jeûnes importés.\nEntrées en chevauchement IGNORÉES : %2$d + Format d\'exportation + CSV (feuille de calcul) + iCalendar (.ics) + ActivityStreams 2.0 (JSON-LD) + Total des jeûnes + Durée totale de jeûne + Jeûne le plus long + Durée + Heures + Minutes + Ajouter un jeûne pour %1$s ? + Ajouter le jeûne + Vider le journal… + Supprimer tout le journal ? + Ceci supprime définitivement les %1$d jeûnes enregistrés. Cette action est irréversible. + Tout supprimer (%1$d) + L\'autophagie est en plein essor ! Continuez comme ça ! + Format date & heure + Compact + Compact + jour de semaine + Système court + Système moyen + Système long + ISO 8601 diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index d08fd1fe..9d67c20e 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -5,7 +5,7 @@ Informazioni Informazioni Esporta il Logbook - Produci un file CVS del registro per il backup + Produci un file CSV del registro per il backup Importa Logbook Popolare il registro da un\'esportazione precedente Registro importato con successo @@ -13,7 +13,7 @@ Notifiche Interfaccia - Logbook + Registro Mostra Notifiche Di digiuno Fornisce informazioni in tempo reale durante un veloce @@ -34,10 +34,10 @@ Questa è una nuova area di studio che sta mostrando molti benefici oltre alla semplice perdita di peso.\n\nPer esplorare cosa sappiamo sul digiuno e cosa dicono le ricerche, controlla la sezione Info di questa app! - Questo è tutto! - Fast Track can show you helpful notifications to keep you motivated during your - fast.\n\nYou’ll see your current fasting time, what stage you’re in, and what energy mode your body is - using.\n\nThis helps you stay on track without having to open the app! + Resta motivato con le notifiche + Fast Track può mostrarti notifiche utili per tenerti motivato durante il + digiuno.\n\nVedrai il tuo tempo di digiuno attuale, in che fase ti trovi e quale modalità energetica il tuo + corpo sta usando.\n\nQuesto ti aiuta a rimanere in carreggiata senza dover aprire l\'app! È tutto! Ora vai a iniziare il digiuno! Adam Brown @@ -133,7 +133,7 @@ l\'autofagia sarà molto efficace. Consumo di Glucosio Brucia Grassi - Chetoacidosi + Chetosi Autofagia Autofagia Ottimale Ho digiunato per %1$d ore e %2$d minuti! Il mio corpo sta attualmente consumando %3$s per @@ -155,10 +155,10 @@ Fase digiuno: Autofagia ottimale Il tuo corpo è stato in Autofagia per molto tempo ormai e la tua salute cellulare sta ottenendo grandi benefici. - Stato Di digiuno - Notifica in corso che mostra il tuo attuale stato di digiuno e - <unk> <unk> progresso. - Chiusura: %1$s ore + Stato di digiuno + Notifica continua che mostra lo stato attuale del digiuno e il + progresso. + Digiuno: %1$s ore Modalità Energetica: %1$s Brucia Grassi In questa fase il tuo corpo ha esaurito le riserve di glucosio. Il @@ -244,8 +244,78 @@ Chiaro Scuro Ancora nessuna voce! - Logbook + Registro Statistiche a vita Elenco Calendario + Appena iniziato + Note (facoltativo) + Com\'è andata? Qualcosa da ricordare di questo digiuno? + Hai appena finito un digiuno — bravo! + 📝 + Digiuno: %1$s + tra %1$s + Tempo dal tuo ultimo digiuno: %1$s + Sto digiunando da %1$s! Il mio corpo sta usando %2$s come fonte di energia. + Ho digiunato per %1$s! + %1$d–%2$d h + %1$d h+ + Inizia ora + Ho iniziato prima… + Termina ora + Ho smesso prima… + Fasi del digiuno + Automatico: mostra solo le fasi attive + Mostra ogni fase quando inizia. Sostituisce gli interruttori individuali qui sotto. + Mostra Combustione dei grassi + Mostra il conto alla rovescia della combustione dei grassi nella schermata del digiuno. + Mostra Chetosi + Mostra il conto alla rovescia della chetosi nella schermata del digiuno. + Mostra Autofagia + Mostra il conto alla rovescia dell\'autofagia nella schermata del digiuno. + La glicemia sale + La glicemia scende + La glicemia si stabilizza + Gluconeogenesi + Combustione dei grassi + Chetosi + Autofagia + Picco dell\'ormone della crescita + Sensibilità all\'insulina + Rigenerazione delle cellule immunitarie + Hai appena mangiato, e il tuo sangue lo sa. La glicemia sale — nelle persone sane raggiunge un picco sotto i ~140 mg/dl entro 30–60 min — e il pancreas risponde con l\'insulina, l\'ormone che porta lo zucchero nelle cellule e mette da parte l\'eccesso come glicogeno.\n\nÈ lo stato nutrito: energia in abbondanza, la priorità è immagazzinare, la combustione dei grassi è spenta.\n\nNon resta che digerire. Il cronometro è partito. + L\'assorbimento si esaurisce e la marea cambia. L\'insulina cala, il glucagone sale e il fegato inizia a rilasciare il glicogeno immagazzinato per tenere la glicemia intorno a ~70–100 mg/dl.\n\nSei passato dallo stato nutrito a quello post-assorbimento — il passaggio silenzioso in cui il corpo smette di immagazzinare e inizia a spendere.\n\nQuel primo accenno di fame è soprattutto l\'insulina che scende. Passa. + Ora il fegato è l\'unico fornitore: rilascia glucosio dal glicogeno (~100 g di riserva) per tenerti stabile. Il solo cervello brucia ~120 g di glucosio al giorno, quindi questa stabilità non è cosa da poco.\n\nNé cali né picchi — la glicogenolisi tiene la linea e tiene lontana l\'ipoglicemia.\n\nQui la concentrazione sembra spesso insolitamente limpida. Non è immaginazione; è carburante costante. + Il glicogeno scarseggia, così il fegato prende un secondo mestiere: costruire glucosio da zero — lattato, glicerolo, amminoacidi — un processo chiamato gluconeogenesi. Verso le ~16 h fornisce già circa metà del tuo glucosio (Cahill, Annu Rev Nutr 2006).\n\nÈ la flessibilità metabolica resa visibile: il tuo corpo non lascerà che il cervello resti a digiuno.\n\nLa fame e il lieve calo che senti sono l\'interruttore che scatta. Ora tocca al grasso. + L\'insulina è finalmente abbastanza bassa da aprire le tue riserve di grasso. La lipolisi libera acidi grassi, il fegato inizia a trasformarli in chetoni, e passi da bruciatore di zuccheri a bruciatore di grassi.\n\nOgni ora qui migliora un po\' la tua sensibilità all\'insulina e intacca le riserve di grasso — il cuore metabolico del perché il digiuno funziona.\n\nUn sapore metallico o un lieve mal di testa sono i primi chetoni. Il fuoco è acceso. + I chetoni nel sangue superano ~0,5 mmol/l — chetosi nutrizionale — e continuano a salire. Il tuo cervello non può ossidare direttamente gli acidi grassi, ma funziona benissimo con questo flusso di chetoni, che evita di sacrificare muscolo per produrre glucosio.\n\nMolti riferiscono una lucidità mentale insolita e una fame che si spegne da sola — i chetoni stessi smorzano l\'appetito.\n\nVentiquattro ore. Ora il tuo corpo preferisce il carburante che produce da sé. + Un giorno intero, e l\'autopulizia si intensifica: l\'autofagia — le cellule smontano proteine e organelli danneggiati e ne riciclano i pezzi (il meccanismo che è valso a Ohsumi il Nobel 2016). Nell\'uomo i tempi sono ancora allo studio, ma il digiuno attiva chiaramente questa via.\n\nPensala come una manutenzione che non si può comprare — una pulizia associata alla longevità e a un minor rischio di malattia.\n\nStanchezza o mal di testa? Sale e acqua. È in corso un lavoro profondo. + Due giorni, e l\'ormone della crescita schizza in alto — un digiuno di ~2 giorni può aumentarne la secrezione di circa 5 volte (Ho, J Clin Invest 1988). Il suo compito ora: proteggere la massa magra e spingere più forte il metabolismo dei grassi.\n\nCosì continui a bruciare grasso conservando il muscolo che ti aspetteresti di perdere — un adattamento davvero elegante.\n\nQuell\'inatteso sprint di energia che molti descrivono qui? È l\'ormone che parla. + Con l\'insulina tenuta bassa per giorni, le tue cellule tornano a rispondervi — la sensibilità all\'insulina si riporta verso un livello più sano, l\'opposto della resistenza che precede il diabete di tipo 2.\n\nLa glicemia resta in una fascia calma e stretta; la sazietà arriva con facilità.\n\nStai mettendo a punto un sistema che quasi nessuno regola di proposito. + Tre giorni: aria rara. Il digiuno prolungato può spingere il corpo a eliminare cellule immunitarie invecchiate e a rigenerarne di nuove dalle cellule staminali ematopoietiche, mentre calano i segnali di IGF-1 e PKA (Cheng, Cell Stem Cell 2014).\n\nL\'infiammazione continua a placarsi; alcuni descrivono un benessere calmo e lucido.\n\nPer quanto lontano tu vada da qui, ne esci rinnovato — e rompi il digiuno con dolcezza. Sii fiero. + %1$d digiuni importati.\nVoci sovrapposte SALTATE: %2$d + Formato di esportazione + CSV (foglio di calcolo) + iCalendar (.ics) + ActivityStreams 2.0 (JSON-LD) + Digiuni totali + Tempo totale di digiuno + Digiuno più lungo + Durata + Ore + Minuti + Aggiungere un digiuno per %1$s? + Aggiungi digiuno + Svuota registro… + Eliminare tutto il registro? + Questa operazione elimina definitivamente tutti i %1$d digiuni registrati. Non può essere annullata. + Elimina tutti (%1$d) + L\'autofagia è in pieno svolgimento! Continua così! + Formato data e ora + Compatto + Compatto + giorno della settimana + Sistema breve + Sistema medio + Sistema lungo + ISO 8601 diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 35ccf4e1..e3bb78f4 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -1,244 +1,276 @@ Fast Track - Share + Delen Info - About - Export Logbook - Produce a CSV file of your log book for backup - Import Logbook - Populate your log book from a previous export - Logbook imported successfully - Failed to import logbook - - Notifications + Over + Logboek exporteren + Maak een CSV-bestand van je logboek als back-up + Logboek importeren + Vul je logboek met een eerdere export + Logboek succesvol geïmporteerd + Importeren van logboek mislukt + Meldingen Interface - Logbook - - Show Fasting Notification - Provides real-time info during a fast - - Stage Alerts - Get notified when entering new fasting stages - Failed to export logbook - Welcome to Fast Track! - What is this app all about? - Motivational Help - The main idea behind Fast Track is to help motivate you during your fasts and - explain to you what your body is doing at the different stages of fasting. - A simple free app - Fast Track is and will always be free and open source. There is no account to - create, no in-app purchases, no ads to avoid. And it doesn’t even have internet access, so it can’t send your - data anywhere. - Intermittent Fasting - This is a new area of study which is showing lots of benefits beyond simple - weight loss.\n\nTo explore what we know about fasting and what the research is saying, check out the Info - section of this app! - Stay Motivated with Notifications - Fast Track can show you helpful notifications to keep you motivated during your - fast.\n\nYou’ll see your current fasting time, what stage you’re in, and what energy mode your body is - using.\n\nThis helps you stay on track without having to open the app! - That’s it! - Now go start fasting! + Logboek + Vastenmelding tonen + Toont realtime-info tijdens het vasten + Fasemeldingen + Krijg een melding wanneer je een nieuwe vastenfase bereikt + Exporteren van logboek mislukt + Welkom bij Fast Track! + Waar draait deze app om? + Motiverende hulp + Het idee achter Fast Track is om je te motiveren tijdens het vasten en je uit te leggen wat je lichaam in de verschillende vastenfasen doet. + Een eenvoudige, gratis app + Fast Track is en blijft gratis en opensource. Geen account nodig, geen in-app-aankopen, geen advertenties. En de app heeft niet eens internettoegang, dus je gegevens gaan nergens heen. + Intermittent fasting + Dit is een nieuw onderzoeksgebied dat veel voordelen laat zien, meer dan alleen gewichtsverlies.\n\nBenieuwd wat we over vasten weten en wat het onderzoek zegt? Kijk in het Info-gedeelte van deze app! + Blijf gemotiveerd met meldingen + Fast Track kan handige meldingen tonen om je gemotiveerd te houden tijdens het vasten.\n\nJe ziet je huidige vastentijd, in welke fase je zit en welke energiemodus je lichaam gebruikt.\n\nZo blijf je op koers zonder de app te hoeven openen! + Dat was het! + Tijd om te gaan vasten! Dark Rock Studios - Open Source Development - Climber, mountaineer, software engineer, and open source advocate. + Opensource-ontwikkeling + Klimmer, bergbeklimmer, software-engineer en voorstander van opensource. Discord - Fasting - Log - Profile - Fast - Start Fast - Start Fast Now - End Fast - Energy Mode: %1$s + Vasten + Logboek + Profiel + Vasten + Vasten starten + Nu vasten starten + Vasten beëindigen + Energiemodus: %1$s Glucose - Ketone Bodies - Start Fast? - Now - Already Started - No - Already Started - When did you start your Fast - End Fast? - Now - Already Stopped? - No - When did you stop? - Edit - Delete - Delete Fast? - Yes - No - Fast in progress - You are not fasting - Next stage at: %1$d hours + Ketonlichamen + Vasten starten? + Nu + Al begonnen + Nee + Al begonnen + Wanneer ben je begonnen met vasten? + Vasten beëindigen? + Nu + Al gestopt? + Nee + Wanneer ben je gestopt? + Bewerken + Verwijderen + Vasten verwijderen? + Ja + Nee + Vasten bezig + Je bent niet aan het vasten + Volgende fase over: %1$d uur * %1$s - %1$d hours - Ketosis: %1$d hours - Autophagy: %1$d hours - Age - Years - Weight - Pounds - Kilograms - Update + %1$d uur + Ketose: %1$d uur + Autofagie: %1$d uur + Leeftijd + Jaar + Gewicht + Pond + Kilogram + Bijwerken BMI: %1$.1f - %2$s - Underweight - Normal - Overweight - Obese - Morbidly Obese - Bad Value + Ondergewicht + Normaal + Overgewicht + Obesitas + Morbide obesitas + Ongeldige waarde BMR: - %.0f kcal / day - Height - Feet - Inches - Centimeters - Male - Female - Manual Add - Edit Entry - Length (Hours) - Add - Save - Fast Started On: %1$s - Start Date and Time - Edit date - Edit time - Fasted Started - Set Fast End - Calculate - Autophagy: - Ketosis: - Fat Burn: - Fasting - Your body is consuming its stores of glucose, keep it up! - Fat Burning - Your glucose stores are depleted! Your body is now burning fat for - energy. A workout in the next couple hours would be optimal. - Fasting Sweet Spot - You’re in the fasting sweet spot, keep it up! - Peak Fat Burning - Your body is burning fat rapidly now, and ketosis has begun. - Autophagy - Your body has begun autophagy! - Optimal Autophagy - Autophagy is in full swing! Keep it up, between now and 72 hours - autophagy will be very effective. - Glucose Burning - Fat Burning - Ketosis - Autophagy - Optimal Autophagy - I’ve been fasting for %1$d hours and %2$d minutes! My body is currently consuming %3$s - for energy. - I fasted for %1$d hours and %2$d minutes! - Stage Alerts - Fasting Alerts - Alerts that let you know when you have entered a new phase of your - fast. - Fast Stage: Fat Burning - You have exhausted your glucose reserves and your body is now consuming - fat for energy. - Fast Stage: Ketosis - Your body has now broken down enough fat reserves that you have - entered Ketosis. - Fast Stage: Autophagy - Your body has entered Autophagy. Old or damaged cells are now being - consumed for energy. - Fast Stage: Optimal Autophagy - Your body has been in autophagy for a long time now, and - your cellular health is seeing great benefits. - Fasting Status - Ongoing notification showing your current fasting status and - progress. - Fasting: %1$s hours - Energy Mode: %1$s - Fat Burning - In this stage your body has exhausted your glucose reserves. Glucose is - stored as glycogen for later use, in this stage, most of your stored glycogen has been broken down into glucose - and consumed for energy.\n\n Now it begins to break down adipose tissue (fat) into Ketone Bodies. In addition to - being able to consume glucose for energy, your body can alternatively consume ketone bodies for energy, and as - glucose stores run low, your body will switch its energy mode to Ketone Bodies. - Ketosis - In this stage your body has been breaking down fat for long enough that - your body is primarily being powered by Ketone Bodies rather than Glucose.\n\nIn addition to the fat loss - benefits this implies, studies have shown benefits for inflammation related illnesses, though the mechanism of - action for this is currently unknown. - Autophagy - In this stage your body has begun to activate some more processes for - harvesting energy. In addition to breaking down adipose tissue into Ketone Bodies, it has begun seeking out old - or damaged cells, and consuming them for more energy.\n\nThe research on this phase is in its early stages, - but it appears this is an important mechanism for the body to stop problems before they start. For instance, - cancer rates are lower in people who regularly enter Autophagy. Additionally, some studies have also shown lower - rates of Alzheimer’s disease. + %.0f kcal / dag + Lengte + Voet + Inch + Centimeter + Man + Vrouw + Handmatig toevoegen + Item bewerken + Duur (uren) + Toevoegen + Opslaan + Vasten begonnen op: %1$s + Startdatum en -tijd + Datum bewerken + Tijd bewerken + Vasten begonnen + Einde vasten instellen + Berekenen + Autofagie: + Ketose: + Vetverbranding: + Vasten + Je lichaam verbruikt zijn glucosevoorraad, ga zo door! + Vetverbranding + Je glucosevoorraad is uitgeput! Je lichaam verbrandt nu vet voor energie. Een work-out in de komende paar uur zou ideaal zijn. + Ideale vastenzone + Je zit in de ideale vastenzone, ga zo door! + Piek-vetverbranding + Je lichaam verbrandt nu snel vet, en de ketose is begonnen. + Autofagie + Je lichaam is met autofagie begonnen! + Optimale autofagie + De autofagie draait op volle toeren! Ga zo door; tussen nu en 72 uur is autofagie zeer effectief. + Glucoseverbranding + Vetverbranding + Ketose + Autofagie + Optimale autofagie + Ik vast al %1$d uur en %2$d minuten! Mijn lichaam gebruikt op dit moment %3$s voor energie. + Ik heb %1$d uur en %2$d minuten gevast! + Fasemeldingen + Vastenmeldingen + Meldingen die je laten weten wanneer je een nieuwe vastenfase hebt bereikt. + Vastenfase: Vetverbranding + Je glucosevoorraad is uitgeput en je lichaam verbruikt nu vet voor energie. + Vastenfase: Ketose + Je lichaam heeft nu genoeg vetreserves afgebroken om in ketose te komen. + Vastenfase: Autofagie + Je lichaam is in autofagie. Oude of beschadigde cellen worden nu voor energie verbruikt. + Vastenfase: Optimale autofagie + Je lichaam is al lange tijd in autofagie, en je celgezondheid plukt daar flink de vruchten van. + Vastenstatus + Doorlopende melding met je huidige vastenstatus en voortgang. + Vasten: %1$s uur + Energiemodus: %1$s + Vetverbranding + In deze fase heeft je lichaam je glucosereserves uitgeput. Glucose wordt als glycogeen opgeslagen voor later; in deze fase is het meeste opgeslagen glycogeen afgebroken tot glucose en voor energie verbruikt.\n\nNu begint het vetweefsel af te breken tot ketonlichamen. Naast glucose kan je lichaam ook ketonlichamen voor energie gebruiken, en naarmate de glucosevoorraad opraakt, schakelt je lichaam over op ketonlichamen als energiemodus. + Ketose + In deze fase breekt je lichaam al lang genoeg vet af om vooral op ketonlichamen te draaien in plaats van op glucose.\n\nNaast de voordelen voor vetverlies laten studies voordelen zien bij ontstekingsgerelateerde aandoeningen, al is het werkingsmechanisme daarvan nog onbekend. + Autofagie + In deze fase begint je lichaam extra processen te activeren om energie te winnen. Naast het afbreken van vetweefsel tot ketonlichamen gaat het op zoek naar oude of beschadigde cellen en verbruikt die voor meer energie.\n\nHet onderzoek naar deze fase staat nog in de kinderschoenen, maar het lijkt een belangrijk mechanisme om problemen te voorkomen voordat ze ontstaan. Zo komt kanker minder vaak voor bij mensen die regelmatig in autofagie komen. Ook laten sommige studies lagere percentages van de ziekte van Alzheimer zien. Body Mass Index - A rough estimation of the healthy weight an average adult human should be. It - is calculated simply by dividing weight by height.\n\nOften criticized for being inaccurate, it is true that - there are factors that can cause it to be misleading. For instance: an extremely muscular body builder, or a - very tall or very short person. These edge cases will get inaccurate results from the basic BMI - calculation.\n\nBut for the majority of people this is a good enough estimate to know if you are in a healthy - weight range. - Basal Metabolic Rate - This is an estimate of the number of calories your body will burn in a day - while completely at rest. If all you did was lay in bed all day, this is how many calories you could eat without - gaining weight.\n\nThis is a critical number for people who are dieting by restricting daily calorie intake. - Cool! + Een ruwe schatting van het gezonde gewicht dat een gemiddelde volwassene zou moeten hebben. Het wordt simpelweg berekend door het gewicht te delen door de lengte.\n\nHet wordt vaak bekritiseerd als onnauwkeurig, en het klopt dat er factoren zijn die het misleidend kunnen maken. Denk aan een extreem gespierde bodybuilder, of iemand die heel lang of heel klein is. In zulke uitzonderlijke gevallen geeft de eenvoudige BMI-berekening onnauwkeurige resultaten.\n\nMaar voor de meeste mensen is dit een goed genoege schatting om te weten of je binnen een gezond gewichtsbereik zit. + Basaalmetabolisme + Dit is een schatting van het aantal calorieën dat je lichaam op een dag verbrandt in volledige rust. Als je de hele dag alleen maar in bed zou liggen, is dit hoeveel calorieën je zou kunnen eten zonder aan te komen.\n\nDit is een cruciaal getal voor wie op dieet is door de dagelijkse calorie-inname te beperken. + Top! Info Tips - Fasting Info - 🧬 Autophagy: - 🔥 Ketosis: - %1$d hours - Metric + Vasteninfo + 🧬 Autofagie: + 🔥 Ketose: + %1$d uur + Metrisch Intro - Track your fasting progress from your home screen - Not fasting - Fasting in progress - %1$d hours - %1$d h - Start Fast - Stop Fast + Volg je vastenvoortgang vanaf je startscherm + Niet aan het vasten + Vasten bezig + %1$d uur + %1$d u + Vasten starten + Vasten stoppen Start - Tap to Start - Close - Cancel - Next - Previous - Skip - Done - Debug: Add 1 hour - Stop Fast - Start Fast - BMI Info - BMR Info - Gender - More options - Close - Started: %1$s - Cancel - Next - Calculate From End - Settings - Settings - Settings coming soon - Back - Show Fancy Background - Disable for a simpler Fasting background - Metric System - Display height and weight in metric units - Theme - Choose light, dark, or follow system - System - Light - Dark - No entries yet! - Logbook - Lifetime stats - Klantenlijst + Tik om te starten + Sluiten + Annuleren + Volgende + Vorige + Overslaan + Klaar + Debug: 1 uur toevoegen + Vasten stoppen + Vasten starten + BMI-info + BMR-info + Geslacht + Meer opties + Sluiten + Begonnen: %1$s + Annuleren + Volgende + Bereken vanaf einde + Instellingen + Instellingen + Instellingen komen binnenkort + Terug + Sierlijke achtergrond tonen + Uitschakelen voor een eenvoudigere vastenachtergrond + Metrisch stelsel + Toon lengte en gewicht in metrische eenheden + Thema + Kies licht, donker of volg het systeem + Systeem + Licht + Donker + Nog geen items! + Logboek + Statistieken aller tijden + Lijst Kalender + Bloedsuiker stijgt + Bloedsuiker daalt + Bloedsuiker stabiliseert + Gluconeogenese + Vetverbranding + Ketose + Autofagie + Piek in groeihormoon + Insulinegevoeligheid + Regeneratie van immuuncellen + %1$d–%2$d u + %1$d u+ + over %1$s + Tijd sinds je laatste vasten: %1$s + Je hebt net je vasten voltooid — goed gedaan! + Net begonnen + Vastenfasen + Automatisch: alleen actieve fasen tonen + Toont elke fase zodra die begint. Overschrijft de losse schakelaars hieronder. + Vetverbranding tonen + Toont de aftelklok van de vetverbranding op het vastenscherm. + Ketose tonen + Toont de aftelklok van de ketose op het vastenscherm. + Autofagie tonen + Toont de aftelklok van de autofagie op het vastenscherm. + Ik vast al %1$s! Mijn lichaam gebruikt op dit moment %2$s voor energie. + Ik heb %1$s gevast! + Vasten: %1$s + Nu starten + Ik ben eerder begonnen… + Nu beëindigen + Ik ben eerder gestopt… + Notities (optioneel) + Hoe ging het? Iets om te onthouden over dit vasten? + 📝 + Je hebt net gegeten, en je bloedbaan merkt het. De glucose stijgt — bij gezonde mensen tot een piek onder ~140 mg/dl binnen 30–60 min — en je alvleesklier reageert met insuline, het hormoon dat die suiker de cellen in loodst en het overschot als glycogeen opslaat.\n\nDit is de verzadigde toestand: energie in overvloed, opslaan heeft voorrang, de vetverbranding staat uit.\n\nEr valt nu niets te doen behalve verteren. De klok loopt. + De opname neemt af, en het tij keert. Insuline zakt, glucagon stijgt, en je lever begint opgeslagen glycogeen vrij te geven om de bloedsuiker rond ~70–100 mg/dl te houden.\n\nJe bent van de verzadigde toestand overgegaan naar de postabsorptieve toestand — de stille wissel waarbij het lichaam stopt met opslaan en begint met verbruiken.\n\nDat eerste vleugje honger is vooral de dalende insuline. Het gaat voorbij. + Nu is je lever de enige leverancier: hij geeft druppelsgewijs glucose uit glycogeen af (~100 g in reserve) om je stabiel te houden. Alleen je hersenen al verbranden ~120 g glucose per dag, dus deze stabiliteit is geen kleinigheid.\n\nGeen dip, geen piek — de glycogenolyse houdt de lijn vast en houdt een hypo op afstand.\n\nDe concentratie voelt hier vaak ongewoon helder. Dat is geen verbeelding; het is gelijkmatige brandstof. + Het glycogeen raakt op, dus de lever pakt een tweede vak op: glucose helemaal zelf maken — uit lactaat, glycerol, aminozuren — een proces dat gluconeogenese heet. Rond ~16 u levert hij al ongeveer de helft van je glucose (Cahill, Annu Rev Nutr 2006).\n\nDit is metabole flexibiliteit die zichtbaar wordt: je lichaam laat de hersenen niet verhongeren.\n\nDe honger en lichte dip die je voelt, zijn de knop die wordt omgezet. Hierna is het vet aan de beurt. + De insuline is eindelijk laag genoeg om je vetvoorraden te openen. De lipolyse maakt vetzuren vrij, de lever begint ze om te zetten in ketonen, en je schakelt van suikerverbrander naar vetverbrander.\n\nElk uur hier verbetert je insulinegevoeligheid een beetje en teert in op je vetreserves — de metabole kern van waarom vasten werkt.\n\nEen metaalsmaak of lichte hoofdpijn zijn de eerste ketonen. Het vuur brandt. + De ketonen in je bloed passeren ~0,5 mmol/l — voedingsketose — en blijven stijgen. Je hersenen kunnen vetzuren niet rechtstreeks verbranden, maar draaien uitstekend op deze ketonenstroom, die voorkomt dat er spier wordt afgebroken voor glucose.\n\nVelen melden een ongewone helderheid van geest en een honger die vanzelf uitgaat — ketonen dempen de eetlust.\n\nVierentwintig uur. Je lichaam geeft nu de voorkeur aan de brandstof die het zelf maakt. + Een hele dag, en de zelfreiniging versnelt: autofagie — cellen breken beschadigde eiwitten en organellen af en recyclen de onderdelen (het mechanisme waarvoor Ohsumi in 2016 de Nobelprijs kreeg). Bij de mens wordt de timing nog in kaart gebracht, maar vasten activeert deze route duidelijk.\n\nZie het als onderhoud dat je niet kunt kopen — een schoonmaak die samenhangt met een langer leven en een lager ziekterisico.\n\nMoe of hoofdpijn? Zout en water. Er is diep werk aan de gang. + Twee dagen, en het groeihormoon schiet omhoog — een vasten van ~2 dagen kan de afgifte ervan ongeveer 5 keer verhogen (Ho, J Clin Invest 1988). Zijn taak nu: spiermassa beschermen en de vetstofwisseling harder aanjagen.\n\nZo blijf je vet verbranden terwijl je de spier behoudt die je zou verwachten te verliezen — een werkelijk elegante aanpassing.\n\nDie onverwachte energieboost die veel mensen hier beschrijven? Dat is het hormoon dat spreekt. + Als de insuline dagenlang laag blijft, worden je cellen er weer gevoelig voor — de insulinegevoeligheid stelt zich in op een gezonder niveau, het tegenovergestelde van de resistentie die aan diabetes type 2 voorafgaat.\n\nDe bloedsuiker blijft in een rustige, smalle band; verzadiging komt moeiteloos.\n\nJe stemt een systeem af dat bijna niemand ooit bewust aanraakt. + Drie dagen: zeldzame hoogten. Langdurig vasten kan het lichaam ertoe aanzetten verouderde immuuncellen op te ruimen en nieuwe aan te maken uit hematopoëtische stamcellen, terwijl de IGF-1- en PKA-signalen dalen (Cheng, Cell Stem Cell 2014).\n\nDe ontsteking blijft afnemen; sommigen beschrijven een rustig, helder gevoel van welzijn.\n\nHoe ver je van hier ook gaat, je gaat vernieuwd verder — en verbreek je vasten rustig. Wees trots. + %1$d vastenperiodes geïmporteerd.\nOverlappende items OVERGESLAGEN: %2$d + Exportformaat + CSV (spreadsheet) + iCalendar (.ics) + ActivityStreams 2.0 (JSON-LD) + Totaal aantal vastenperiodes + Totale vastentijd + Langste vastenperiode + Duur + Uren + Minuten + Vasten toevoegen voor %1$s? + Vasten toevoegen + Logboek wissen… + Hele logboek verwijderen? + Hiermee worden alle %1$d gelogde vastenperiodes permanent verwijderd. Dit kan niet ongedaan worden gemaakt. + Alle %1$d verwijderen + De autofagie draait op volle toeren! Ga zo door! + Datum- & tijdnotatie + Compact + Compact + weekdag + Systeem kort + Systeem medium + Systeem lang + ISO 8601 diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 48486f99..d620be2c 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -37,7 +37,7 @@ Para explorar o que sabemos sobre o jejum e o que as pesquisas dizem, confira a Você verá o tempo atual de jejum, em que fase está e qual modo de energia seu corpo está usando. Isso ajuda você a seguir no caminho certo sem precisar abrir o app! - That\'s it! + É isso! Agora comece a jejuar! Dark Rock Studios Desenvolvimento de código aberto @@ -119,7 +119,7 @@ Isso ajuda você a seguir no caminho certo sem precisar abrir o app! Queima de gordura Suas reservas de glicose se esgotaram! Seu corpo agora está queimando gordura para obter energia. Um treino nas próximas horas seria ideal. Ponto ideal do jejum - You\'re in the fasting sweet spot, keep it up! + Você está no ponto ideal do jejum, continue assim! Pico de queima de gordura Seu corpo está queimando gordura rapidamente agora, e a cetose começou. Autofagia @@ -224,4 +224,74 @@ Esse é um número fundamental para pessoas que fazem dieta restringindo a inges Estatísticas totais Lista Calendário + Recém começado + Notas (opcional) + Como foi? Algo para lembrar sobre este jejum? + Você acabou de terminar um jejum — muito bem! + 📝 + Jejum: %1$s + em %1$s + Tempo desde o seu último jejum: %1$s + Estou jejuando há %1$s! Meu corpo está usando %2$s como fonte de energia. + Jejuei por %1$s! + %1$d–%2$d h + %1$d h+ + Começar agora + Comecei antes… + Encerrar agora + Parei antes… + Fases do jejum + Automático: mostrar só as fases ativas + Mostra cada fase assim que ela começa. Substitui as opções individuais abaixo. + Mostrar Queima de gordura + Mostra a contagem regressiva da queima de gordura na tela do jejum. + Mostrar Cetose + Mostra a contagem regressiva da cetose na tela do jejum. + Mostrar Autofagia + Mostra a contagem regressiva da autofagia na tela do jejum. + A glicemia sobe + A glicemia cai + A glicemia se estabiliza + Gliconeogênese + Queima de gordura + Cetose + Autofagia + Pico do hormônio do crescimento + Sensibilidade à insulina + Regeneração das células imunológicas + Você acabou de comer, e seu sangue percebe. A glicose sobe — em pessoas saudáveis, atinge o pico abaixo de ~140 mg/dL em 30–60 min — e o pâncreas responde com insulina, o hormônio que leva esse açúcar para as células e guarda o excedente como glicogênio.\n\nÉ o estado alimentado: energia de sobra, a prioridade é armazenar, a queima de gordura está desligada.\n\nSó resta digerir. O relógio começou a contar. + A absorção vai se esgotando, e a maré vira. A insulina recua, o glucagon sobe, e o fígado começa a liberar o glicogênio armazenado para manter a glicose por volta de ~70–100 mg/dL.\n\nVocê passou do estado alimentado para o pós-absortivo — a troca silenciosa em que o corpo para de armazenar e começa a gastar.\n\nAquele primeiro sinal de fome é, principalmente, a insulina caindo. Passa. + Agora o fígado é o único fornecedor: libera glicose do glicogênio (~100 g de reserva) aos poucos para manter você estável. Só o seu cérebro queima ~120 g de glicose por dia, então essa estabilidade não é pouca coisa.\n\nSem quedas nem picos — a glicogenólise segura a linha e mantém a hipoglicemia longe.\n\nAqui a concentração costuma parecer incomumente limpa. Não é imaginação; é combustível constante. + O glicogênio está ficando escasso, então o fígado assume um segundo ofício: fabricar glicose do zero — lactato, glicerol, aminoácidos — um processo chamado gliconeogênese. Por volta das ~16 h ele já fornece cerca de metade da sua glicose (Cahill, Annu Rev Nutr 2006).\n\nÉ a flexibilidade metabólica ficando visível: seu corpo não vai deixar o cérebro passar fome.\n\nA fome e aquela leve queda que você sente são a chave sendo virada. A seguir, a gordura. + A insulina está finalmente baixa o suficiente para abrir suas reservas de gordura. A lipólise libera ácidos graxos, o fígado começa a transformá-los em cetonas, e você passa de queimador de açúcar a queimador de gordura.\n\nCada hora aqui melhora um pouco sua sensibilidade à insulina e reduz suas reservas de gordura — o núcleo metabólico do porquê o jejum funciona.\n\nUm gosto metálico ou uma leve dor de cabeça são as primeiras cetonas. O fogo está aceso. + As cetonas no sangue passam de ~0,5 mmol/L — cetose nutricional — e continuam subindo. Seu cérebro não consegue oxidar ácidos graxos diretamente, mas funciona muito bem com esse fluxo de cetonas, que evita sacrificar músculo para produzir glicose.\n\nMuitos relatam uma clareza mental incomum e uma fome que simplesmente se desliga — as próprias cetonas reduzem o apetite.\n\nVinte e quatro horas. Seu corpo agora prefere o combustível que ele mesmo fabrica. + Um dia inteiro, e a autolimpeza se intensifica: a autofagia — as células desmontam proteínas e organelas danificadas e reciclam as peças (o mecanismo que rendeu a Ohsumi o Nobel de 2016). Nos humanos, os tempos ainda estão sendo mapeados, mas o jejum ativa claramente essa via.\n\nPense nisso como uma manutenção que não se pode comprar — uma limpeza associada à longevidade e a menor risco de doença.\n\nCansaço ou dor de cabeça? Sal e água. Um trabalho profundo está em andamento. + Dois dias, e o hormônio do crescimento dispara — um jejum de ~2 dias pode aumentar sua secreção em cerca de 5 vezes (Ho, J Clin Invest 1988). Sua missão agora: proteger a massa muscular e acelerar mais o metabolismo da gordura.\n\nAssim você continua queimando gordura enquanto preserva o músculo que esperaria perder — uma adaptação realmente elegante.\n\nAquele aumento inesperado de energia que muitos descrevem aqui? É o hormônio falando. + Com a insulina mantida baixa por dias, suas células voltam a responder a ela — a sensibilidade à insulina se reajusta para um nível mais saudável, o oposto da resistência que precede o diabetes tipo 2.\n\nA glicose fica numa faixa calma e estreita; a saciedade vem com facilidade.\n\nVocê está afinando um sistema que quase ninguém ajusta de propósito. + Três dias: ar rarefeito. O jejum prolongado pode levar o corpo a eliminar células imunológicas envelhecidas e regenerar novas a partir de células-tronco hematopoéticas, à medida que a sinalização de IGF-1 e PKA diminui (Cheng, Cell Stem Cell 2014).\n\nA inflamação continua cedendo; algumas pessoas descrevem um bem-estar calmo e lúcido.\n\nPor mais longe que você vá daqui, você sai renovado — e quebre o jejum com calma. Tenha orgulho. + %1$d jejuns importados.\nEntradas sobrepostas IGNORADAS: %2$d + Formato de exportação + CSV (planilha) + iCalendar (.ics) + ActivityStreams 2.0 (JSON-LD) + Total de jejuns + Tempo total de jejum + Jejum mais longo + Duração + Horas + Minutos + Adicionar um jejum para %1$s? + Adicionar jejum + Limpar registro… + Excluir todo o registro? + Isso exclui permanentemente todos os %1$d jejuns registrados. Esta ação não pode ser desfeita. + Excluir todos (%1$d) + A autofagia está em plena atividade! Continue assim! + Formato de data e hora + Compacto + Compacto + dia da semana + Sistema curto + Sistema médio + Sistema longo + ISO 8601 diff --git a/app/src/main/res/values-uk-rUA/strings.xml b/app/src/main/res/values-uk-rUA/strings.xml index 923cff77..23211a06 100644 --- a/app/src/main/res/values-uk-rUA/strings.xml +++ b/app/src/main/res/values-uk-rUA/strings.xml @@ -235,4 +235,74 @@ Статистика за весь час Список Календар + Щойно почато + Нотатки (необов\'язково) + Як усе пройшло? Щось, що варто запам\'ятати про це голодування? + Ти щойно завершив голодування — молодець! + 📝 + Голодування: %1$s + через %1$s + Час від останнього голодування: %1$s + Я голодую вже %1$s! Зараз моє тіло використовує %2$s як джерело енергії. + Я голодував %1$s! + %1$d–%2$d год + %1$d год+ + Почати зараз + Я почав раніше… + Завершити зараз + Я зупинився раніше… + Фази голодування + Авто: показувати лише активні фази + Показує кожну фазу, щойно вона починається. Замінює окремі перемикачі нижче. + Показувати Спалювання жиру + Показує зворотний відлік спалювання жиру на екрані голодування. + Показувати Кетоз + Показує зворотний відлік кетозу на екрані голодування. + Показувати Автофагію + Показує зворотний відлік автофагії на екрані голодування. + Цукор у крові зростає + Цукор у крові падає + Цукор у крові стабілізується + Глюконеогенез + Спалювання жиру + Кетоз + Автофагія + Сплеск гормону росту + Чутливість до інсуліну + Регенерація імунних клітин + Ти щойно поїв, і твоя кров це відчуває. Глюкоза зростає — у здорових людей сягає піку нижче ~140 мг/дл за 30–60 хв — а підшлункова залоза відповідає інсуліном, гормоном, що заводить цей цукор у клітини й відкладає надлишок як глікоген.\n\nЦе стан насичення: енергії вдосталь, пріоритет — накопичувати, спалювання жиру вимкнене.\n\nЛишається тільки перетравлювати. Годинник пішов. + Всмоктування згасає, і течія повертає назад. Інсулін відступає, глюкагон зростає, а печінка починає вивільняти запасений глікоген, щоб утримувати глюкозу біля ~70–100 мг/дл.\n\nТи перейшов зі стану насичення до постабсорбтивного — тиха передача естафети, коли тіло припиняє накопичувати й починає витрачати.\n\nТой перший проблиск голоду — це переважно інсулін, що падає. Він мине. + Тепер печінка — єдиний постачальник: краплями віддає глюкозу з глікогену (~100 г запасу), щоб тримати тебе стабільно. Сам лише мозок спалює ~120 г глюкози на день, тож ця стабільність — не дрібниця.\n\nНі спадів, ні піків — глікогеноліз тримає лінію й не пускає гіпоглікемію.\n\nТут зосередженість часто відчувається напрочуд чистою. Це не уява; це рівне пальне. + Глікоген вичерпується, тож печінка береться за друге ремесло: створювати глюкозу з нуля — лактат, гліцерол, амінокислоти — процес, що зветься глюконеогенезом. Близько ~16 год вона вже дає майже половину твоєї глюкози (Cahill, Annu Rev Nutr 2006).\n\nЦе метаболічна гнучкість у дії: твоє тіло не дасть мозку голодувати.\n\nГолод і легкий спад, що ти відчуваєш, — це перемкнутий тумблер. Далі — жир. + Інсулін нарешті достатньо низький, щоб відкрити твої запаси жиру. Ліполіз вивільняє жирні кислоти, печінка починає перетворювати їх на кетони, і ти переходиш зі спалювача цукру на спалювача жиру.\n\nКожна година тут трохи покращує твою чутливість до інсуліну й зменшує запаси жиру — метаболічна серцевина того, чому голодування працює.\n\nМеталевий присмак чи легкий головний біль — це перші кетони. Вогонь запалено. + Кетони в крові переходять ~0,5 ммоль/л — харчовий кетоз — і продовжують зростати. Твій мозок не може окиснювати жирні кислоти напряму, але чудово працює на цьому потоці кетонів, що береже м\'язи від розщеплення заради глюкози.\n\nБагато хто відзначає незвичну ясність думки й голод, що сам собою вщухає, — кетони притуплюють апетит.\n\nДвадцять чотири години. Тепер твоє тіло віддає перевагу пальному, яке саме виробляє. + Цілий день, і самоочищення посилюється: автофагія — клітини розбирають ушкоджені білки й органели та переробляють деталі (механізм, що приніс Осумі Нобелівську премію 2016 року). У людей терміни ще вивчають, але голодування виразно активує цей шлях.\n\nУяви це як обслуговування, яке не купиш, — очищення, пов\'язане з довголіттям і нижчим ризиком хвороб.\n\nВтома чи головний біль? Сіль і вода. Триває глибока робота. + Два дні, і гормон росту стрімко зростає — ~2-денне голодування може підвищити його секрецію приблизно в 5 разів (Ho, J Clin Invest 1988). Його завдання зараз: захищати м\'язову масу й сильніше розганяти метаболізм жиру.\n\nТож ти й далі спалюєш жир, зберігаючи м\'яз, який очікував втратити, — справді елегантна адаптація.\n\nТой несподіваний приплив енергії, що його описують тут? Це говорить гормон. + Коли інсулін тримається низьким днями, твої клітини знову стають чутливими до нього — чутливість до інсуліну повертається до здоровішого рівня, протилежність резистентності, що передує діабету 2 типу.\n\nГлюкоза тримається у спокійному вузькому діапазоні; насичення настає легко.\n\nТи налаштовуєш систему, якої майже ніхто не торкається свідомо. + Три дні: рідкісні висоти. Тривале голодування може спонукати тіло позбуватися старіючих імунних клітин і відновлювати нові з гемопоетичних стовбурових клітин, поки знижується сигналізація IGF-1 і PKA (Cheng, Cell Stem Cell 2014).\n\nЗапалення й далі вщухає; дехто описує тихе, ясне відчуття добробуту.\n\nХоч як далеко ти зайдеш звідси, ти виходиш оновленим — і виходь із голодування лагідно. Пишайся собою. + Імпортовано голодувань: %1$d.\nПропущено записів, що перекриваються: %2$d + Формат експорту + CSV (таблиця) + iCalendar (.ics) + ActivityStreams 2.0 (JSON-LD) + Усього голодувань + Загальний час голодування + Найдовше голодування + Тривалість + Години + Хвилини + Додати голодування для %1$s? + Додати голодування + Очистити журнал… + Видалити весь журнал? + Це назавжди видаляє всі %1$d записаних голодувань. Цю дію не можна скасувати. + Видалити всі %1$d + Автофагія у повному розпалі! Так тримати! + Формат дати та часу + Компактний + Компактний + день тижня + Короткий (системний) + Середній (системний) + Довгий (системний) + ISO 8601 diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 97b33d33..fe0603a0 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -5,7 +5,7 @@ 信息 关于 导出日志簿 - 为备份生成日志中的 CVS 文件 + 为备份生成日记中的 CSV 文件 导入日志簿 从以前的导出中填写日志记录 日志成功导入 @@ -27,17 +27,13 @@ Fast Track的主要理念是帮助您在禁食期间保持动力,并向您解释在禁食的不同阶段您的身体正在发生什么。 一个简单的免费应用程序 - Fast Track is and will always be free and open source. There is no account to - create, no in-app purchases, no ads to avoid. And it doesn’t even have internet access, so it can’t send your - data anywhere. + Fast Track 是而且将永远免费且开源。无需创建账户,没有内购,没有广告。它甚至无法访问互联网,所以你的数据不会被发送到任何地方。 间歇性禁食 这是一个新的研究领域,展现了超越简单减重的诸多好处。\n\n要探索我们对禁食的了解以及研究结果,请查看本应用的资讯部分! - 就这样! - Fast Track can show you helpful notifications to keep you motivated during your - fast.\n\nYou’ll see your current fasting time, what stage you’re in, and what energy mode your body is - using.\n\nThis helps you stay on track without having to open the app! - 就是这样! - 现在开始快速! + 通过通知保持动力 + Fast Track 可以显示有用的通知,在禁食期间为你加油打气。\n\n你会看到当前的禁食时间、所处的阶段以及身体正在使用的能量模式。\n\n这样即使不打开应用也能随时掌握进度! + 开始吧! + 现在开始禁食吧! 亚当·布朗 移动开发者 攀登者、登山者、软件工程师和开源倡导者。 @@ -45,35 +41,35 @@ 禁食 日志 个人资料 - 快速 - 快速开始 - 立即快速开始 - 结束快速 + 禁食 + 开始禁食 + 立即开始禁食 + 结束禁食 能量模式:%1$s 葡萄糖 酮体 - 快速开始吗? + 开始禁食? 现在 已经开始 已经开始 你什么时候开始你的禁食? - 结束快速吗? + 结束禁食? 现在 已经停止? 您何时停止? 编辑 删除 - 删除快速吗? - + 删除禁食? + 禁食进行中 你没有禁食。 下一个阶段在:%1$d 小时后 * - 已启动: %1$s - 长度: %1$d 小时 + 已启动:%1$s + %1$d 小时 %1$d 小时 自噬:%1$d 小时 年龄 @@ -97,78 +93,62 @@ 英寸 厘米 - 女性 + 手动添加 编辑条目 长度(小时) 添加 保存 - 快速启动于:%1$s + 禁食开始于:%1$s 开始日期和时间 编辑日期 编辑时间 - 快速开始 - 设置快速结束 + 禁食已开始 + 设置禁食结束 计算 自噬: 酮症: 脂肪燃烧: - 正在退出 + 禁食 你的身体正在消耗其葡萄糖储备,继续保持! 脂肪燃烧 你的葡萄糖储备已耗尽!你的身体现在正在燃烧脂肪以获取能量。接下来的几个小时内进行锻炼将是最佳选择。 禁食最佳时机 你在禁食的最佳状态,继续保持! 高峰脂肪燃烧 - Your body is burning fat rapidly now, and ketosis has begun. + 你的身体正在快速燃烧脂肪,酮症已经开始。 自噬 你的身体已经开始自噬! 最佳自噬 自噬正全力进行中!继续保持,在接下来的72小时内,自噬将非常有效。 葡萄糖燃烧 - 脂肪Burning + 脂肪燃烧 酮症 自噬 - 最优自動作 - I’ve been fasting for %1$d hours and %2$d minutes! My body is currently consuming %3$s - for energy. + 最佳自噬 + 我已经禁食了 %1$d 小时 %2$d 分钟!我的身体目前以 %3$s 作为能量来源。 我禁食了 %1$d 小时和 %2$d 分钟! - 阶段警报 + 阶段提醒 禁食提醒 - Alerts that let you know when you have entered a new phase of your - fast. - 快速阶段:脂肪燃烧 - You have exhausted your glucose reserves and your body is now consuming - fat for energy. - 快速阶段:酮症 - Your body has now broken down enough fat reserves that you have - entered Ketosis. - 快速阶段:自噬 - Your body has entered Autophagy. Old or damaged cells are now being - consumed for energy. - 快速阶段:最佳自噬 - Your body has been in autophagy for a long time now, and - your cellular health is seeing great benefits. - 退货状态 - Ongoing notification showing your current fasting status and - progress. - 快速︰ %1$s 小时 + 当你进入禁食的新阶段时通知你的提醒。 + 禁食阶段:脂肪燃烧 + 你的葡萄糖储备已耗尽,你的身体现在正在消耗脂肪来获取能量。 + 禁食阶段:酮症 + 你的身体已经分解了足够的脂肪储备,已进入酮症状态。 + 禁食阶段:自噬 + 你的身体已进入自噬状态。老旧或受损的细胞正在被分解以获取能量。 + 禁食阶段:最佳自噬 + 你的身体已处于自噬状态很长时间,你的细胞健康正受益匪浅。 + 禁食状态 + 持续显示当前禁食状态和进度的通知。 + 禁食:%1$s 小时 能量模式:%1$s - 脂肪Burning - In this stage your body has exhausted your glucose reserves. Glucose is - stored as glycogen for later use, in this stage, most of your stored glycogen has been broken down into glucose - and consumed for energy.\n\n Now it begins to break down adipose tissue (fat) into Ketone Bodies. In addition to - being able to consume glucose for energy, your body can alternatively consume ketone bodies for energy, and as - glucose stores run low, your body will switch its energy mode to Ketone Bodies. + 脂肪燃烧 + 在这个阶段,你的身体已经耗尽了葡萄糖储备。葡萄糖以糖原的形式储存以备后用,在这个阶段,大部分储存的糖原已被分解为葡萄糖并消耗以提供能量。\n\n现在它开始将脂肪组织分解为酮体。除了能够消耗葡萄糖获取能量外,你的身体也可以消耗酮体来获取能量,当葡萄糖储备不足时,身体会将其能量模式切换为酮体。 酮症 在这个阶段,你的身体已经足够长时间地分解脂肪,主要由酮体而非葡萄糖提供能量。\n\n除了这意味着脂肪损失的好处,研究还表明对与炎症相关的疾病有好处,尽管目前对这一机制的作用尚不清楚。 自噬 - In this stage your body has begun to activate some more processes for - harvesting energy. In addition to breaking down adipose tissue into Ketone Bodies, it has begun seeking out old - or damaged cells, and consuming them for more energy.\n\nThe research on this phase is in its early stages, - but it appears this is an important mechanism for the body to stop problems before they start. For instance, - cancer rates are lower in people who regularly enter Autophagy. Additionally, some studies have also shown lower - rates of Alzheimer’s disease. + 在这个阶段,你的身体已开始激活更多获取能量的过程。除了将脂肪组织分解为酮体外,它还会寻找老旧或受损的细胞,并将其消耗以获取更多能量。\n\n关于这一阶段的研究仍处于早期阶段,但这似乎是身体在问题发生前阻止问题的重要机制。例如,定期进入自噬状态的人患癌率较低。此外,一些研究还显示阿尔茨海默病的发病率也较低。 身体质量指数 一个普通成年人应有的健康体重的粗略估计。它的计算方法很简单,就是将体重除以身高。\n\n虽然常常被批评为不准确,但确实有一些因素可能导致其误导性。例如:一位极其肌肉发达的健美选手,或者一个非常高或非常矮的人。这些边缘案例会从基本的BMI计算中得出不准确的结果。\n\n但对大多数人来说,这足够好地估计出您是否处于健康的体重范围内。 基础代谢率 @@ -177,8 +157,8 @@ 信息 提示 禁食信息 - 全部自动化: - 总共Ketosis: + 🧬 自噬: + 🔥 酮症: %1$d 小时 公制 介绍 @@ -187,8 +167,8 @@ 禁食进行中 %1$d 小时 %1$d 小时 - 快速启动 - 停止快速 + 开始禁食 + 结束禁食 开始 点击开始 关闭 @@ -198,8 +178,8 @@ 跳过 完成 调试:添加1小时 - 快速停止 - 快速启动 + 结束禁食 + 开始禁食 BMI 信息 BMR 信息 性别 @@ -213,18 +193,88 @@ 设置 设置即将开始 后退 - 显示花哨背景 - 禁用较简单的快取背景 - 公式系统 - 以公制单位显示高度和权重 + 显示精美背景 + 关闭以获得更简洁的禁食背景 + 公制单位 + 以公制单位显示身高和体重 主题 - 选择光线、暗色或关注系统 + 选择浅色、深色或跟随系统 系统 - 亮色的 + 浅色 深色 尚无条目! 日志记录 终生统计 列表 - 日程表 + 日历 + 刚刚开始 + 备注(可选) + 感觉如何?这次断食有什么想记下的吗? + 你刚刚完成了一次断食——做得好! + 📝 + 断食:%1$s + 还有 %1$s + 距上次断食:%1$s + 我已经断食 %1$s 了!我的身体正在以%2$s作为能量来源。 + 我断食了 %1$s! + %1$d–%2$d 小时 + %1$d 小时以上 + 立即开始 + 我更早就开始了… + 立即结束 + 我更早就停止了… + 断食阶段 + 自动:仅显示已激活的阶段 + 每个阶段开始后才显示。会覆盖下方的单项开关。 + 显示脂肪燃烧 + 在断食界面显示脂肪燃烧的倒计时。 + 显示生酮 + 在断食界面显示生酮的倒计时。 + 显示细胞自噬 + 在断食界面显示细胞自噬的倒计时。 + 血糖上升 + 血糖下降 + 血糖趋于稳定 + 糖异生 + 脂肪燃烧 + 生酮 + 细胞自噬 + 生长激素激增 + 胰岛素敏感性 + 免疫细胞再生 + 你刚刚进食,血液能感觉到。血糖上升——健康人会在 30–60 分钟内达到峰值,通常低于 ~140 mg/dL——胰腺随即分泌胰岛素,这种激素把糖送进细胞,并把多余的储存为糖原。\n\n这是进食后状态:能量充足,优先储存,脂肪燃烧关闭。\n\n现在只需消化。计时开始了。 + 吸收逐渐减弱,潮水开始退去。胰岛素回落,胰高血糖素上升,肝脏开始释放储存的糖原,把血糖维持在 ~70–100 mg/dL 左右。\n\n你已从进食后状态进入吸收后状态——身体停止储存、开始消耗的安静交接。\n\n那第一丝饥饿感,多半只是胰岛素在下降。它会过去。 + 现在肝脏是唯一的供给者:一点点从糖原(约 100 g 储备)释放葡萄糖,让你保持平稳。仅大脑每天就消耗约 120 g 葡萄糖,所以这份稳定并不简单。\n\n不骤降也不飙升——糖原分解稳住阵线,把低血糖挡在门外。\n\n这里的专注常常清晰得出奇。这不是错觉,而是稳定的燃料。 + 糖原渐少,于是肝脏又干起第二门手艺:从头合成葡萄糖——乳酸、甘油、氨基酸——这个过程叫糖异生。到约 16 小时,它已提供你大约一半的葡萄糖(Cahill, Annu Rev Nutr 2006)。\n\n这是代谢灵活性的显现:你的身体不会让大脑挨饿。\n\n你感到的饥饿和轻微低落,是开关正在切换。接下来轮到脂肪。 + 胰岛素终于低到足以打开你的脂肪储备。脂解释放脂肪酸,肝脏开始把它们转化为酮体,你从燃烧糖转为燃烧脂肪。\n\n这里的每一小时都会略微提升你的胰岛素敏感性,并动用脂肪储备——这正是断食有效的代谢核心。\n\n口中的金属味或轻微头痛,是最初的酮体。火已点燃。 + 血液中的酮体越过 ~0.5 mmol/L——营养性生酮——并持续升高。你的大脑无法直接氧化脂肪酸,却能靠这股酮体流运转得很好,从而免去为制造葡萄糖而分解肌肉。\n\n许多人报告一种少有的头脑清晰,以及自行消退的饥饿——酮体本身会抑制食欲。\n\n二十四小时。你的身体如今更偏爱自己制造的燃料。 + 整整一天,自我清理加强了:细胞自噬——细胞拆解受损的蛋白质和细胞器,并回收零件(正是这一机制让大隅良典获得 2016 年诺贝尔奖)。人体的时间线仍在研究,但断食显然会上调这条通路。\n\n把它想成一种买不到的保养——一种与长寿和更低患病风险相关的清理。\n\n疲惫或头痛?盐和水。深层的工作正在进行。 + 两天,生长激素飙升——约 2 天的断食可使其分泌大约增加 5 倍(Ho, J Clin Invest 1988)。它此刻的任务:守护瘦肌肉,并更用力地推动脂肪代谢。\n\n于是你一边继续燃脂,一边保住了你以为会失去的肌肉——一次真正优雅的适应。\n\n很多人在这里描述的那股意外的精力?那是激素在说话。 + 胰岛素连日维持低位,你的细胞重新对它敏感起来——胰岛素敏感性回调到更健康的水平,正好与 2 型糖尿病之前的抵抗相反。\n\n血糖停留在平静而狭窄的区间;饱腹感也来得轻松。\n\n你正在微调一套几乎没人会刻意去动的系统。 + 三天:稀薄的高处。长时间断食可促使身体清除衰老的免疫细胞,并由造血干细胞再生出新的,同时 IGF-1 与 PKA 信号下降(Cheng, Cell Stem Cell 2014)。\n\n炎症持续消退;有些人描述出一种平静而清明的安适。\n\n无论你从这里走多远,你都焕然一新地前行——并温和地结束断食。为自己骄傲。 + 已导入 %1$d 条禁食记录。\n已跳过重叠的条目:%2$d + 导出格式 + CSV(电子表格) + iCalendar (.ics) + ActivityStreams 2.0 (JSON-LD) + 禁食总次数 + 累计禁食时长 + 最长禁食 + 时长 + 小时 + 分钟 + 为 %1$s 添加一次禁食? + 添加禁食 + 清空日志… + 删除整个日志? + 此操作将永久删除全部 %1$d 条禁食记录,且无法撤销。 + 全部删除 (%1$d) + 自噬正全力进行中!继续保持! + 日期和时间格式 + 紧凑 + 紧凑 + 星期 + 系统-短 + 系统-中 + 系统-长 + ISO 8601 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3ee7c75f..2ffd7f22 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,3 +1,4 @@ + Fast Track Share @@ -9,6 +10,11 @@ Populate your log book from a previous export Logbook imported successfully Failed to import logbook + Imported %1$d fasts.\nOverlapping entries SKIPPED: %2$d + Export format + CSV (spreadsheet) + iCalendar (.ics) + ActivityStreams 2.0 (JSON-LD) Notifications @@ -59,6 +65,9 @@ Climber, mountaineer, software engineer, and open source advocate. Discord + GitHub + Website + Rate on Google Play Fasting Log @@ -136,6 +145,9 @@ Manual Add Edit Entry Length (Hours) + Duration + Hours + Minutes Add Save Fast Started On: %1$s @@ -145,6 +157,19 @@ Fasted Started Set Fast End Calculate + Add a fast for %1$s? + Add fast + Date & time format + Compact + Compact + weekday + System short + System medium + System long + ISO 8601 + Clear logbook… + Delete entire logbook? + This permanently deletes all %1$d logged fasts. This cannot be undone. + Delete all %1$d Autophagy: Ketosis: Fat Burn: @@ -263,6 +288,9 @@ Fasting Info 🧬 Autophagy: 🔥 Ketosis: + Total Fasts + Total Fasted + Longest Fast %1$d hours Metric Intro @@ -311,4 +339,52 @@ Lifetime stats List Calendar + + + Blood Sugar Rises + You just ate, and your bloodstream knows it. Glucose climbs — in healthy people peaking under ~140 mg/dL within 30–60 min — and your pancreas answers with insulin, the hormone that ushers that sugar into cells and banks the surplus as glycogen.\n\nThis is the fed state: energy is abundant, storage is the priority, fat-burning is switched off.\n\nNothing to do but digest. The clock has started. + Blood Sugar Falls + Absorption tapers and the tide turns. Insulin recedes, glucagon rises, and your liver starts releasing stored glycogen to hold blood glucose steady near ~70–100 mg/dL.\n\nYou\'ve crossed from the fed state into the post-absorptive state — the quiet handoff where the body stops storing and starts spending.\n\nThat first flicker of hunger is mostly insulin falling. It passes. + Blood Sugar Stabilizes + Your liver is now the sole supplier, drip-feeding glucose from glycogen (~100 g in reserve) to keep you level. Your brain alone burns ~120 g of glucose a day, so this stability is not trivial.\n\nNo dip, no spike — glycogenolysis holds the line and keeps hypoglycemia away.\n\nFocus often feels unusually clean here. That\'s not imagination; it\'s steady fuel. + Gluconeogenesis + Glycogen is running thin, so the liver takes up a second trade: building glucose from scratch — lactate, glycerol, amino acids — a process called gluconeogenesis. By ~16 h it already supplies roughly half of your glucose output (Cahill, Annu Rev Nutr 2006).\n\nThis is metabolic flexibility made visible: your body will not let the brain go hungry.\n\nThe hunger and mild dip you feel are the switch being thrown. Fat is next. + Fat Burning + Insulin is finally low enough to unlock your fat stores. Lipolysis frees fatty acids, the liver begins spinning them into ketones, and you pivot from sugar-burner to fat-burner.\n\nEvery hour here nudges insulin sensitivity upward and draws down fat reserves — the metabolic core of why fasting works.\n\nA metallic taste or faint headache is early ketones. The fire is lit. + Ketosis + Blood ketones cross ~0.5 mmol/L — nutritional ketosis — and keep climbing. Your brain can\'t oxidize fatty acids directly, but it runs beautifully on this ketone stream, which spares muscle from being cannibalized for glucose.\n\nMany report an unusual mental clarity, and a hunger that quietly switches off — ketones themselves blunt appetite.\n\nTwenty-four hours. Your body now prefers the fuel it\'s making. + Autophagy + A full day in, and self-cleaning intensifies: autophagy — cells disassembling damaged proteins and organelles and recycling the parts (the mechanism that earned Ohsumi the 2016 Nobel). Human timing is still being mapped, but the pathway is clearly upregulated by fasting.\n\nThink of it as maintenance you can\'t buy — clearance associated with longevity and lower disease risk.\n\nTiredness or a headache? Salt and water. Deep work is underway. + Growth Hormone Surge + Two days, and growth hormone spikes — a ~2-day fast can raise GH secretion roughly 5-fold (Ho, J Clin Invest 1988). Its brief right now: defend lean muscle and push fat metabolism harder.\n\nSo you keep burning fat while sparing the muscle you\'d expect to lose — a genuinely elegant adaptation.\n\nThat unexpected lift in energy people describe here? That\'s the hormone talking. + Insulin Sensitivity + With insulin held low for days, your cells turn newly responsive to it — insulin sensitivity resets toward a healthier baseline, the opposite of the resistance that precedes type 2 diabetes.\n\nBlood glucose sits in a calm, narrow band; satiety comes easily.\n\nYou\'re tuning a system most people never deliberately touch. + Immune Cell Regeneration + Three days: rare air. Prolonged fasting can prompt the body to clear senescent immune cells and regenerate new ones from hematopoietic stem cells, as IGF-1 and PKA signaling drop (Cheng, Cell Stem Cell 2014).\n\nInflammation keeps easing; some describe a quiet, lucid sense of well-being.\n\nHowever far you go from here, you go renewed — and break your fast gently. Be proud. + %1$d–%2$d h + %1$d h+ + in %1$s + Time since your last fast: %1$s + You just finished a fast — well done! + Just started + Fasting Phases + Auto: show only active phases + Reveal each phase once it begins. Overrides the individual toggles below. + Show Fat Burn + Display the Fat Burn countdown on the fasting screen + Show Ketosis + Display the Ketosis countdown on the fasting screen + Show Autophagy + Display the Autophagy countdown on the fasting screen + I’ve been fasting for %1$s! My body is currently consuming %2$s for energy. + I fasted for %1$s! + Fasting: %1$s + Start now + I started earlier… + End now + I stopped earlier… + Notes (optional) + How did it go? Anything to remember about this fast? + 📝 + Autophagy is in full swing! Keep it up! diff --git a/app/src/test/java/com/darkrockstudios/apps/fasttrack/data/log/FastingLogRepositoryImplTest.kt b/app/src/test/java/com/darkrockstudios/apps/fasttrack/data/log/FastingLogRepositoryImplTest.kt index ef502618..41ddef24 100644 --- a/app/src/test/java/com/darkrockstudios/apps/fasttrack/data/log/FastingLogRepositoryImplTest.kt +++ b/app/src/test/java/com/darkrockstudios/apps/fasttrack/data/log/FastingLogRepositoryImplTest.kt @@ -3,12 +3,19 @@ package com.darkrockstudios.apps.fasttrack.data.log import com.darkrockstudios.apps.fasttrack.data.database.FastEntry import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking -import kotlinx.datetime.* -import org.junit.Assert.* +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toInstant +import kotlinx.datetime.toLocalDateTime +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import kotlin.time.Duration.Companion.hours import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Instant class FastingLogRepositoryImplTest { @@ -130,28 +137,41 @@ class FastingLogRepositoryImplTest { val csvOutput = repository.exportLog() // Then - // Expected format: "ID,Start Date,Start Time,Duration (hours)" val lines = csvOutput.split("\n") - // Check header - assertEquals("ID,Start Date,Start Time,Duration (hours)", lines[0]) - - // Check that we have the expected number of data rows + // New schema header + assertEquals("ID,Start,End,Duration (s),Duration,Notes", lines[0]) assertEquals(3, lines.size) // Header + 2 entries - // Convert entries to local time for comparison - val entry1Local = entry1Start.toLocalDateTime(TimeZone.currentSystemDefault()) - val entry2Local = entry2Start.toLocalDateTime(TimeZone.currentSystemDefault()) - - // Check that each entry is correctly formatted - // Note: The exact format may vary depending on the local time zone, so we check for the presence of key data + // The datetime columns depend on the local time zone, so assert the + // tz-independent columns: duration seconds and humanized duration. assertTrue(lines[1].startsWith("1,")) - assertTrue(lines[1].contains(entry1Local.date.toString())) - assertTrue(lines[1].endsWith("16")) + assertTrue(lines[1].contains(",57600,")) // 16h in seconds + assertTrue(lines[1].endsWith("16h 0m,")) // humanized, then empty Notes assertTrue(lines[2].startsWith("2,")) - assertTrue(lines[2].contains(entry2Local.date.toString())) - assertTrue(lines[2].endsWith("24")) + assertTrue(lines[2].contains(",86400,")) // 24h in seconds + assertTrue(lines[2].endsWith("1d 0h 0m,")) + } + + @Test + fun `test export then import round-trips through the new format`() = runBlocking { + fakeDatasource.clear() + val start = LocalDateTime(2023, 3, 15, 9, 5, 30).toInstant(TimeZone.currentSystemDefault()) + fakeDatasource.insertAll( + FastEntry(uid = 1, start = start.toEpochMilliseconds(), length = 40.hours.inWholeMilliseconds, notes = "Felt, \"great\"") + ) + + val csv = repository.exportLog() + fakeDatasource.clear() + val ok = repository.importLog(csv) + + assertTrue(ok) + val entries = fakeDatasource.getAll() + assertEquals(1, entries.size) + assertEquals(start.toEpochMilliseconds(), entries[0].start) + assertEquals(40.hours.inWholeMilliseconds, entries[0].length) + assertEquals("Felt, \"great\"", entries[0].notes) // commas + quotes survive CSV escaping } @Test @@ -166,66 +186,42 @@ class FastingLogRepositoryImplTest { 2,2023-01-02,12:30,24 """.trimIndent() - // When + // When (legacy format must still import) val result = repository.importLog(csvInput) // Then assertTrue(result) // Import should succeed - // Check that entries were added to the datasource val entries = fakeDatasource.getAll() assertEquals(2, entries.size) - // Verify the entries have the correct properties - // Note: We can't directly check the exact millisecond values as they depend on the timezone - // So we'll check the IDs and relative properties - - // Find entries by ID - val entry1 = entries.find { it.uid == 1 } - val entry2 = entries.find { it.uid == 2 } - - // Verify entries exist - assertTrue(entry1 != null) - assertTrue(entry2 != null) - - // Verify durations - assertEquals(16 * 60 * 60 * 1000, entry1!!.length) // 16 hours in milliseconds - assertEquals(24 * 60 * 60 * 1000, entry2!!.length) // 24 hours in milliseconds + // Import keys on start (not the CSV's ID), so verify by duration + assertTrue(entries.any { it.length == 16L * 60 * 60 * 1000 }) + assertTrue(entries.any { it.length == 24L * 60 * 60 * 1000 }) } @Test - fun `test importLog handles conflicts by replacing existing entries`() = runBlocking { - // Given - fakeDatasource.clear() // Ensure we start with a clean state - - // Add an existing entry with ID 1 - val existingEntry = FastEntry(uid = 1, start = 1000, length = 2000) - fakeDatasource.insertAll(existingEntry) + fun `test importLog de-dupes by start time, replacing the existing entry`() = runBlocking { + // Given an entry starting at a specific instant + fakeDatasource.clear() + val start = LocalDateTime(2023, 1, 1, 8, 0, 0).toInstant(TimeZone.currentSystemDefault()) + fakeDatasource.insertAll(FastEntry(uid = 1, start = start.toEpochMilliseconds(), length = 2000)) - // Create a CSV with an entry that has the same ID + // Importing a row with the SAME start (different duration) should replace it val csvInput = """ ID,Start Date,Start Time,Duration (hours) - 1,2023-01-01,8:00,16 + 99,2023-01-01,8:00,16 """.trimIndent() // When val result = repository.importLog(csvInput) // Then - assertTrue(result) // Import should succeed - - // Check that the entry was replaced + assertTrue(result) val entries = fakeDatasource.getAll() - assertEquals(1, entries.size) - - // Verify the entry has the new properties - val entry = entries[0] - assertEquals(1, entry.uid) - assertEquals(16 * 60 * 60 * 1000, entry.length) // 16 hours in milliseconds - - // The original entry had length 2000, so if it's now 16 hours in milliseconds, - // we know it was replaced - assertNotEquals(2000, entry.length) + assertEquals(1, entries.size) // replaced, not duplicated + assertEquals(16L * 60 * 60 * 1000, entries[0].length) + assertNotEquals(2000L, entries[0].length) } @Test diff --git a/app/src/test/java/com/darkrockstudios/apps/fasttrack/data/settings/FakeSettingsDatasource.kt b/app/src/test/java/com/darkrockstudios/apps/fasttrack/data/settings/FakeSettingsDatasource.kt index 258625b1..36c6cf02 100644 --- a/app/src/test/java/com/darkrockstudios/apps/fasttrack/data/settings/FakeSettingsDatasource.kt +++ b/app/src/test/java/com/darkrockstudios/apps/fasttrack/data/settings/FakeSettingsDatasource.kt @@ -14,6 +14,7 @@ class FakeSettingsDatasource : SettingsDatasource { private var showFastingNotification: Boolean = true private var useMetricSystem: Boolean? = null private var themeMode: ThemeMode = ThemeMode.SYSTEM + private var dateStyle: DateStyle = DateStyle.OPTIMIZED_COMPACT private var logViewMode: LogViewMode = LogViewMode.LIST override fun getFastingAlerts(): Boolean = fastingAlerts @@ -57,12 +58,35 @@ class FakeSettingsDatasource : SettingsDatasource { themeMode = mode } + override fun getDateStyle(): DateStyle = dateStyle + + override fun setDateStyle(style: DateStyle) { + dateStyle = style + } + override fun getLogViewMode(): LogViewMode = logViewMode override fun setLogViewMode(mode: LogViewMode) { logViewMode = mode } + private var showFatBurn: Boolean = true + private var showKetosis: Boolean = true + private var showAutophagy: Boolean = true + private var phaseAutoMode: Boolean = false + + override fun getShowFatBurn(): Boolean = showFatBurn + override fun setShowFatBurn(enabled: Boolean) { showFatBurn = enabled } + override fun getShowKetosis(): Boolean = showKetosis + override fun setShowKetosis(enabled: Boolean) { showKetosis = enabled } + override fun getShowAutophagy(): Boolean = showAutophagy + override fun setShowAutophagy(enabled: Boolean) { showAutophagy = enabled } + override fun getPhaseAutoMode(): Boolean = phaseAutoMode + override fun setPhaseAutoMode(enabled: Boolean) { phaseAutoMode = enabled } + + override fun phaseVisibilityFlow(): Flow = + flowOf(PhaseVisibility(showFatBurn, showKetosis, showAutophagy, phaseAutoMode)) + /** * Clears all data - useful for test setup/teardown */ diff --git a/fasting-stages.md b/fasting-stages.md new file mode 100644 index 00000000..279ec13b --- /dev/null +++ b/fasting-stages.md @@ -0,0 +1,116 @@ +# The Fasting Journey + +Ten stages, one continuous arc. Written for an intelligent reader who is comfortable +with the science — personal and vivid, but never dumbed down. Real figures where they +earn their place, cited in shortest-lookup form. Dense enough to reward 30–60 seconds +of attention. + +This file is the fasting spec: each stage below maps to one milestone dot on the dial. + +Citations are compressed to `Author, Journal Year` — enough to find the paper. + +--- + +## 0–2 h · Blood Sugar Rises · 🌅 + +You just ate, and your bloodstream knows it. Glucose climbs — in healthy people peaking under ~140 mg/dL within 30–60 min — and your pancreas answers with insulin, the hormone that ushers that sugar into cells and banks the surplus as glycogen. + +This is the fed state: energy is abundant, storage is the priority, fat-burning is switched off. + +Nothing to do but digest. The clock has started. + +## 2–5 h · Blood Sugar Falls · 🌊 + +Absorption tapers and the tide turns. Insulin recedes, glucagon rises, and your liver starts releasing stored glycogen to hold blood glucose steady near ~70–100 mg/dL. + +You've crossed from the fed state into the post-absorptive state — the quiet handoff where the body stops storing and starts spending. + +That first flicker of hunger is mostly insulin falling. It passes. + +## 5–8 h · Blood Sugar Stabilizes · ⚖️ + +Your liver is now the sole supplier, drip-feeding glucose from glycogen (~100 g in reserve) to keep you level. Your brain alone burns ~120 g of glucose a day, so this stability is not trivial. + +No dip, no spike — glycogenolysis holds the line and keeps hypoglycemia away. + +Focus often feels unusually clean here. That's not imagination; it's steady fuel. + +## 8–12 h · Gluconeogenesis · 🧪 + +Glycogen is running thin, so the liver takes up a second trade: building glucose from scratch — lactate, glycerol, amino acids — a process called gluconeogenesis. By ~16 h it already supplies roughly half of your glucose output (Cahill, Annu Rev Nutr 2006). + +This is metabolic flexibility made visible: your body will not let the brain go hungry. + +The hunger and mild dip you feel are the switch being thrown. Fat is next. + +## 12–18 h · Fat Burning · 🔥 + +Insulin is finally low enough to unlock your fat stores. Lipolysis frees fatty acids, the liver begins spinning them into ketones, and you pivot from sugar-burner to fat-burner. + +Every hour here nudges insulin sensitivity upward and draws down fat reserves — the metabolic core of why fasting works. + +A metallic taste or faint headache is early ketones. The fire is lit. + +## 18–24 h · Ketosis · 💎 + +Blood ketones cross ~0.5 mmol/L — nutritional ketosis — and keep climbing. Your brain can't oxidize fatty acids directly, but it runs beautifully on this ketone stream, which spares muscle from being cannibalized for glucose. + +Many report an unusual mental clarity, and a hunger that quietly switches off — ketones themselves blunt appetite. + +Twenty-four hours. Your body now prefers the fuel it's making. + +## 24–48 h · Autophagy · ♻️ + +A full day in, and self-cleaning intensifies: autophagy — cells disassembling damaged proteins and organelles and recycling the parts (the mechanism that earned Ohsumi the 2016 Nobel). Human timing is still being mapped, but the pathway is clearly upregulated by fasting. + +Think of it as maintenance you can't buy — clearance associated with longevity and lower disease risk. + +Tiredness or a headache? Salt and water. Deep work is underway. + +## 48–54 h · Growth Hormone Surge · 💪 + +Two days, and growth hormone spikes — a ~2-day fast can raise GH secretion roughly 5-fold (Ho, J Clin Invest 1988). Its brief right now: defend lean muscle and push fat metabolism harder. + +So you keep burning fat while sparing the muscle you'd expect to lose — a genuinely elegant adaptation. + +That unexpected lift in energy people describe here? That's the hormone talking. + +## 54–72 h · Insulin Sensitivity · 🎯 + +With insulin held low for days, your cells turn newly responsive to it — insulin sensitivity resets toward a healthier baseline, the opposite of the resistance that precedes type 2 diabetes. + +Blood glucose sits in a calm, narrow band; satiety comes easily. + +You're tuning a system most people never deliberately touch. + +## 72 h+ · Immune Cell Regeneration · 🌱 + +Three days: rare air. Prolonged fasting can prompt the body to clear senescent immune cells and regenerate new ones from hematopoietic stem cells, as IGF-1 and PKA signaling drop (Cheng, Cell Stem Cell 2014). + +Inflammation keeps easing; some describe a quiet, lucid sense of well-being. + +However far you go from here, you go renewed — and break your fast gently. Be proud. + +--- + +### Icon anchors + +| Stage | Icon | Anchor | +|---|---|---| +| 0–2 h | 🌅 | Dawn — the fed state begins | +| 2–5 h | 🌊 | Tide turning — glucose ebbs | +| 5–8 h | ⚖️ | Balance — glycemic steadiness | +| 8–12 h | 🧪 | Chemistry — glucose synthesized de novo | +| 12–18 h | 🔥 | Fire — fat becomes fuel | +| 18–24 h | 💎 | Clarity — ketones feed the brain | +| 24–48 h | ♻️ | Recycling — cellular self-cleaning | +| 48–54 h | 💪 | Strength — muscle defended | +| 54–72 h | 🎯 | Precision — insulin retuned | +| 72 h+ | 🌱 | New growth — immune renewal | + +### Sources + +- Cahill GF. *Fuel metabolism in starvation.* Annu Rev Nutr 2006;26:1–22. +- Ho KY et al. *Fasting enhances growth hormone secretion…* J Clin Invest 1988;81(4):968–75. +- Cheng CW et al. *Prolonged fasting reduces IGF-1/PKA to promote hematopoietic-stem-cell regeneration.* Cell Stem Cell 2014;14(6):810–23. +- Ohsumi Y. Nobel Prize in Physiology or Medicine 2016 (autophagy mechanisms). diff --git a/gradle.properties b/gradle.properties index da39f2fe..1c79a234 100644 --- a/gradle.properties +++ b/gradle.properties @@ -15,8 +15,6 @@ org.gradle.jvmargs=-Xmx2048m # Android operating system, and which are packaged with your app"s APK # https://developer.android.com/topic/libraries/support-library/androidx-rn android.useAndroidX=true -# Automatically convert third-party libraries to use AndroidX -# Kept on because MaterialAbout and CustomDateTimePicker are old jitpack libs that may still reference support-lib classes -android.enableJetifier=true # Kotlin code style for this project: "official" or "obsolete": -kotlin.code.style=official \ No newline at end of file +kotlin.code.style=official +org.gradle.configuration-cache=true \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index dc77a2dc..1f42bd23 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -38,7 +38,6 @@ ksp = "2.3.8" legacySupportV4 = "1.0.0" lifecycleExtensions = "2.2.0" lifecycleViewmodelKtx = "2.10.0" -materialabout = "0.3.0" napier = "2.7.1" navigationRuntimeKtx = "2.9.8" navigationUiKtx = "2.9.8" @@ -95,7 +94,6 @@ compiler = { module = "com.github.bumptech.glide:compiler", version.ref = "glide constraintlayout = { module = "androidx.constraintlayout:constraintlayout", version.ref = "constraintlayout" } core = { module = "io.noties.markwon:core", version.ref = "core" } core-ktx = { module = "androidx.core:core-ktx", version.ref = "coreKtx" } -customdatetimepicker = { module = "com.github.noowenz:CustomDateTimePicker", version.ref = "customdatetimepicker" } ext-latex = { module = "io.noties.markwon:ext-latex", version.ref = "core" } ext-tables = { module = "io.noties.markwon:ext-tables", version.ref = "core" } ext-strikethrough = { module = "io.noties.markwon:ext-strikethrough", version.ref = "core" } @@ -117,7 +115,6 @@ kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutine kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinxDatetime" } kotlinx-serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "kotlinxSerializationCore" } linkify = { module = "io.noties.markwon:linkify", version.ref = "core" } -materialabout = { module = "com.github.jrvansuita:MaterialAbout", version.ref = "materialabout" } napier = { module = "io.github.aakira:napier", version.ref = "napier" } navigation-runtime-ktx = { module = "androidx.navigation:navigation-runtime-ktx", version.ref = "navigationRuntimeKtx" } recyclerview = { module = "androidx.recyclerview:recyclerview", version.ref = "recyclerview" } diff --git a/gradlew b/gradlew old mode 100644 new mode 100755