diff --git a/.gitignore b/.gitignore index a90ddc1..653c40c 100644 --- a/.gitignore +++ b/.gitignore @@ -68,3 +68,5 @@ fastlane/readme.md app/schemas/com.example.android.january2022.db.GymDatabase/1.json *.json /app/signing/ +.idea +.DS_Store diff --git a/.idea/.gitignore b/.idea/.gitignore deleted file mode 100644 index 26d3352..0000000 --- a/.idea/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -# Default ignored files -/shelf/ -/workspace.xml diff --git a/.idea/.name b/.idea/.name deleted file mode 100644 index e773e77..0000000 --- a/.idea/.name +++ /dev/null @@ -1 +0,0 @@ -January 2022 \ No newline at end of file diff --git a/.idea/androidTestResultsUserPreferences.xml b/.idea/androidTestResultsUserPreferences.xml deleted file mode 100644 index 7a6e8f8..0000000 --- a/.idea/androidTestResultsUserPreferences.xml +++ /dev/null @@ -1,37 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.idea/compiler.xml b/.idea/compiler.xml deleted file mode 100644 index fb7f4a8..0000000 --- a/.idea/compiler.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml deleted file mode 100644 index ea46863..0000000 --- a/.idea/inspectionProfiles/Project_Default.xml +++ /dev/null @@ -1,38 +0,0 @@ - - - - \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml deleted file mode 100644 index 05b4063..0000000 --- a/.idea/misc.xml +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - - - - - - \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml deleted file mode 100644 index 180dfd3..0000000 --- a/.idea/modules.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - - - - \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml deleted file mode 100644 index 94a25f7..0000000 --- a/.idea/vcs.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/app/src/androidTest/java/com/example/android/january2022/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/example/android/january2022/ExampleInstrumentedTest.kt index 9791867..fd5165a 100644 --- a/app/src/androidTest/java/com/example/android/january2022/ExampleInstrumentedTest.kt +++ b/app/src/androidTest/java/com/example/android/january2022/ExampleInstrumentedTest.kt @@ -1,13 +1,11 @@ package com.example.android.january2022 -import androidx.test.platform.app.InstrumentationRegistry import androidx.test.ext.junit.runners.AndroidJUnit4 - +import androidx.test.platform.app.InstrumentationRegistry +import junit.framework.TestCase.assertEquals import org.junit.Test import org.junit.runner.RunWith -import org.junit.Assert.* - /** * Instrumented test, which will execute on an Android device. * @@ -21,4 +19,4 @@ class ExampleInstrumentedTest { val appContext = InstrumentationRegistry.getInstrumentation().targetContext assertEquals("com.example.android.january2022", appContext.packageName) } -} \ No newline at end of file +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 65fdae6..54e1434 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -15,7 +15,6 @@ diff --git a/app/src/main/java/com/example/android/january2022/GymApp.kt b/app/src/main/java/com/example/android/january2022/GymApp.kt index f25b197..73c98ec 100644 --- a/app/src/main/java/com/example/android/january2022/GymApp.kt +++ b/app/src/main/java/com/example/android/january2022/GymApp.kt @@ -9,24 +9,24 @@ import dagger.hilt.android.HiltAndroidApp @HiltAndroidApp class GymApp : Application() { - override fun onCreate() { - super.onCreate() - val channel = NotificationChannel( - TimerService.CHANNEL_ID, - "Workout Timer", - NotificationManager.IMPORTANCE_DEFAULT - ).also { - it.setSound(null, null) + override fun onCreate() { + super.onCreate() + val channel = NotificationChannel( + TimerService.CHANNEL_ID, + "Workout Timer", + NotificationManager.IMPORTANCE_DEFAULT, + ).also { + it.setSound(null, null) + } + val alertChannel = NotificationChannel( + TimerService.ALERT_CHANNEL_ID, + "Workout Timer Alerts", + NotificationManager.IMPORTANCE_HIGH, + ).also { + it.enableVibration(true) + } + val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + notificationManager.createNotificationChannel(channel) + notificationManager.createNotificationChannel(alertChannel) } - val alertChannel = NotificationChannel( - TimerService.ALERT_CHANNEL_ID, - "Workout Timer Alerts", - NotificationManager.IMPORTANCE_HIGH - ).also { - it.enableVibration(true) - } - val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - notificationManager.createNotificationChannel(channel) - notificationManager.createNotificationChannel(alertChannel) - } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/example/android/january2022/MainActivity.kt b/app/src/main/java/com/example/android/january2022/MainActivity.kt index 0f3d43d..58bcb0c 100644 --- a/app/src/main/java/com/example/android/january2022/MainActivity.kt +++ b/app/src/main/java/com/example/android/january2022/MainActivity.kt @@ -16,42 +16,41 @@ import dagger.hilt.android.AndroidEntryPoint import timber.log.Timber import timber.log.Timber.DebugTree - @AndroidEntryPoint class MainActivity : ComponentActivity() { - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - if (BuildConfig.DEBUG) { - Timber.plant(DebugTree()) - } - setContent { - WorkoutTheme { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - ActivityCompat.requestPermissions( - this, - arrayOf(Manifest.permission.POST_NOTIFICATIONS), - 0 - ) + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + if (BuildConfig.DEBUG) { + Timber.plant(DebugTree()) + } + setContent { + WorkoutTheme { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + ActivityCompat.requestPermissions( + this, + arrayOf(Manifest.permission.POST_NOTIFICATIONS), + 0, + ) + } + val navController = rememberNavController() + WindowCompat.setDecorFitsSystemWindows(window, false) + NavHost(navController) + } } - val navController = rememberNavController() - WindowCompat.setDecorFitsSystemWindows(window, false) - NavHost(navController) - } } - } - override fun onStart() { - super.onStart() - this.sendTimerIntent { - it.action = TimerService.Actions.MOVE_TO_BACKGROUND.toString() + override fun onStart() { + super.onStart() + this.sendTimerIntent { + it.action = TimerService.Actions.MOVE_TO_BACKGROUND.toString() + } } - } - override fun onPause() { - super.onPause() - this.sendTimerIntent { - it.action = TimerService.Actions.MOVE_TO_FOREGROUND.toString() + override fun onPause() { + super.onPause() + this.sendTimerIntent { + it.action = TimerService.Actions.MOVE_TO_FOREGROUND.toString() + } } - } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/example/android/january2022/db/Equipment.kt b/app/src/main/java/com/example/android/january2022/db/Equipment.kt index 0d5eb5e..2ff8b97 100644 --- a/app/src/main/java/com/example/android/january2022/db/Equipment.kt +++ b/app/src/main/java/com/example/android/january2022/db/Equipment.kt @@ -1,6 +1,5 @@ package com.example.android.january2022.db - object Equipment { const val ATLAS_STONE = "Atlas Stone" const val BARBELL = "Barbell" @@ -40,7 +39,7 @@ object Equipment { SMITH_MACHINE, STABILITY_BALL, SUSPENSION, - WEIGHT + WEIGHT, ) } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/example/android/january2022/db/GymDAO.kt b/app/src/main/java/com/example/android/january2022/db/GymDAO.kt index 38b9f9b..7513509 100644 --- a/app/src/main/java/com/example/android/january2022/db/GymDAO.kt +++ b/app/src/main/java/com/example/android/january2022/db/GymDAO.kt @@ -1,89 +1,96 @@ package com.example.android.january2022.db -import androidx.room.* -import com.example.android.january2022.db.entities.* +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Update +import com.example.android.january2022.db.entities.Exercise +import com.example.android.january2022.db.entities.GymSet +import com.example.android.january2022.db.entities.Session +import com.example.android.january2022.db.entities.SessionExercise +import com.example.android.january2022.db.entities.SessionExerciseWithExercise import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.StateFlow - @Dao interface GymDAO { - @Query("SELECT * FROM sessions WHERE sessionId = :sessionId") - fun getSessionById(sessionId: Long): Session + @Query("SELECT * FROM sessions WHERE sessionId = :sessionId") + fun getSessionById(sessionId: Long): Session - @Query("SELECT * FROM sets ORDER BY setId ASC") - fun getAllSets(): Flow> + @Query("SELECT * FROM sets ORDER BY setId ASC") + fun getAllSets(): Flow> - @Query("SELECT * FROM sessions ORDER BY start DESC") - fun getAllSessions(): Flow> + @Query("SELECT * FROM sessions ORDER BY start DESC") + fun getAllSessions(): Flow> - @Query("SELECT * FROM sessions ORDER BY sessionId DESC LIMIT 1") - fun getLastSession(): Session + @Query("SELECT * FROM sessions ORDER BY sessionId DESC LIMIT 1") + fun getLastSession(): Session - @Query("SELECT * FROM exercises ORDER BY title ASC") - fun getAllExercises(): Flow> + @Query("SELECT * FROM exercises ORDER BY title ASC") + fun getAllExercises(): Flow> - @Query("SELECT * FROM sessionExercises join exercises ON sessionExercises.parentExerciseId = exercises.id") - fun getAllSessionExercises(): Flow> + @Query("SELECT * FROM sessionExercises join exercises ON sessionExercises.parentExerciseId = exercises.id") + fun getAllSessionExercises(): Flow> - @Query("SELECT * FROM sessionExercises JOIN exercises ON sessionExercises.parentExerciseId = exercises.id WHERE parentSessionId = :sessionId") - fun getExercisesForSession(sessionId: Long): Flow> + @Query("SELECT * FROM sessionExercises JOIN exercises ON sessionExercises.parentExerciseId = exercises.id WHERE parentSessionId = :sessionId") + fun getExercisesForSession(sessionId: Long): Flow> - @Query("SELECT * FROM sets WHERE parentSessionExerciseId = :id ORDER BY setId ASC") - fun getSetsForExercise(id: Long): Flow> + @Query("SELECT * FROM sets WHERE parentSessionExerciseId = :id ORDER BY setId ASC") + fun getSetsForExercise(id: Long): Flow> - @Query("SELECT GROUP_CONCAT(targets,'|') FROM exercises as e JOIN sessionExercises as se ON e.id = se.parentExerciseId WHERE se.parentSessionId = :sessionId") - fun getMuscleGroupsForSession(sessionId: Long): Flow + @Query("SELECT GROUP_CONCAT(targets,'|') FROM exercises as e JOIN sessionExercises as se ON e.id = se.parentExerciseId WHERE se.parentSessionId = :sessionId") + fun getMuscleGroupsForSession(sessionId: Long): Flow - @Insert(onConflict = OnConflictStrategy.IGNORE) - suspend fun insertSession(session: Session): Long + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insertSession(session: Session): Long - @Delete - suspend fun removeSession(session: Session) + @Delete + suspend fun removeSession(session: Session) - @Update - suspend fun updateSession(session: Session) - @Insert(onConflict = OnConflictStrategy.IGNORE) - suspend fun insertExercise(exercise: Exercise): Long + @Update + suspend fun updateSession(session: Session) - @Insert(onConflict = OnConflictStrategy.IGNORE) - suspend fun insertSessionExercise(sessionExercise: SessionExercise): Long + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insertExercise(exercise: Exercise): Long - @Delete - suspend fun removeSessionExercise(sessionExercise: SessionExercise) + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insertSessionExercise(sessionExercise: SessionExercise): Long - @Insert(onConflict = OnConflictStrategy.IGNORE) - suspend fun insertSet(set: GymSet): Long + @Delete + suspend fun removeSessionExercise(sessionExercise: SessionExercise) - @Update - suspend fun updateSet(set: GymSet) + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insertSet(set: GymSet): Long - @Delete - suspend fun deleteSet(set: GymSet) + @Update + suspend fun updateSet(set: GymSet) - @Query("SELECT * FROM sessions") - fun getSessionList(): List + @Delete + suspend fun deleteSet(set: GymSet) - @Query("SELECT * FROM exercises") - fun getExerciseList(): List + @Query("SELECT * FROM sessions") + fun getSessionList(): List - @Query("SELECT * FROM sessionExercises") - fun getSessionExerciseList(): List + @Query("SELECT * FROM exercises") + fun getExerciseList(): List - @Query("SELECT * FROM sets") - fun getSetList(): List + @Query("SELECT * FROM sessionExercises") + fun getSessionExerciseList(): List - @Query("DELETE FROM sessions") - suspend fun clearSessions() + @Query("SELECT * FROM sets") + fun getSetList(): List - @Query("DELETE FROM sessionExercises") - suspend fun clearSessionExercises() + @Query("DELETE FROM sessions") + suspend fun clearSessions() - @Query("DELETE FROM sets") - suspend fun clearSets() + @Query("DELETE FROM sessionExercises") + suspend fun clearSessionExercises() - @Query("DELETE FROM exercises") - suspend fun clearExercises() -} + @Query("DELETE FROM sets") + suspend fun clearSets() + @Query("DELETE FROM exercises") + suspend fun clearExercises() +} diff --git a/app/src/main/java/com/example/android/january2022/db/GymDatabase.kt b/app/src/main/java/com/example/android/january2022/db/GymDatabase.kt index 6b2fe06..e0d60d4 100644 --- a/app/src/main/java/com/example/android/january2022/db/GymDatabase.kt +++ b/app/src/main/java/com/example/android/january2022/db/GymDatabase.kt @@ -1,23 +1,24 @@ package com.example.android.january2022.db -import androidx.room.* +import androidx.room.Database +import androidx.room.RoomDatabase +import androidx.room.TypeConverters import com.example.android.january2022.db.entities.Exercise import com.example.android.january2022.db.entities.GymSet import com.example.android.january2022.db.entities.Session import com.example.android.january2022.db.entities.SessionExercise import com.example.android.january2022.utils.Converters - @Database( entities = [ Session::class, Exercise::class, SessionExercise::class, - GymSet::class - ], - autoMigrations = [ + GymSet::class, ], - version = 2, exportSchema = true + autoMigrations = [], + version = 2, + exportSchema = true, ) @TypeConverters(Converters::class) abstract class GymDatabase : RoomDatabase() { @@ -26,5 +27,4 @@ abstract class GymDatabase : RoomDatabase() { * Connects the database to the DAO. */ abstract val dao: GymDAO - } diff --git a/app/src/main/java/com/example/android/january2022/db/GymRepository.kt b/app/src/main/java/com/example/android/january2022/db/GymRepository.kt index e32498f..5e40d8a 100644 --- a/app/src/main/java/com/example/android/january2022/db/GymRepository.kt +++ b/app/src/main/java/com/example/android/january2022/db/GymRepository.kt @@ -1,89 +1,94 @@ package com.example.android.january2022.db -import com.example.android.january2022.db.entities.* +import com.example.android.january2022.db.entities.Exercise +import com.example.android.january2022.db.entities.GymSet +import com.example.android.january2022.db.entities.Session +import com.example.android.january2022.db.entities.SessionExercise +import com.example.android.january2022.db.entities.SessionExerciseWithExercise import com.example.android.january2022.ui.DatabaseModel import com.example.android.january2022.utils.turnTargetIntoMuscleGroups import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.* +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.mapNotNull import timber.log.Timber - class GymRepository( - private val dao: GymDAO + private val dao: GymDAO, ) { - fun getSessionById(sessionId: Long) = dao.getSessionById(sessionId) + fun getSessionById(sessionId: Long) = dao.getSessionById(sessionId) - fun getAllSessions() = dao.getAllSessions() + fun getAllSessions() = dao.getAllSessions() - fun getAllSets() = dao.getAllSets() - fun getAllExercises() = dao.getAllExercises() + fun getAllSets() = dao.getAllSets() + fun getAllExercises() = dao.getAllExercises() - fun getLastSession() = dao.getLastSession() + fun getLastSession() = dao.getLastSession() - fun getAllSessionExercises() = dao.getAllSessionExercises() + fun getAllSessionExercises() = dao.getAllSessionExercises() - @OptIn(ExperimentalCoroutinesApi::class) - fun getExercisesForSession(session: Flow): Flow> { - return session.flatMapLatest { - dao.getExercisesForSession(it.sessionId) + @OptIn(ExperimentalCoroutinesApi::class) + fun getExercisesForSession(session: Flow): Flow> { + return session.flatMapLatest { + dao.getExercisesForSession(it.sessionId) + } } - } - - fun getExercisesForSession(session: Session): Flow> { - Timber.d("Retrieving exercises for session: $session") - return dao.getExercisesForSession(session.sessionId) - } - - fun getSetsForExercise(sessionExerciseId: Long) = dao.getSetsForExercise(sessionExerciseId) - - fun getMuscleGroupsForSession(session: Session): Flow> { - val list = dao.getMuscleGroupsForSession(session.sessionId).mapNotNull { - Timber.d("MuscleGroup flow created.") - try { - turnTargetIntoMuscleGroups(it) - } catch (_: Exception) { - Timber.d("Error when converting target.") - emptyList() - } + + fun getExercisesForSession(session: Session): Flow> { + Timber.d("Retrieving exercises for session: $session") + return dao.getExercisesForSession(session.sessionId) } - return list - } - suspend fun insertExercise(exercise: Exercise) = dao.insertExercise(exercise) + fun getSetsForExercise(sessionExerciseId: Long) = dao.getSetsForExercise(sessionExerciseId) + + fun getMuscleGroupsForSession(session: Session): Flow> { + val list = dao.getMuscleGroupsForSession(session.sessionId).mapNotNull { + Timber.d("MuscleGroup flow created.") + try { + turnTargetIntoMuscleGroups(it) + } catch (_: Exception) { + Timber.d("Error when converting target.") + emptyList() + } + } + return list + } - suspend fun insertSession(session: Session) = dao.insertSession(session) + suspend fun insertExercise(exercise: Exercise) = dao.insertExercise(exercise) - suspend fun removeSession(session: Session) = dao.removeSession(session) + suspend fun insertSession(session: Session) = dao.insertSession(session) - suspend fun updateSession(session: Session) = dao.updateSession(session) + suspend fun removeSession(session: Session) = dao.removeSession(session) - suspend fun insertSessionExercise(sessionExercise: SessionExercise) = - dao.insertSessionExercise(sessionExercise) + suspend fun updateSession(session: Session) = dao.updateSession(session) - suspend fun removeSessionExercise(sessionExercise: SessionExercise) = - dao.removeSessionExercise(sessionExercise) + suspend fun insertSessionExercise(sessionExercise: SessionExercise) = + dao.insertSessionExercise(sessionExercise) - suspend fun insertSet(gymSet: GymSet) = dao.insertSet(gymSet) + suspend fun removeSessionExercise(sessionExercise: SessionExercise) = + dao.removeSessionExercise(sessionExercise) - suspend fun updateSet(set: GymSet) = dao.updateSet(set) - suspend fun deleteSet(set: GymSet) = dao.deleteSet(set) + suspend fun insertSet(gymSet: GymSet) = dao.insertSet(gymSet) - suspend fun createSet(sessionExercise: SessionExercise) = - dao.insertSet(GymSet(parentSessionExerciseId = sessionExercise.sessionExerciseId)) + suspend fun updateSet(set: GymSet) = dao.updateSet(set) + suspend fun deleteSet(set: GymSet) = dao.deleteSet(set) - fun getDatabaseModel() = - DatabaseModel( - sessions = dao.getSessionList(), - exercises = dao.getExerciseList(), - sessionExercises = dao.getSessionExerciseList(), - sets = dao.getSetList() - ) + suspend fun createSet(sessionExercise: SessionExercise) = + dao.insertSet(GymSet(parentSessionExerciseId = sessionExercise.sessionExerciseId)) - suspend fun clearDatabase() { - dao.clearSessions() - dao.clearSessionExercises() - dao.clearExercises() - dao.clearSets() - } + fun getDatabaseModel() = + DatabaseModel( + sessions = dao.getSessionList(), + exercises = dao.getExerciseList(), + sessionExercises = dao.getSessionExerciseList(), + sets = dao.getSetList(), + ) + + suspend fun clearDatabase() { + dao.clearSessions() + dao.clearSessionExercises() + dao.clearExercises() + dao.clearSets() + } } diff --git a/app/src/main/java/com/example/android/january2022/db/MuscleGroup.kt b/app/src/main/java/com/example/android/january2022/db/MuscleGroup.kt index 7ef7276..7190b10 100644 --- a/app/src/main/java/com/example/android/january2022/db/MuscleGroup.kt +++ b/app/src/main/java/com/example/android/january2022/db/MuscleGroup.kt @@ -33,7 +33,7 @@ object MuscleGroup { FOREARMS, TRICEPS, CHEST, - SHOULDERS + SHOULDERS, ) } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/example/android/january2022/db/SetType.kt b/app/src/main/java/com/example/android/january2022/db/SetType.kt index f1f056f..4c948ec 100644 --- a/app/src/main/java/com/example/android/january2022/db/SetType.kt +++ b/app/src/main/java/com/example/android/january2022/db/SetType.kt @@ -7,13 +7,12 @@ object SetType { const val HARD = "Hard" const val DROP = "Drop" - - private val order by lazy { listOf(WARMUP, EASY, NORMAL, HARD, DROP)} + private val order by lazy { listOf(WARMUP, EASY, NORMAL, HARD, DROP) } fun add(current: String): String { val currentIndex: Int = order.indexOf(current) - val nextIndex = (currentIndex+1) % order.size + val nextIndex = (currentIndex + 1) % order.size return order[nextIndex] } fun next(current: String): String = add(current) -} \ No newline at end of file +} diff --git a/app/src/main/java/com/example/android/january2022/db/StartingExercises.kt b/app/src/main/java/com/example/android/january2022/db/StartingExercises.kt index 96df19f..2862275 100644 --- a/app/src/main/java/com/example/android/january2022/db/StartingExercises.kt +++ b/app/src/main/java/com/example/android/january2022/db/StartingExercises.kt @@ -19,73 +19,71 @@ import javax.inject.Inject import javax.inject.Provider class StartingExercises @Inject constructor( - private val repository: Provider, - @ApplicationScope private val applicationScope: CoroutineScope, - @ApplicationContext private val context: Context + private val repository: Provider, + @ApplicationScope private val applicationScope: CoroutineScope, + @ApplicationContext private val context: Context, ) : RoomDatabase.Callback() { - override fun onCreate(db: SupportSQLiteDatabase) { - super.onCreate(db) - applicationScope.launch { - fillWithStartingExercises(context, repository.get()) + override fun onCreate(db: SupportSQLiteDatabase) { + super.onCreate(db) + applicationScope.launch { + fillWithStartingExercises(context, repository.get()) + } } - } - private suspend fun fillWithStartingExercises( - context: Context, - repository: GymRepository - ) { - Timber.d("Starting process") - try { - val exercises = loadJSONArray(context) - for (i in 0 until exercises.length()) { - val item = exercises.getJSONObject(i) - val title = item.getString("title") - val type = item.getString("type") - val force = item.parseToList("force") - val equipment = item.parseToList("equipment") - val targets = item.parseToList("targets") - val synergists = item.parseToList("synergists") - val stabilizers = item.parseToList("stabilizers") + private suspend fun fillWithStartingExercises( + context: Context, + repository: GymRepository, + ) { + Timber.d("Starting process") + try { + val exercises = loadJSONArray(context) + for (i in 0 until exercises.length()) { + val item = exercises.getJSONObject(i) + val title = item.getString("title") + val type = item.getString("type") + val force = item.parseToList("force") + val equipment = item.parseToList("equipment") + val targets = item.parseToList("targets") + val synergists = item.parseToList("synergists") + val stabilizers = item.parseToList("stabilizers") - val exercise = Exercise( - title = title, - force = force, - type = type, - equipment = equipment, - targets = targets, - synergists = synergists, - stabilizers = stabilizers - ) - val result = repository.insertExercise(exercise) - if (result == -1L) Timber.d("Exercise insertion failed for $exercise") - } - - } catch (e: Exception) { - Timber.d("fillWithStartingExercises: $e") - Timber.d("Error caught") + val exercise = Exercise( + title = title, + force = force, + type = type, + equipment = equipment, + targets = targets, + synergists = synergists, + stabilizers = stabilizers, + ) + val result = repository.insertExercise(exercise) + if (result == -1L) Timber.d("Exercise insertion failed for $exercise") + } + } catch (e: Exception) { + Timber.d("fillWithStartingExercises: $e") + Timber.d("Error caught") + } } - } - private fun JSONObject.parseToList(propertyName: String): List { - val gson = GsonBuilder().create() - return try { - Timber.d("Parsing $propertyName") - val list = gson.fromJson(this.getString(propertyName), Array::class.java).toList() - Timber.d("List: $list") - return list - } catch (e: JSONException) { - println("Error parsing $propertyName: ${e.message}") - emptyList() + private fun JSONObject.parseToList(propertyName: String): List { + val gson = GsonBuilder().create() + return try { + Timber.d("Parsing $propertyName") + val list = gson.fromJson(this.getString(propertyName), Array::class.java).toList() + Timber.d("List: $list") + return list + } catch (e: JSONException) { + println("Error parsing $propertyName: ${e.message}") + emptyList() + } } - } - - private fun loadJSONArray(context: Context): JSONArray { - Timber.d("loading JSON array") - val inputStream = context.resources.openRawResource(R.raw.exercises) + private fun loadJSONArray(context: Context): JSONArray { + Timber.d("loading JSON array") + val inputStream = context.resources.openRawResource(R.raw.exercises) - BufferedReader(inputStream.reader()).use { - return JSONArray(it.readText()) + BufferedReader(inputStream.reader()).use { + return JSONArray(it.readText()) + } } - } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/example/android/january2022/db/entities/Exercise.kt b/app/src/main/java/com/example/android/january2022/db/entities/Exercise.kt index 20730f6..fa71c06 100644 --- a/app/src/main/java/com/example/android/january2022/db/entities/Exercise.kt +++ b/app/src/main/java/com/example/android/january2022/db/entities/Exercise.kt @@ -5,26 +5,25 @@ import androidx.room.PrimaryKey import com.example.android.january2022.utils.FuzzySearch import com.example.android.january2022.utils.turnTargetIntoMuscleGroups - @Entity(tableName = "exercises") data class Exercise( - @PrimaryKey(autoGenerate = true) - var id: Long = 0L, - var title: String = "Exercise", - var type: String? = null, - var force: List = emptyList(), - var equipment: List = emptyList(), - var targets: List = emptyList(), - var synergists: List = emptyList(), - var stabilizers: List = emptyList() + @PrimaryKey(autoGenerate = true) + var id: Long = 0L, + var title: String = "Exercise", + var type: String? = null, + var force: List = emptyList(), + var equipment: List = emptyList(), + var targets: List = emptyList(), + var synergists: List = emptyList(), + var stabilizers: List = emptyList(), ) { - fun getMuscleGroups(exercise: Exercise = this): List { - return exercise.targets.flatMap { - turnTargetIntoMuscleGroups(it) - }.distinct() - } + fun getMuscleGroups(exercise: Exercise = this): List { + return exercise.targets.flatMap { + turnTargetIntoMuscleGroups(it) + }.distinct() + } - fun getStringMatch(string: String): Boolean { - return FuzzySearch.regexMatch(string, title) - } -} \ No newline at end of file + fun getStringMatch(string: String): Boolean { + return FuzzySearch.regexMatch(string, title) + } +} diff --git a/app/src/main/java/com/example/android/january2022/db/entities/Session.kt b/app/src/main/java/com/example/android/january2022/db/entities/Session.kt index 4ab79f5..ada8053 100644 --- a/app/src/main/java/com/example/android/january2022/db/entities/Session.kt +++ b/app/src/main/java/com/example/android/january2022/db/entities/Session.kt @@ -4,14 +4,13 @@ import androidx.room.Entity import androidx.room.PrimaryKey import java.time.LocalDateTime - /** * A workout-session contains multiple SessionExercises. Has a start and end-time. */ @Entity(tableName = "sessions") data class Session( - @PrimaryKey(autoGenerate = true) - val sessionId: Long = 0L, - val start: LocalDateTime = LocalDateTime.now(), - val end: LocalDateTime? = null -) \ No newline at end of file + @PrimaryKey(autoGenerate = true) + val sessionId: Long = 0L, + val start: LocalDateTime = LocalDateTime.now(), + val end: LocalDateTime? = null, +) diff --git a/app/src/main/java/com/example/android/january2022/db/entities/SessionExercise.kt b/app/src/main/java/com/example/android/january2022/db/entities/SessionExercise.kt index d3db9e3..2e7338b 100644 --- a/app/src/main/java/com/example/android/january2022/db/entities/SessionExercise.kt +++ b/app/src/main/java/com/example/android/january2022/db/entities/SessionExercise.kt @@ -7,27 +7,27 @@ import androidx.room.PrimaryKey @Entity(tableName = "sessionExercises") data class SessionExercise( - @PrimaryKey(autoGenerate = true) - val sessionExerciseId: Long = 0, - @ColumnInfo(index = true) - val parentSessionId: Long, - @ColumnInfo(index = true) - val parentExerciseId: Long, - val comment: String? = null + @PrimaryKey(autoGenerate = true) + val sessionExerciseId: Long = 0, + @ColumnInfo(index = true) + val parentSessionId: Long, + @ColumnInfo(index = true) + val parentExerciseId: Long, + val comment: String? = null, ) /** * Holds a sessionExercise and it's associated exercise. Embedded = bad? it works though. */ data class SessionExerciseWithExercise( - @Embedded - val sessionExercise: SessionExercise, - @Embedded - val exercise: Exercise + @Embedded + val sessionExercise: SessionExercise, + @Embedded + val exercise: Exercise, ) data class SessionWithSessionExerciseWithExercise( - @Embedded val session: Session, - @Embedded val sessionExercise: SessionExercise, - @Embedded val exercise: Exercise + @Embedded val session: Session, + @Embedded val sessionExercise: SessionExercise, + @Embedded val exercise: Exercise, ) diff --git a/app/src/main/java/com/example/android/january2022/db/entities/Set.kt b/app/src/main/java/com/example/android/january2022/db/entities/Set.kt index 8495135..cfc1b22 100644 --- a/app/src/main/java/com/example/android/january2022/db/entities/Set.kt +++ b/app/src/main/java/com/example/android/january2022/db/entities/Set.kt @@ -3,23 +3,21 @@ package com.example.android.january2022.db.entities import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.PrimaryKey -import com.example.android.january2022.db.Equipment import com.example.android.january2022.db.SetType - /** * A SessionExercise can have multiple Set:s associated with it. * Each Set is a number of reps with a specific weight (if applicable) */ @Entity(tableName = "sets") data class GymSet( - @PrimaryKey(autoGenerate = true) - val setId: Long = 0L, - @ColumnInfo(index = true) - val parentSessionExerciseId: Long, - val reps: Int? = null, - val weight: Float? = null, - val time: Long? = null, - val distance: Float? = null, - val setType: String = SetType.NORMAL -) \ No newline at end of file + @PrimaryKey(autoGenerate = true) + val setId: Long = 0L, + @ColumnInfo(index = true) + val parentSessionExerciseId: Long, + val reps: Int? = null, + val weight: Float? = null, + val time: Long? = null, + val distance: Float? = null, + val setType: String = SetType.NORMAL, +) diff --git a/app/src/main/java/com/example/android/january2022/di/AppModule.kt b/app/src/main/java/com/example/android/january2022/di/AppModule.kt index 95e410c..93f361c 100644 --- a/app/src/main/java/com/example/android/january2022/di/AppModule.kt +++ b/app/src/main/java/com/example/android/january2022/di/AppModule.kt @@ -14,42 +14,41 @@ import kotlinx.coroutines.SupervisorJob import javax.inject.Qualifier import javax.inject.Singleton - @Module @InstallIn(SingletonComponent::class) object AppModule { - @Provides - @Singleton - fun provideGymDatabase( - app: Application, - callback: StartingExercises - ): GymDatabase { - return Room - .databaseBuilder( - app, - GymDatabase::class.java, - "gym_database.db" - ) - .fallbackToDestructiveMigration() - .addCallback(callback) - .build() - } + @Provides + @Singleton + fun provideGymDatabase( + app: Application, + callback: StartingExercises, + ): GymDatabase { + return Room + .databaseBuilder( + app, + GymDatabase::class.java, + "gym_database.db", + ) + .fallbackToDestructiveMigration() + .addCallback(callback) + .build() + } - @Provides - @Singleton - fun provideGymRepository(db: GymDatabase): GymRepository { - return GymRepository(db.dao) - } + @Provides + @Singleton + fun provideGymRepository(db: GymDatabase): GymRepository { + return GymRepository(db.dao) + } - @ApplicationScope - @Provides - @Singleton - fun provideApplicationScope() = CoroutineScope(SupervisorJob()) + @ApplicationScope + @Provides + @Singleton + fun provideApplicationScope() = CoroutineScope(SupervisorJob()) } // detta används typ för att man ska kunna använda olika provideApplicationScopes // behövs tekniskt sätt inte då jag bara har ett scope @Retention(AnnotationRetention.RUNTIME) @Qualifier -annotation class ApplicationScope \ No newline at end of file +annotation class ApplicationScope diff --git a/app/src/main/java/com/example/android/january2022/timer/TimerService.kt b/app/src/main/java/com/example/android/january2022/timer/TimerService.kt index 525f206..226bf31 100644 --- a/app/src/main/java/com/example/android/january2022/timer/TimerService.kt +++ b/app/src/main/java/com/example/android/january2022/timer/TimerService.kt @@ -17,225 +17,227 @@ import kotlin.math.roundToInt class TimerService : Service() { - private var running = false - private var time = 0L - private var maxTime = 60000L - private val increment = 30 * 1000L - - private var showNotification = false - - private var timer: WorkoutTimer? = null - - private lateinit var notificationManager: NotificationManager - - override fun onBind(intent: Intent?): IBinder? { - return null - } - - override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - notificationManager = - ContextCompat.getSystemService(this, NotificationManager::class.java) as NotificationManager - - val action = intent?.action - Timber.d("Start Timer with action: $action") - when (action) { - Actions.TOGGLE.toString() -> toggle() - Actions.RESET.toString() -> reset() - Actions.INCREMENT.toString() -> increment() - Actions.DECREMENT.toString() -> decrement() - Actions.STOP.toString() -> stop() - Actions.MOVE_TO_FOREGROUND.toString() -> toForeground() - Actions.MOVE_TO_BACKGROUND.toString() -> toBackground() - Actions.QUERY.toString() -> sendStatus() - } - return super.onStartCommand(intent, flags, startId) - } - - private fun toForeground() { - if (running) { - showNotification = true - startForeground(1, buildStatusNotification()) - } - } - - private fun toBackground() { - showNotification = false - stopForeground(STOP_FOREGROUND_REMOVE) - } - - private fun toggle() { - if (running) stop() - else if (time <= 0L) start() else resume() - } - - private fun start() { - running = true - timer?.cancel() - Timber.d("maxTime: $maxTime") - timer = WorkoutTimer(maxTime).apply { start() } - sendStatus() - } - - private fun stop() { - timer?.cancel() - running = false - sendStatus() - } - - private fun resume() { - timer?.cancel() - timer = WorkoutTimer(time) - timer?.start() - running = true - sendStatus() - } - - private fun increment() { - if (running) { - stop() - maxTime += increment - time += increment - resume() - } else { - maxTime += increment - if (time > 0L) time += increment - } - sendStatus() - } - - private fun decrement() { - if (running) { - stop() - time = time.minus(increment).coerceAtLeast(0L) - if (time <= 0L) reset() else resume() - } else { - maxTime = maxTime.minus(increment).coerceAtLeast(0L) - time = time.minus(increment).coerceAtLeast(0L) - } - sendStatus() - } - - private fun reset() { - timer?.cancel() - timer = null - time = 0L - running = false - sendStatus() - } - - private fun sendStatus() { - val statusIntent = Intent().also { - it.action = Intents.STATUS.toString() - it.putExtra(Intents.Extras.IS_RUNNING.toString(), running) - it.putExtra(Intents.Extras.TIME.toString(), time) - it.putExtra(Intents.Extras.MAX_TIME.toString(), maxTime) - } - sendBroadcast(statusIntent) - Timber.d("Broadcasting status: $running, $time, $maxTime") - } - - private fun buildStatusNotification() = notification( - subText = time.toTimerString(), - channelId = CHANNEL_ID - ) - - private fun buildFinishedNotification() = notification( - contentText = "Timer finished, tap to return.", - progressBar = false, - channelId = ALERT_CHANNEL_ID - ) - - private fun notification( - contentText: String? = null, - subText: String? = null, - progressBar: Boolean = true, - channelId: String - ): Notification { - val max = if (progressBar) maxTime.toInt() else 0 - val progress = if (progressBar) time.toInt() else 0 - - val intent = Intent(this, MainActivity::class.java).also { - it.flags = Intent.FLAG_ACTIVITY_SINGLE_TOP - } - val pendingIntent = PendingIntent.getActivity( - this, - 0, - intent, - PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + private var running = false + private var time = 0L + private var maxTime = 60000L + private val increment = 30 * 1000L + + private var showNotification = false + + private var timer: WorkoutTimer? = null + + private lateinit var notificationManager: NotificationManager + + override fun onBind(intent: Intent?): IBinder? { + return null + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + notificationManager = + ContextCompat.getSystemService(this, NotificationManager::class.java) as NotificationManager + + val action = intent?.action + Timber.d("Start Timer with action: $action") + when (action) { + Actions.TOGGLE.toString() -> toggle() + Actions.RESET.toString() -> reset() + Actions.INCREMENT.toString() -> increment() + Actions.DECREMENT.toString() -> decrement() + Actions.STOP.toString() -> stop() + Actions.MOVE_TO_FOREGROUND.toString() -> toForeground() + Actions.MOVE_TO_BACKGROUND.toString() -> toBackground() + Actions.QUERY.toString() -> sendStatus() + } + return super.onStartCommand(intent, flags, startId) + } + + private fun toForeground() { + if (running) { + showNotification = true + startForeground(1, buildStatusNotification()) + } + } + + private fun toBackground() { + showNotification = false + stopForeground(STOP_FOREGROUND_REMOVE) + } + + private fun toggle() { + if (running) { + stop() + } else if (time <= 0L) start() else resume() + } + + private fun start() { + running = true + timer?.cancel() + Timber.d("maxTime: $maxTime") + timer = WorkoutTimer(maxTime).apply { start() } + sendStatus() + } + + private fun stop() { + timer?.cancel() + running = false + sendStatus() + } + + private fun resume() { + timer?.cancel() + timer = WorkoutTimer(time) + timer?.start() + running = true + sendStatus() + } + + private fun increment() { + if (running) { + stop() + maxTime += increment + time += increment + resume() + } else { + maxTime += increment + if (time > 0L) time += increment + } + sendStatus() + } + + private fun decrement() { + if (running) { + stop() + time = time.minus(increment).coerceAtLeast(0L) + if (time <= 0L) reset() else resume() + } else { + maxTime = maxTime.minus(increment).coerceAtLeast(0L) + time = time.minus(increment).coerceAtLeast(0L) + } + sendStatus() + } + + private fun reset() { + timer?.cancel() + timer = null + time = 0L + running = false + sendStatus() + } + + private fun sendStatus() { + val statusIntent = Intent().also { + it.action = Intents.STATUS.toString() + it.putExtra(Intents.Extras.IS_RUNNING.toString(), running) + it.putExtra(Intents.Extras.TIME.toString(), time) + it.putExtra(Intents.Extras.MAX_TIME.toString(), maxTime) + } + sendBroadcast(statusIntent) + Timber.d("Broadcasting status: $running, $time, $maxTime") + } + + private fun buildStatusNotification() = notification( + subText = time.toTimerString(), + channelId = CHANNEL_ID, + ) + + private fun buildFinishedNotification() = notification( + contentText = "Timer finished, tap to return.", + progressBar = false, + channelId = ALERT_CHANNEL_ID, ) - return NotificationCompat.Builder(this, channelId) - .setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE) - .setContentIntent(pendingIntent) - .setAutoCancel(true) - .setProgress(max, progress, false) - .setSmallIcon(R.drawable.ic_launcher_foreground) - .setContentTitle("Workout Timer") - .setContentText(contentText) - .setSubText(subText) - .build() - } - - private fun notify(notification: Notification) { - notificationManager.notify(1, notification) - } - - private fun alert(notification: Notification) { - notificationManager.notify(2, notification) - } - - enum class Actions { - TOGGLE, INCREMENT, DECREMENT, RESET, STOP, MOVE_TO_BACKGROUND, MOVE_TO_FOREGROUND, QUERY - } - - enum class Intents { - STATUS; - - enum class Extras { - TIME, IS_RUNNING, MAX_TIME - } - } - - inner class WorkoutTimer(length: Long, interval: Long = 1000L) : - CountDownTimer(length, interval) { - - override fun onTick(millisUntilFinished: Long) { - time = millisUntilFinished - if (showNotification) notify(buildStatusNotification()) - if (time <= 0L) onFinish() - sendStatus() - } - - override fun onFinish() { - Timber.d("Timer finished") - time = maxTime - alert(buildFinishedNotification()) - reset() - } - } - - companion object { - const val CHANNEL_ID = "workout_timer" - const val ALERT_CHANNEL_ID = "workout_alert_timer" - } + + private fun notification( + contentText: String? = null, + subText: String? = null, + progressBar: Boolean = true, + channelId: String, + ): Notification { + val max = if (progressBar) maxTime.toInt() else 0 + val progress = if (progressBar) time.toInt() else 0 + + val intent = Intent(this, MainActivity::class.java).also { + it.flags = Intent.FLAG_ACTIVITY_SINGLE_TOP + } + val pendingIntent = PendingIntent.getActivity( + this, + 0, + intent, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ) + return NotificationCompat.Builder(this, channelId) + .setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE) + .setContentIntent(pendingIntent) + .setAutoCancel(true) + .setProgress(max, progress, false) + .setSmallIcon(R.drawable.ic_launcher_foreground) + .setContentTitle("Workout Timer") + .setContentText(contentText) + .setSubText(subText) + .build() + } + + private fun notify(notification: Notification) { + notificationManager.notify(1, notification) + } + + private fun alert(notification: Notification) { + notificationManager.notify(2, notification) + } + + enum class Actions { + TOGGLE, INCREMENT, DECREMENT, RESET, STOP, MOVE_TO_BACKGROUND, MOVE_TO_FOREGROUND, QUERY + } + + enum class Intents { + STATUS, + ; + + enum class Extras { + TIME, IS_RUNNING, MAX_TIME + } + } + + inner class WorkoutTimer(length: Long, interval: Long = 1000L) : + CountDownTimer(length, interval) { + + override fun onTick(millisUntilFinished: Long) { + time = millisUntilFinished + if (showNotification) notify(buildStatusNotification()) + if (time <= 0L) onFinish() + sendStatus() + } + + override fun onFinish() { + Timber.d("Timer finished") + time = maxTime + alert(buildFinishedNotification()) + reset() + } + } + + companion object { + const val CHANNEL_ID = "workout_timer" + const val ALERT_CHANNEL_ID = "workout_alert_timer" + } } fun Long.toTimerString(): String { - val totalSeconds = this.toFloat().div(1000).roundToInt() - val minutes = totalSeconds / 60 - val seconds = totalSeconds % 60 - val displayedSeconds = if (seconds < 10) "0$seconds" else seconds - return "$minutes:$displayedSeconds" + val totalSeconds = this.toFloat().div(1000).roundToInt() + val minutes = totalSeconds / 60 + val seconds = totalSeconds % 60 + val displayedSeconds = if (seconds < 10) "0$seconds" else seconds + return "$minutes:$displayedSeconds" } fun Context.sendTimerAction(action: TimerService.Actions) { - this.sendTimerIntent { - it.action = action.toString() - } + this.sendTimerIntent { + it.action = action.toString() + } } fun Context.sendTimerIntent(also: (Intent) -> Unit) { - Intent(this, TimerService::class.java).also { - also(it) - startService(it) - } -} \ No newline at end of file + Intent(this, TimerService::class.java).also { + also(it) + startService(it) + } +} diff --git a/app/src/main/java/com/example/android/january2022/ui/NavHost.kt b/app/src/main/java/com/example/android/january2022/ui/NavHost.kt index 2183572..f3419e0 100644 --- a/app/src/main/java/com/example/android/january2022/ui/NavHost.kt +++ b/app/src/main/java/com/example/android/january2022/ui/NavHost.kt @@ -16,52 +16,50 @@ import com.example.android.january2022.utils.UiEvent @Composable fun NavHost( - navController: NavHostController + navController: NavHostController, ) { - - NavHost( - navController = navController, - startDestination = Routes.HOME - ) { - - composable(Routes.HOME) { - HomeScreen( - onNavigate = { navController.navigationEvent(event = it) }, - ) - } - composable( - route = "${Routes.SESSION}/{session_id}", - arguments = listOf( - navArgument("session_id") { - type = NavType.LongType - } - ) + NavHost( + navController = navController, + startDestination = Routes.HOME, ) { - SessionScreen( - onNavigate = { navController.navigationEvent(event = it) }, - ) - } - composable( - route = "${Routes.EXERCISE_PICKER}/{session_id}", - arguments = listOf( - navArgument("session_id") { - type = NavType.LongType + composable(Routes.HOME) { + HomeScreen( + onNavigate = { navController.navigationEvent(event = it) }, + ) + } + composable( + route = "${Routes.SESSION}/{session_id}", + arguments = listOf( + navArgument("session_id") { + type = NavType.LongType + }, + ), + ) { + SessionScreen( + onNavigate = { navController.navigationEvent(event = it) }, + ) + } + composable( + route = "${Routes.EXERCISE_PICKER}/{session_id}", + arguments = listOf( + navArgument("session_id") { + type = NavType.LongType + }, + ), + ) { + ExercisePickerScreen( + navController = navController, + ) + } + composable(Routes.SETTINGS) { + SettingsScreen() } - ) - ) { - ExercisePickerScreen( - navController = navController, - ) - } - composable(Routes.SETTINGS) { - SettingsScreen() } - } } fun NavController.navigationEvent(event: UiEvent.Navigate) { - navigate(event.route) { - if (event.popBackStack) currentDestination?.route?.let { popUpTo(it) { inclusive = true } } - launchSingleTop = true - } + navigate(event.route) { + if (event.popBackStack) currentDestination?.route?.let { popUpTo(it) { inclusive = true } } + launchSingleTop = true + } } diff --git a/app/src/main/java/com/example/android/january2022/ui/UiState.kt b/app/src/main/java/com/example/android/january2022/ui/UiState.kt index b1c405a..a7f0c5d 100644 --- a/app/src/main/java/com/example/android/january2022/ui/UiState.kt +++ b/app/src/main/java/com/example/android/january2022/ui/UiState.kt @@ -6,25 +6,25 @@ import com.example.android.january2022.db.entities.Session import com.example.android.january2022.db.entities.SessionExercise data class SessionWrapper( - val session: Session, - val muscleGroups: List + val session: Session, + val muscleGroups: List, ) data class ExerciseWrapper( - val sessionExercise: SessionExercise, - val exercise: Exercise, - val sets: List + val sessionExercise: SessionExercise, + val exercise: Exercise, + val sets: List, ) data class TimerState( - val time: Long, - val running: Boolean, - val maxTime: Long + val time: Long, + val running: Boolean, + val maxTime: Long, ) data class DatabaseModel( - val sessions: List, - val exercises: List, - val sessionExercises: List, - val sets: List -) \ No newline at end of file + val sessions: List, + val exercises: List, + val sessionExercises: List, + val sets: List, +) diff --git a/app/src/main/java/com/example/android/january2022/ui/datetimedialog/Composables.kt b/app/src/main/java/com/example/android/january2022/ui/datetimedialog/Composables.kt index e758142..955bd16 100644 --- a/app/src/main/java/com/example/android/january2022/ui/datetimedialog/Composables.kt +++ b/app/src/main/java/com/example/android/january2022/ui/datetimedialog/Composables.kt @@ -1,6 +1,5 @@ package com.example.android.january2022.ui.datetimedialog - import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.material.MaterialTheme @@ -15,23 +14,23 @@ import androidx.compose.ui.unit.sp @Composable internal fun DialogTitle(text: String, modifier: Modifier = Modifier) { - Text( - text, - modifier = modifier - .fillMaxWidth() - .wrapContentWidth(Alignment.CenterHorizontally), - color = MaterialTheme.colors.onBackground, - fontSize = 20.sp, - style = TextStyle(fontWeight = FontWeight.W600) - ) + Text( + text, + modifier = modifier + .fillMaxWidth() + .wrapContentWidth(Alignment.CenterHorizontally), + color = MaterialTheme.colors.onBackground, + fontSize = 20.sp, + style = TextStyle(fontWeight = FontWeight.W600), + ) } @Composable internal fun isSmallDevice(): Boolean { - return LocalConfiguration.current.screenWidthDp <= 360 + return LocalConfiguration.current.screenWidthDp <= 360 } @Composable internal fun isLargeDevice(): Boolean { - return LocalConfiguration.current.screenWidthDp <= 600 + return LocalConfiguration.current.screenWidthDp <= 600 } diff --git a/app/src/main/java/com/example/android/january2022/ui/datetimedialog/Extensions.kt b/app/src/main/java/com/example/android/january2022/ui/datetimedialog/Extensions.kt index 95ae68d..b2b47ea 100644 --- a/app/src/main/java/com/example/android/january2022/ui/datetimedialog/Extensions.kt +++ b/app/src/main/java/com/example/android/january2022/ui/datetimedialog/Extensions.kt @@ -1,35 +1,38 @@ package com.example.android.january2022.ui.datetimedialog - import androidx.compose.ui.geometry.Offset -import java.time.* +import java.time.DayOfWeek +import java.time.LocalDate +import java.time.LocalTime +import java.time.Month +import java.time.YearMonth import java.util.* import kotlin.math.cos import kotlin.math.sin internal fun Float.getOffset(angle: Double): Offset = - Offset((this * cos(angle)).toFloat(), (this * sin(angle)).toFloat()) + Offset((this * cos(angle)).toFloat(), (this * sin(angle)).toFloat()) internal val LocalDate.yearMonth: YearMonth - get() = YearMonth.of(this.year, this.month) + get() = YearMonth.of(this.year, this.month) internal val LocalTime.isAM: Boolean - get() = this.hour in 0..11 + get() = this.hour in 0..11 internal val LocalTime.simpleHour: Int - get() { - val tempHour = this.hour % 12 - return if (tempHour == 0) 12 else tempHour - } + get() { + val tempHour = this.hour % 12 + return if (tempHour == 0) 12 else tempHour + } internal fun Month.getShortLocalName(locale: Locale): String = - this.getDisplayName(java.time.format.TextStyle.SHORT, locale) + this.getDisplayName(java.time.format.TextStyle.SHORT, locale) internal fun Month.getFullLocalName(locale: Locale) = - this.getDisplayName(java.time.format.TextStyle.FULL_STANDALONE, locale) + this.getDisplayName(java.time.format.TextStyle.FULL_STANDALONE, locale) internal fun DayOfWeek.getShortLocalName(locale: Locale) = - this.getDisplayName(java.time.format.TextStyle.SHORT, locale) + this.getDisplayName(java.time.format.TextStyle.SHORT, locale) internal fun LocalTime.toAM(): LocalTime = if (this.isAM) this else this.minusHours(12) internal fun LocalTime.toPM(): LocalTime = if (!this.isAM) this else this.plusHours(12) diff --git a/app/src/main/java/com/example/android/january2022/ui/datetimedialog/MaterialDialog.kt b/app/src/main/java/com/example/android/january2022/ui/datetimedialog/MaterialDialog.kt index e50a1ca..50f80a5 100644 --- a/app/src/main/java/com/example/android/january2022/ui/datetimedialog/MaterialDialog.kt +++ b/app/src/main/java/com/example/android/january2022/ui/datetimedialog/MaterialDialog.kt @@ -1,6 +1,5 @@ package com.example.android.january2022.ui.datetimedialog - import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column @@ -43,111 +42,111 @@ import kotlin.math.min * within a [MaterialDialog]'s content parameter */ interface MaterialDialogScope { - val dialogState: MaterialDialogState - val dialogButtons: MaterialDialogButtons - - val callbacks: SnapshotStateMap Unit> - val positiveButtonEnabled: SnapshotStateMap - - val autoDismiss: Boolean - - /** - * Hides the dialog and calls any callbacks from components in the dialog - */ - fun submit() - - /** - * Clears the dialog's state - */ - fun reset() - - /** - * Adds a value to the [positiveButtonEnabled] map and updates the value in the map when - * [valid] changes - * - * @param valid boolean value to initialise the index in the list - * @param onDispose cleanup callback when component calling this gets destroyed - */ - @Composable - fun PositiveButtonEnabled(valid: Boolean, onDispose: () -> Unit) - - /** - * Adds a callback to the dialog which is called on positive button press - * - * @param callback called when positive button is pressed - */ - @Composable - fun DialogCallback(callback: () -> Unit) + val dialogState: MaterialDialogState + val dialogButtons: MaterialDialogButtons + + val callbacks: SnapshotStateMap Unit> + val positiveButtonEnabled: SnapshotStateMap + + val autoDismiss: Boolean + + /** + * Hides the dialog and calls any callbacks from components in the dialog + */ + fun submit() + + /** + * Clears the dialog's state + */ + fun reset() + + /** + * Adds a value to the [positiveButtonEnabled] map and updates the value in the map when + * [valid] changes + * + * @param valid boolean value to initialise the index in the list + * @param onDispose cleanup callback when component calling this gets destroyed + */ + @Composable + fun PositiveButtonEnabled(valid: Boolean, onDispose: () -> Unit) + + /** + * Adds a callback to the dialog which is called on positive button press + * + * @param callback called when positive button is pressed + */ + @Composable + fun DialogCallback(callback: () -> Unit) } internal class MaterialDialogScopeImpl( - override val dialogState: MaterialDialogState, - override val autoDismiss: Boolean = true + override val dialogState: MaterialDialogState, + override val autoDismiss: Boolean = true, ) : MaterialDialogScope { - override val dialogButtons = MaterialDialogButtons(this) + override val dialogButtons = MaterialDialogButtons(this) - override val callbacks = mutableStateMapOf Unit>() - private val callbackCounter = AtomicInteger(0) + override val callbacks = mutableStateMapOf Unit>() + private val callbackCounter = AtomicInteger(0) + + override val positiveButtonEnabled = mutableStateMapOf() + private val positiveEnabledCounter = AtomicInteger(0) + + /** + * Hides the dialog and calls any callbacks from components in the dialog + */ + override fun submit() { + dialogState.hide() + callbacks.values.forEach { + it() + } + } - override val positiveButtonEnabled = mutableStateMapOf() - private val positiveEnabledCounter = AtomicInteger(0) + /** + * Clears the dialog callbacks and positive button enables values along with their + * respective counters + */ + override fun reset() { + positiveButtonEnabled.clear() + callbacks.clear() - /** - * Hides the dialog and calls any callbacks from components in the dialog - */ - override fun submit() { - dialogState.hide() - callbacks.values.forEach { - it() + positiveEnabledCounter.set(0) + callbackCounter.set(0) } - } - - /** - * Clears the dialog callbacks and positive button enables values along with their - * respective counters - */ - override fun reset() { - positiveButtonEnabled.clear() - callbacks.clear() - - positiveEnabledCounter.set(0) - callbackCounter.set(0) - } - - /** - * Adds a value to the [positiveButtonEnabled] map and updates the value in the map when - * [valid] changes - * - * @param valid boolean value to initialise the index in the list - * @param onDispose cleanup callback when component calling this gets destroyed - */ - @Composable - override fun PositiveButtonEnabled(valid: Boolean, onDispose: () -> Unit) { - val positiveEnabledIndex = remember { positiveEnabledCounter.getAndIncrement() } - - DisposableEffect(valid) { - positiveButtonEnabled[positiveEnabledIndex] = valid - onDispose { - positiveButtonEnabled[positiveEnabledIndex] = true - onDispose() - } + + /** + * Adds a value to the [positiveButtonEnabled] map and updates the value in the map when + * [valid] changes + * + * @param valid boolean value to initialise the index in the list + * @param onDispose cleanup callback when component calling this gets destroyed + */ + @Composable + override fun PositiveButtonEnabled(valid: Boolean, onDispose: () -> Unit) { + val positiveEnabledIndex = remember { positiveEnabledCounter.getAndIncrement() } + + DisposableEffect(valid) { + positiveButtonEnabled[positiveEnabledIndex] = valid + onDispose { + positiveButtonEnabled[positiveEnabledIndex] = true + onDispose() + } + } } - } - - /** - * Adds a callback to the dialog which is called on positive button press - * - * @param callback called when positive button is pressed - */ - @Composable - override fun DialogCallback(callback: () -> Unit) { - val callbackIndex = rememberSaveable { callbackCounter.getAndIncrement() } - - DisposableEffect(Unit) { - callbacks[callbackIndex] = callback - onDispose { callbacks[callbackIndex] = {} } + + /** + * Adds a callback to the dialog which is called on positive button press + * + * @param callback called when positive button is pressed + */ + @Composable + override fun DialogCallback(callback: () -> Unit) { + val callbackIndex = rememberSaveable { callbackCounter.getAndIncrement() } + + DisposableEffect(Unit) { + callbacks[callbackIndex] = callback + onDispose { callbacks[callbackIndex] = {} } + } } - } } /** @@ -156,39 +155,39 @@ internal class MaterialDialogScopeImpl( * @param initialValue the initial showing state of the dialog */ class MaterialDialogState(initialValue: Boolean = false) { - var showing by mutableStateOf(initialValue) - - /** - * Dialog background color with elevation overlay - */ - var dialogBackgroundColor by mutableStateOf(null) - - /** - * Shows the dialog - */ - fun show() { - showing = true - } - - /** - * Clears focus with a given [FocusManager] and then hides the dialog - * - * @param focusManager the focus manager of the dialog view - */ - fun hide(focusManager: FocusManager? = null) { - focusManager?.clearFocus() - showing = false - } - - companion object { + var showing by mutableStateOf(initialValue) + + /** + * Dialog background color with elevation overlay + */ + var dialogBackgroundColor by mutableStateOf(null) + + /** + * Shows the dialog + */ + fun show() { + showing = true + } + /** - * The default [Saver] implementation for [MaterialDialogState]. + * Clears focus with a given [FocusManager] and then hides the dialog + * + * @param focusManager the focus manager of the dialog view */ - fun Saver(): Saver = Saver( - save = { it.showing }, - restore = { MaterialDialogState(it) } - ) - } + fun hide(focusManager: FocusManager? = null) { + focusManager?.clearFocus() + showing = false + } + + companion object { + /** + * The default [Saver] implementation for [MaterialDialogState]. + */ + fun Saver(): Saver = Saver( + save = { it.showing }, + restore = { MaterialDialogState(it) }, + ) + } } /** @@ -198,9 +197,9 @@ class MaterialDialogState(initialValue: Boolean = false) { */ @Composable fun rememberMaterialDialogState(initialValue: Boolean = false): MaterialDialogState { - return rememberSaveable(saver = MaterialDialogState.Saver()) { - MaterialDialogState(initialValue) - } + return rememberSaveable(saver = MaterialDialogState.Saver()) { + MaterialDialogState(initialValue) + } } /** @@ -218,89 +217,89 @@ fun rememberMaterialDialogState(initialValue: Boolean = false): MaterialDialogSt */ @Composable fun MaterialDialog( - dialogState: MaterialDialogState = rememberMaterialDialogState(), - properties: DialogProperties = DialogProperties(), - backgroundColor: Color = MaterialTheme.colorScheme.surface, - shape: Shape = MaterialTheme.shapes.medium, - border: BorderStroke? = null, - elevation: Dp = 0.dp, - autoDismiss: Boolean = true, - onCloseRequest: (MaterialDialogState) -> Unit = { it.hide() }, - buttons: @Composable MaterialDialogButtons.() -> Unit = {}, - content: @Composable MaterialDialogScope.() -> Unit + dialogState: MaterialDialogState = rememberMaterialDialogState(), + properties: DialogProperties = DialogProperties(), + backgroundColor: Color = MaterialTheme.colorScheme.surface, + shape: Shape = MaterialTheme.shapes.medium, + border: BorderStroke? = null, + elevation: Dp = 0.dp, + autoDismiss: Boolean = true, + onCloseRequest: (MaterialDialogState) -> Unit = { it.hide() }, + buttons: @Composable MaterialDialogButtons.() -> Unit = {}, + content: @Composable MaterialDialogScope.() -> Unit, ) { - val dialogScope = remember { MaterialDialogScopeImpl(dialogState, autoDismiss) } - DisposableEffect(dialogState.showing) { - if (!dialogState.showing) dialogScope.reset() - onDispose { } - } - - BoxWithConstraints { - val maxHeight = if (isLargeDevice()) { - LocalConfiguration.current.screenHeightDp.dp - 96.dp - } else { - 560.dp + val dialogScope = remember { MaterialDialogScopeImpl(dialogState, autoDismiss) } + DisposableEffect(dialogState.showing) { + if (!dialogState.showing) dialogScope.reset() + onDispose { } } - val maxHeightPx = with(LocalDensity.current) { maxHeight.toPx().toInt() } - val isDialogFullWidth = LocalConfiguration.current.screenWidthDp.dp == maxWidth - val padding = if (isDialogFullWidth) 16.dp else 0.dp - - if (dialogState.showing) { - dialogState.dialogBackgroundColor = LocalElevationOverlay.current?.apply( - color = backgroundColor, - elevation = elevation - ) ?: MaterialTheme.colorScheme.surface - - Dialog( - properties = properties, - onDismissRequest = { onCloseRequest(dialogState) } - ) { - Surface( - modifier = Modifier - .fillMaxWidth() - .sizeIn(maxHeight = maxHeight, maxWidth = 560.dp) - .padding(horizontal = padding) - .clipToBounds() - .wrapContentHeight() - .testTag("dialog"), - shape = shape, - color = backgroundColor, - border = border, - tonalElevation = elevation - ) { - Layout( - content = { - dialogScope.DialogButtonsLayout( - modifier = Modifier.layoutId("buttons"), - content = buttons - ) - Column(Modifier.layoutId("content")) { content(dialogScope) } - } - ) { measurables, constraints -> - val buttonsHeight = - measurables[0].minIntrinsicHeight(constraints.maxWidth) - val buttonsPlaceable = measurables[0].measure( - constraints.copy(maxHeight = buttonsHeight, minHeight = 0) - ) - - val contentPlaceable = measurables[1].measure( - constraints.copy( - maxHeight = maxHeightPx - buttonsPlaceable.height, - minHeight = 0 - ) - ) - - val height = - min(maxHeightPx, buttonsPlaceable.height + contentPlaceable.height) - - return@Layout layout(constraints.maxWidth, height) { - contentPlaceable.place(0, 0) - buttonsPlaceable.place(0, height - buttonsPlaceable.height) + BoxWithConstraints { + val maxHeight = if (isLargeDevice()) { + LocalConfiguration.current.screenHeightDp.dp - 96.dp + } else { + 560.dp + } + + val maxHeightPx = with(LocalDensity.current) { maxHeight.toPx().toInt() } + val isDialogFullWidth = LocalConfiguration.current.screenWidthDp.dp == maxWidth + val padding = if (isDialogFullWidth) 16.dp else 0.dp + + if (dialogState.showing) { + dialogState.dialogBackgroundColor = LocalElevationOverlay.current?.apply( + color = backgroundColor, + elevation = elevation, + ) ?: MaterialTheme.colorScheme.surface + + Dialog( + properties = properties, + onDismissRequest = { onCloseRequest(dialogState) }, + ) { + Surface( + modifier = Modifier + .fillMaxWidth() + .sizeIn(maxHeight = maxHeight, maxWidth = 560.dp) + .padding(horizontal = padding) + .clipToBounds() + .wrapContentHeight() + .testTag("dialog"), + shape = shape, + color = backgroundColor, + border = border, + tonalElevation = elevation, + ) { + Layout( + content = { + dialogScope.DialogButtonsLayout( + modifier = Modifier.layoutId("buttons"), + content = buttons, + ) + Column(Modifier.layoutId("content")) { content(dialogScope) } + }, + ) { measurables, constraints -> + val buttonsHeight = + measurables[0].minIntrinsicHeight(constraints.maxWidth) + val buttonsPlaceable = measurables[0].measure( + constraints.copy(maxHeight = buttonsHeight, minHeight = 0), + ) + + val contentPlaceable = measurables[1].measure( + constraints.copy( + maxHeight = maxHeightPx - buttonsPlaceable.height, + minHeight = 0, + ), + ) + + val height = + min(maxHeightPx, buttonsPlaceable.height + contentPlaceable.height) + + return@Layout layout(constraints.maxWidth, height) { + contentPlaceable.place(0, 0) + buttonsPlaceable.place(0, height - buttonsPlaceable.height) + } + } + } } - } } - } } - } } diff --git a/app/src/main/java/com/example/android/january2022/ui/datetimedialog/MaterialDialogButtons.kt b/app/src/main/java/com/example/android/january2022/ui/datetimedialog/MaterialDialogButtons.kt index b23f50b..6f2b309 100644 --- a/app/src/main/java/com/example/android/january2022/ui/datetimedialog/MaterialDialogButtons.kt +++ b/app/src/main/java/com/example/android/january2022/ui/datetimedialog/MaterialDialogButtons.kt @@ -1,6 +1,5 @@ package com.example.android.january2022.ui.datetimedialog - import androidx.annotation.StringRes import androidx.compose.foundation.Image import androidx.compose.foundation.background @@ -27,15 +26,14 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.testTag -import androidx.compose.ui.text.TextStyle import androidx.compose.ui.unit.dp import java.util.Locale internal enum class MaterialDialogButtonTypes(val testTag: String) { - Text("text"), - Positive("positive"), - Negative("negative"), - Accessibility("accessibility") + Text("text"), + Positive("positive"), + Negative("negative"), + Accessibility("accessibility"), } /** @@ -45,72 +43,72 @@ internal enum class MaterialDialogButtonTypes(val testTag: String) { */ @Composable internal fun MaterialDialogScope.DialogButtonsLayout( - modifier: Modifier = Modifier, - content: @Composable MaterialDialogButtons.() -> Unit + modifier: Modifier = Modifier, + content: @Composable MaterialDialogButtons.() -> Unit, ) { - val interButtonPadding = with(LocalDensity.current) { 12.dp.toPx().toInt() } - val defaultBoxHeight = with(LocalDensity.current) { 52.dp.toPx().toInt() } - val defaultButtonHeight = with(LocalDensity.current) { 36.dp.toPx().toInt() } - val accessibilityPadding = with(LocalDensity.current) { 12.dp.toPx().toInt() } - val verticalPadding = with(LocalDensity.current) { 8.dp.toPx().toInt() } + val interButtonPadding = with(LocalDensity.current) { 12.dp.toPx().toInt() } + val defaultBoxHeight = with(LocalDensity.current) { 52.dp.toPx().toInt() } + val defaultButtonHeight = with(LocalDensity.current) { 36.dp.toPx().toInt() } + val accessibilityPadding = with(LocalDensity.current) { 12.dp.toPx().toInt() } + val verticalPadding = with(LocalDensity.current) { 8.dp.toPx().toInt() } - Layout( - { content(dialogButtons) }, - modifier - .fillMaxWidth() - .padding(horizontal = 8.dp) - .background(Color.Transparent), - { measurables, constraints -> + Layout( + { content(dialogButtons) }, + modifier + .fillMaxWidth() + .padding(horizontal = 8.dp) + .background(Color.Transparent), + { measurables, constraints -> - if (measurables.isEmpty()) { - return@Layout layout(0, 0) {} - } + if (measurables.isEmpty()) { + return@Layout layout(0, 0) {} + } - val placeables = measurables.map { - (it.layoutId as MaterialDialogButtonTypes) to it.measure( - constraints.copy(minWidth = 0, maxHeight = defaultButtonHeight) - ) - } - val totalWidth = placeables.sumOf { it.second.width } - val column = totalWidth > 0.8 * constraints.maxWidth + val placeables = measurables.map { + (it.layoutId as MaterialDialogButtonTypes) to it.measure( + constraints.copy(minWidth = 0, maxHeight = defaultButtonHeight), + ) + } + val totalWidth = placeables.sumOf { it.second.width } + val column = totalWidth > 0.8 * constraints.maxWidth - val height = - if (column) { - val buttonHeight = placeables.sumOf { it.second.height } - val heightPadding = (placeables.size - 1) * interButtonPadding - buttonHeight + heightPadding + 2 * verticalPadding - } else { - defaultBoxHeight - } + val height = + if (column) { + val buttonHeight = placeables.sumOf { it.second.height } + val heightPadding = (placeables.size - 1) * interButtonPadding + buttonHeight + heightPadding + 2 * verticalPadding + } else { + defaultBoxHeight + } - layout(constraints.maxWidth, height) { - var currX = constraints.maxWidth - var currY = verticalPadding + layout(constraints.maxWidth, height) { + var currX = constraints.maxWidth + var currY = verticalPadding - val posButtons = placeables.buttons(MaterialDialogButtonTypes.Positive) - val negButtons = placeables.buttons(MaterialDialogButtonTypes.Negative) - val textButtons = placeables.buttons(MaterialDialogButtonTypes.Text) - val accButtons = placeables.buttons(MaterialDialogButtonTypes.Accessibility) + val posButtons = placeables.buttons(MaterialDialogButtonTypes.Positive) + val negButtons = placeables.buttons(MaterialDialogButtonTypes.Negative) + val textButtons = placeables.buttons(MaterialDialogButtonTypes.Text) + val accButtons = placeables.buttons(MaterialDialogButtonTypes.Accessibility) - val buttonInOrder = posButtons + textButtons + negButtons - buttonInOrder.forEach { button -> - if (column) { - button.place(currX - button.width, currY) - currY += button.height + interButtonPadding - } else { - currX -= button.width - button.place(currX, currY) - } - } + val buttonInOrder = posButtons + textButtons + negButtons + buttonInOrder.forEach { button -> + if (column) { + button.place(currX - button.width, currY) + currY += button.height + interButtonPadding + } else { + currX -= button.width + button.place(currX, currY) + } + } - if (accButtons.isNotEmpty()) { - /* There can only be one accessibility button so take first */ - val button = accButtons[0] - button.place(accessibilityPadding, height - button.height) - } - } - } - ) + if (accButtons.isNotEmpty()) { + /* There can only be one accessibility button so take first */ + val button = accButtons[0] + button.place(accessibilityPadding, height - button.height) + } + } + }, + ) } /** @@ -118,149 +116,149 @@ internal fun MaterialDialogScope.DialogButtonsLayout( * with the [com.vanpra.composematerialdialogs.MaterialDialog.dialogButtons] function */ class MaterialDialogButtons(private val scope: MaterialDialogScope) { - /** - * Adds a button which is always enabled to the bottom of the dialog. This should - * only be used for neutral actions. - * - * @param text the string literal text shown in the button - * @param res the string resource text shown in the button - * @param onClick a callback which is called when the button is pressed - */ - @Composable - fun button( - text: String? = null, - @StringRes res: Int? = null, - colors: ButtonColors = ButtonDefaults.textButtonColors(), - onClick: () -> Unit = {} - ) { - val buttonText = getString(res, text).uppercase(Locale.ROOT) - TextButton( - onClick = { - onClick() - }, - modifier = Modifier - .layoutId(MaterialDialogButtonTypes.Text) - .testTag(MaterialDialogButtonTypes.Text.testTag), - colors = colors + /** + * Adds a button which is always enabled to the bottom of the dialog. This should + * only be used for neutral actions. + * + * @param text the string literal text shown in the button + * @param res the string resource text shown in the button + * @param onClick a callback which is called when the button is pressed + */ + @Composable + fun Button( + text: String? = null, + @StringRes res: Int? = null, + colors: ButtonColors = ButtonDefaults.textButtonColors(), + onClick: () -> Unit = {}, ) { - Text(text = buttonText) + val buttonText = getString(res, text).uppercase(Locale.ROOT) + TextButton( + onClick = { + onClick() + }, + modifier = Modifier + .layoutId(MaterialDialogButtonTypes.Text) + .testTag(MaterialDialogButtonTypes.Text.testTag), + colors = colors, + ) { + Text(text = buttonText) + } } - } - /** - * Adds a positive button to the dialog - * - * @param text the string literal text shown in the button - * @param res the string resource text shown in the button - * @param disableDismiss when true this will stop the dialog closing when the button is pressed - * even if autoDismissing is disabled - * @param onClick a callback which is called when the button is pressed - */ - @Composable - fun positiveButton( - text: String? = null, - @StringRes res: Int? = null, - colors: ButtonColors = ButtonDefaults.textButtonColors(), - disableDismiss: Boolean = false, - onClick: () -> Unit = {} - ) { - val buttonText = getString(res, text).uppercase(Locale.ROOT) - val buttonEnabled = scope.positiveButtonEnabled.values.all { it } - val focusManager = LocalFocusManager.current + /** + * Adds a positive button to the dialog + * + * @param text the string literal text shown in the button + * @param res the string resource text shown in the button + * @param disableDismiss when true this will stop the dialog closing when the button is pressed + * even if autoDismissing is disabled + * @param onClick a callback which is called when the button is pressed + */ + @Composable + fun PositiveButton( + text: String? = null, + @StringRes res: Int? = null, + colors: ButtonColors = ButtonDefaults.textButtonColors(), + disableDismiss: Boolean = false, + onClick: () -> Unit = {}, + ) { + val buttonText = getString(res, text).uppercase(Locale.ROOT) + val buttonEnabled = scope.positiveButtonEnabled.values.all { it } + val focusManager = LocalFocusManager.current - TextButton( - onClick = { - if (scope.autoDismiss && !disableDismiss) { - scope.dialogState.hide(focusManager) - } + TextButton( + onClick = { + if (scope.autoDismiss && !disableDismiss) { + scope.dialogState.hide(focusManager) + } - scope.callbacks.values.forEach { - it() - } + scope.callbacks.values.forEach { + it() + } - onClick() - }, - modifier = Modifier.layoutId(MaterialDialogButtonTypes.Positive) - .testTag(MaterialDialogButtonTypes.Positive.testTag), - enabled = buttonEnabled, - colors = colors - ) { - Text(text = buttonText) + onClick() + }, + modifier = Modifier.layoutId(MaterialDialogButtonTypes.Positive) + .testTag(MaterialDialogButtonTypes.Positive.testTag), + enabled = buttonEnabled, + colors = colors, + ) { + Text(text = buttonText) + } } - } - /** - * Adds a negative button to the dialog - * - * @param text the string literal text shown in the button - * @param res the string resource text shown in the button - * even if autoDismissing is disabled - * @param onClick a callback which is called when the button is pressed - */ - @Composable - fun negativeButton( - text: String? = null, - @StringRes res: Int? = null, - colors: ButtonColors = ButtonDefaults.textButtonColors(), - onClick: () -> Unit = {} - ) { - val buttonText = getString(res, text).uppercase(Locale.ROOT) - val focusManager = LocalFocusManager.current + /** + * Adds a negative button to the dialog + * + * @param text the string literal text shown in the button + * @param res the string resource text shown in the button + * even if autoDismissing is disabled + * @param onClick a callback which is called when the button is pressed + */ + @Composable + fun NegativeButton( + text: String? = null, + @StringRes res: Int? = null, + colors: ButtonColors = ButtonDefaults.textButtonColors(), + onClick: () -> Unit = {}, + ) { + val buttonText = getString(res, text).uppercase(Locale.ROOT) + val focusManager = LocalFocusManager.current - TextButton( - onClick = { - if (scope.autoDismiss) { - scope.dialogState.hide(focusManager) + TextButton( + onClick = { + if (scope.autoDismiss) { + scope.dialogState.hide(focusManager) + } + onClick() + }, + modifier = Modifier.layoutId(MaterialDialogButtonTypes.Negative) + .testTag(MaterialDialogButtonTypes.Negative.testTag), + colors = colors, + ) { + Text(text = buttonText) } - onClick() - }, - modifier = Modifier.layoutId(MaterialDialogButtonTypes.Negative) - .testTag(MaterialDialogButtonTypes.Negative.testTag), - colors = colors - ) { - Text(text = buttonText) } - } - /** - * Adds a accessibility button to the bottom left of the dialog - * - * @param icon the icon to be shown on the button - * @param onClick a callback which is called when the button is pressed - */ - @Composable - fun accessibilityButton( - icon: ImageVector, - colorFilter: ColorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onBackground), - onClick: () -> Unit - ) { - Box( - Modifier - .size(48.dp) - .layoutId(MaterialDialogButtonTypes.Accessibility) - .testTag(MaterialDialogButtonTypes.Accessibility.testTag) - .clickable(onClick = onClick), - contentAlignment = Alignment.Center + /** + * Adds a accessibility button to the bottom left of the dialog + * + * @param icon the icon to be shown on the button + * @param onClick a callback which is called when the button is pressed + */ + @Composable + fun AccessibilityButton( + icon: ImageVector, + colorFilter: ColorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onBackground), + onClick: () -> Unit, ) { - Image( - icon, - contentDescription = null, - modifier = Modifier.size(24.dp), - colorFilter = colorFilter - ) + Box( + Modifier + .size(48.dp) + .layoutId(MaterialDialogButtonTypes.Accessibility) + .testTag(MaterialDialogButtonTypes.Accessibility.testTag) + .clickable(onClick = onClick), + contentAlignment = Alignment.Center, + ) { + Image( + icon, + contentDescription = null, + modifier = Modifier.size(24.dp), + colorFilter = colorFilter, + ) + } } - } } @Composable internal fun getString(@StringRes res: Int? = null, default: String? = null): String { - return if (res != null) { - LocalContext.current.getString(res) - } else { - default - ?: throw IllegalArgumentException("Function must receive one non null string parameter") - } + return if (res != null) { + LocalContext.current.getString(res) + } else { + default + ?: throw IllegalArgumentException("Function must receive one non null string parameter") + } } internal fun List>.buttons(type: MaterialDialogButtonTypes) = - this.filter { it.first == type }.map { it.second } \ No newline at end of file + this.filter { it.first == type }.map { it.second } diff --git a/app/src/main/java/com/example/android/january2022/ui/datetimedialog/time/TimePicker.kt b/app/src/main/java/com/example/android/january2022/ui/datetimedialog/time/TimePicker.kt index e45ac3f..4f1f51d 100644 --- a/app/src/main/java/com/example/android/january2022/ui/datetimedialog/time/TimePicker.kt +++ b/app/src/main/java/com/example/android/january2022/ui/datetimedialog/time/TimePicker.kt @@ -9,7 +9,20 @@ import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.gestures.detectTapGestures -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.paddingFromBaseline +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CornerSize import androidx.compose.material.ContentAlpha import androidx.compose.material3.MaterialTheme @@ -17,6 +30,7 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Alignment @@ -35,15 +49,28 @@ import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.TextStyle import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import com.example.android.january2022.ui.datetimedialog.* +import com.example.android.january2022.ui.datetimedialog.MaterialDialogScope +import com.example.android.january2022.ui.datetimedialog.getOffset +import com.example.android.january2022.ui.datetimedialog.isAM +import com.example.android.january2022.ui.datetimedialog.isSmallDevice +import com.example.android.january2022.ui.datetimedialog.noSeconds +import com.example.android.january2022.ui.datetimedialog.simpleHour +import com.example.android.january2022.ui.datetimedialog.toAM +import com.example.android.january2022.ui.datetimedialog.toPM import java.time.LocalTime -import kotlin.math.* +import kotlin.math.PI +import kotlin.math.abs +import kotlin.math.cos +import kotlin.math.min +import kotlin.math.pow +import kotlin.math.roundToInt +import kotlin.math.sin /* Offset of the clock line and selected circle */ private data class SelectedOffset( - val lineOffset: Offset = Offset.Zero, - val selectedOffset: Offset = Offset.Zero, - val selectedRadius: Float = 0.0f + val lineOffset: Offset = Offset.Zero, + val selectedOffset: Offset = Offset.Zero, + val selectedRadius: Float = 0.0f, ) /** @@ -59,680 +86,680 @@ private data class SelectedOffset( * @param onTimeChange callback with a LocalTime object when the user completes their input */ @Composable -fun MaterialDialogScope.timepicker( - initialTime: LocalTime = LocalTime.now().noSeconds(), - title: String = "SELECT TIME", - colors: TimePickerColors = TimePickerDefaults.colors(), - waitForPositiveButton: Boolean = true, - timeRange: ClosedRange = LocalTime.MIN..LocalTime.MAX, - is24HourClock: Boolean = false, - onTimeChange: (LocalTime) -> Unit = {} +fun MaterialDialogScope.Timepicker( + initialTime: LocalTime = LocalTime.now().noSeconds(), + title: String = "SELECT TIME", + colors: TimePickerColors = TimePickerDefaults.colors(), + waitForPositiveButton: Boolean = true, + timeRange: ClosedRange = LocalTime.MIN..LocalTime.MAX, + is24HourClock: Boolean = false, + onTimeChange: (LocalTime) -> Unit = {}, ) { - val timePickerState = remember { - TimePickerState( - selectedTime = initialTime.coerceIn(timeRange), - colors = colors, - timeRange = timeRange, - is24Hour = is24HourClock - ) - } - - if (waitForPositiveButton) { - DialogCallback { onTimeChange(timePickerState.selectedTime) } - } else { - DisposableEffect(timePickerState.selectedTime) { - onTimeChange(timePickerState.selectedTime) - onDispose { } + val timePickerState = remember { + TimePickerState( + selectedTime = initialTime.coerceIn(timeRange), + colors = colors, + timeRange = timeRange, + is24Hour = is24HourClock, + ) + } + + if (waitForPositiveButton) { + DialogCallback { onTimeChange(timePickerState.selectedTime) } + } else { + DisposableEffect(timePickerState.selectedTime) { + onTimeChange(timePickerState.selectedTime) + onDispose { } + } } - } - TimePickerImpl(title = title, state = timePickerState) + TimePickerImpl(title = title, state = timePickerState) } @Composable internal fun TimePickerExpandedImpl( - modifier: Modifier = Modifier, - title: String, - state: TimePickerState + modifier: Modifier = Modifier, + title: String, + state: TimePickerState, ) { - Column(modifier.padding(start = 24.dp, end = 24.dp)) { - Box(Modifier.align(Alignment.Start)) { - TimePickerTitle(Modifier.height(36.dp), title, state) - } + Column(modifier.padding(start = 24.dp, end = 24.dp)) { + Box(Modifier.align(Alignment.Start)) { + TimePickerTitle(Modifier.height(36.dp), title, state) + } - Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) { - Column( - Modifier - .padding(top = 72.dp, bottom = 50.dp) - .width(216.dp) - ) { - TimeLayout(state = state) - Spacer(modifier = Modifier.height(12.dp)) - HorizontalPeriodPicker(state = state) - } + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) { + Column( + Modifier + .padding(top = 72.dp, bottom = 50.dp) + .width(216.dp), + ) { + TimeLayout(state = state) + Spacer(modifier = Modifier.height(12.dp)) + HorizontalPeriodPicker(state = state) + } /* This isn't an exact match to the material spec as there is a contradiction it. Dialogs should be limited to the size of 560 dp but given sizes for extended time picker go over this limit */ - Spacer(modifier = Modifier.width(40.dp)) - Crossfade(state.currentScreen) { - when (it) { - ClockScreen.Hour -> if (state.is24Hour) { - ExtendedClockHourLayout(state = state) - } else { - ClockHourLayout(state = state) - } - ClockScreen.Minute -> ClockMinuteLayout(state = state) + Spacer(modifier = Modifier.width(40.dp)) + Crossfade(state.currentScreen) { + when (it) { + ClockScreen.Hour -> if (state.is24Hour) { + ExtendedClockHourLayout(state = state) + } else { + ClockHourLayout(state = state) + } + ClockScreen.Minute -> ClockMinuteLayout(state = state) + } + } } - } } - } } @Composable internal fun TimePickerImpl( - modifier: Modifier = Modifier, - title: String, - state: TimePickerState + modifier: Modifier = Modifier, + title: String, + state: TimePickerState, ) { - Column( - modifier.padding(start = 24.dp, end = 24.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - if (title != "") { - Box(Modifier.align(Alignment.Start)) { - TimePickerTitle(Modifier.height(52.dp), title, state) - } - } + Column( + modifier.padding(start = 24.dp, end = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + if (title != "") { + Box(Modifier.align(Alignment.Start)) { + TimePickerTitle(Modifier.height(52.dp), title, state) + } + } - TimeLayout(state = state) + TimeLayout(state = state) - Spacer(modifier = Modifier.height(if (isSmallDevice()) 24.dp else 36.dp)) - Crossfade(state.currentScreen) { - when (it) { - ClockScreen.Hour -> if (state.is24Hour) { - ExtendedClockHourLayout(state = state) - } else { - ClockHourLayout(state = state) + Spacer(modifier = Modifier.height(if (isSmallDevice()) 24.dp else 36.dp)) + Crossfade(state.currentScreen) { + when (it) { + ClockScreen.Hour -> if (state.is24Hour) { + ExtendedClockHourLayout(state = state) + } else { + ClockHourLayout(state = state) + } + ClockScreen.Minute -> ClockMinuteLayout(state = state) + } } - ClockScreen.Minute -> ClockMinuteLayout(state = state) - } - } - Spacer(modifier = Modifier.height(24.dp)) - } + Spacer(modifier = Modifier.height(24.dp)) + } } @Composable private fun ExtendedClockHourLayout(state: TimePickerState) { - fun adjustAnchor(anchor: Int): Int = when (anchor) { - 0 -> 12 - 12 -> 0 - else -> anchor - } - - val isEnabled: (Int) -> Boolean = remember(state.timeRange) { - { index -> adjustAnchor(index) in state.hourRange() } - } - - ClockLayout( - anchorPoints = 12, - innerAnchorPoints = 12, - label = { index -> - /* Swapping 12 and 00 as this is the standard layout */ - when (index) { - 0 -> "12" - 12 -> "00" - else -> index.toString() - } - }, - onAnchorChange = { anchor -> - /* Swapping 12 and 00 as this is the standard layout */ - state.selectedTime = - state.selectedTime.withHour(adjustAnchor(anchor)).coerceIn(state.timeRange) - }, - startAnchor = adjustAnchor(state.selectedTime.hour), - onLift = { state.currentScreen = ClockScreen.Minute }, - colors = state.colors, - isAnchorEnabled = isEnabled - ) + fun adjustAnchor(anchor: Int): Int = when (anchor) { + 0 -> 12 + 12 -> 0 + else -> anchor + } + + val isEnabled: (Int) -> Boolean = remember(state.timeRange) { + { index -> adjustAnchor(index) in state.hourRange() } + } + + ClockLayout( + anchorPoints = 12, + innerAnchorPoints = 12, + label = { index -> + /* Swapping 12 and 00 as this is the standard layout */ + when (index) { + 0 -> "12" + 12 -> "00" + else -> index.toString() + } + }, + onAnchorChange = { anchor -> + /* Swapping 12 and 00 as this is the standard layout */ + state.selectedTime = + state.selectedTime.withHour(adjustAnchor(anchor)).coerceIn(state.timeRange) + }, + startAnchor = adjustAnchor(state.selectedTime.hour), + onLift = { state.currentScreen = ClockScreen.Minute }, + colors = state.colors, + isAnchorEnabled = isEnabled, + ) } @Composable private fun ClockHourLayout(state: TimePickerState) { - fun adjustedHour(hour: Int): Int { - return if (state.selectedTime.isAM || hour == 12) hour else hour + 12 - } - - val isEnabled: (Int) -> Boolean = remember(state.timeRange, state.selectedTime) { - { index -> adjustedHour(index) in state.hourRange() } - } - - ClockLayout( - anchorPoints = 12, - label = { index -> if (index == 0) "12" else index.toString() }, - onAnchorChange = { hours -> - val adjustedHour = when (hours) { - 12 -> if (state.selectedTime.isAM) 0 else 12 - else -> if (state.selectedTime.isAM) hours else hours + 12 - } - state.selectedTime = state.selectedTime.withHour(adjustedHour).coerceIn(state.timeRange) - }, - startAnchor = state.selectedTime.simpleHour % 12, - onLift = { state.currentScreen = ClockScreen.Minute }, - colors = state.colors, - isAnchorEnabled = isEnabled - ) + fun adjustedHour(hour: Int): Int { + return if (state.selectedTime.isAM || hour == 12) hour else hour + 12 + } + + val isEnabled: (Int) -> Boolean = remember(state.timeRange, state.selectedTime) { + { index -> adjustedHour(index) in state.hourRange() } + } + + ClockLayout( + anchorPoints = 12, + label = { index -> if (index == 0) "12" else index.toString() }, + onAnchorChange = { hours -> + val adjustedHour = when (hours) { + 12 -> if (state.selectedTime.isAM) 0 else 12 + else -> if (state.selectedTime.isAM) hours else hours + 12 + } + state.selectedTime = state.selectedTime.withHour(adjustedHour).coerceIn(state.timeRange) + }, + startAnchor = state.selectedTime.simpleHour % 12, + onLift = { state.currentScreen = ClockScreen.Minute }, + colors = state.colors, + isAnchorEnabled = isEnabled, + ) } @Composable private fun ClockMinuteLayout(state: TimePickerState) { - val isEnabled: (Int) -> Boolean = - remember(state.timeRange, state.selectedTime, state.selectedTime.isAM) { - { index -> - index in state.minuteRange(state.selectedTime.isAM, state.selectedTime.hour) - } - } - ClockLayout( - anchorPoints = 60, - label = { index -> index.toString().padStart(2, '0') }, - onAnchorChange = { mins -> state.selectedTime = state.selectedTime.withMinute(mins) }, - startAnchor = state.selectedTime.minute, - isNamedAnchor = { anchor -> anchor % 5 == 0 }, - colors = state.colors, - isAnchorEnabled = isEnabled - ) + val isEnabled: (Int) -> Boolean = + remember(state.timeRange, state.selectedTime, state.selectedTime.isAM) { + { index -> + index in state.minuteRange(state.selectedTime.isAM, state.selectedTime.hour) + } + } + ClockLayout( + anchorPoints = 60, + label = { index -> index.toString().padStart(2, '0') }, + onAnchorChange = { mins -> state.selectedTime = state.selectedTime.withMinute(mins) }, + startAnchor = state.selectedTime.minute, + isNamedAnchor = { anchor -> anchor % 5 == 0 }, + colors = state.colors, + isAnchorEnabled = isEnabled, + ) } @Composable internal fun TimePickerTitle(modifier: Modifier, text: String, state: TimePickerState) { - Box(modifier) { - Text( - text, - modifier = Modifier.paddingFromBaseline(top = 28.dp), - style = TextStyle(color = state.colors.headerTextColor()) - ) - } + Box(modifier) { + Text( + text, + modifier = Modifier.paddingFromBaseline(top = 28.dp), + style = TextStyle(color = state.colors.headerTextColor()), + ) + } } @Composable internal fun ClockLabel( - text: String, - backgroundColor: Color, - textColor: Color, - onClick: () -> Unit + text: String, + backgroundColor: Color, + textColor: Color, + onClick: () -> Unit, ) { - Surface( - modifier = Modifier - .width(if (isSmallDevice()) 80.dp else 96.dp) - .fillMaxHeight(), - shape = MaterialTheme.shapes.medium, - color = backgroundColor - ) { - Box( - modifier = Modifier - .fillMaxSize() - .clickable(onClick = onClick), - contentAlignment = Alignment.Center + Surface( + modifier = Modifier + .width(if (isSmallDevice()) 80.dp else 96.dp) + .fillMaxHeight(), + shape = MaterialTheme.shapes.medium, + color = backgroundColor, ) { - Text( - text = text, - style = TextStyle( - fontSize = 50.sp, - color = textColor - ) - ) + Box( + modifier = Modifier + .fillMaxSize() + .clickable(onClick = onClick), + contentAlignment = Alignment.Center, + ) { + Text( + text = text, + style = TextStyle( + fontSize = 50.sp, + color = textColor, + ), + ) + } } - } } @Composable internal fun TimeLayout(modifier: Modifier = Modifier, state: TimePickerState) { - val clockHour: String = remember( - state.is24Hour, - state.selectedTime, - state.selectedTime.hour - ) { - if (state.is24Hour) { - state.selectedTime.hour.toString().padStart(2, '0') - } else { - state.selectedTime.simpleHour.toString() + val clockHour: String = remember( + state.is24Hour, + state.selectedTime, + state.selectedTime.hour, + ) { + if (state.is24Hour) { + state.selectedTime.hour.toString().padStart(2, '0') + } else { + state.selectedTime.simpleHour.toString() + } } - } - - Row( - horizontalArrangement = Arrangement.Center, - modifier = modifier - .height(80.dp) - .fillMaxWidth() - ) { - ClockLabel( - text = clockHour, - backgroundColor = state.colors.backgroundColor(state.currentScreen.isHour()).value, - textColor = state.colors.textColor(state.currentScreen.isHour()).value, - onClick = { state.currentScreen = ClockScreen.Hour } - ) - Box( - Modifier - .width(24.dp) - .fillMaxHeight(), - contentAlignment = Alignment.Center + Row( + horizontalArrangement = Arrangement.Center, + modifier = modifier + .height(80.dp) + .fillMaxWidth(), ) { - Text( - text = ":", - style = TextStyle(fontSize = 50.sp, color = MaterialTheme.colorScheme.onSurface) - ) - } + ClockLabel( + text = clockHour, + backgroundColor = state.colors.backgroundColor(state.currentScreen.isHour()).value, + textColor = state.colors.textColor(state.currentScreen.isHour()).value, + onClick = { state.currentScreen = ClockScreen.Hour }, + ) - ClockLabel( - text = state.selectedTime.minute.toString().padStart(2, '0'), - backgroundColor = state.colors.backgroundColor(state.currentScreen.isMinute()).value, - textColor = state.colors.textColor(state.currentScreen.isMinute()).value, - onClick = { state.currentScreen = ClockScreen.Minute } + Box( + Modifier + .width(24.dp) + .fillMaxHeight(), + contentAlignment = Alignment.Center, + ) { + Text( + text = ":", + style = TextStyle(fontSize = 50.sp, color = MaterialTheme.colorScheme.onSurface), + ) + } - ) + ClockLabel( + text = state.selectedTime.minute.toString().padStart(2, '0'), + backgroundColor = state.colors.backgroundColor(state.currentScreen.isMinute()).value, + textColor = state.colors.textColor(state.currentScreen.isMinute()).value, + onClick = { state.currentScreen = ClockScreen.Minute }, + + ) - if (!state.is24Hour) { - VerticalPeriodPicker(state = state) + if (!state.is24Hour) { + VerticalPeriodPicker(state = state) + } } - } } @Composable private fun VerticalPeriodPicker(state: TimePickerState) { - val topPeriodShape = MaterialTheme.shapes.medium.copy( - bottomStart = CornerSize(0.dp), - bottomEnd = CornerSize(0.dp) - ) - val bottomPeriodShape = - MaterialTheme.shapes.medium.copy(topStart = CornerSize(0.dp), topEnd = CornerSize(0.dp)) - val isAMEnabled = remember(state.timeRange) { state.timeRange.start.hour <= 12 } - val isPMEnabled = remember(state.timeRange) { state.timeRange.endInclusive.hour >= 0 } - - Spacer(modifier = Modifier.width(12.dp)) - - Column( - Modifier - .fillMaxHeight() - .width(52.dp) - .border(state.colors.border, MaterialTheme.shapes.medium) - ) { - Box( - modifier = Modifier - .size(height = 40.dp, width = 52.dp) - .clip(topPeriodShape) - .background(state.colors.periodBackgroundColor(state.selectedTime.isAM).value) - .then( - if (isAMEnabled) { - Modifier.clickable { - state.selectedTime = state.selectedTime - .toAM() - .coerceIn(state.timeRange) - } - } else { - Modifier - } - ), - contentAlignment = Alignment.Center - ) { - Text( - "AM", - style = TextStyle( - state.colors.textColor(state.selectedTime.isAM).value.copy(alpha = if (isAMEnabled) ContentAlpha.high else ContentAlpha.disabled) - ) - ) - } - - Spacer( - Modifier - .fillMaxWidth() - .height(1.dp) - .background(state.colors.border.brush) + val topPeriodShape = MaterialTheme.shapes.medium.copy( + bottomStart = CornerSize(0.dp), + bottomEnd = CornerSize(0.dp), ) + val bottomPeriodShape = + MaterialTheme.shapes.medium.copy(topStart = CornerSize(0.dp), topEnd = CornerSize(0.dp)) + val isAMEnabled = remember(state.timeRange) { state.timeRange.start.hour <= 12 } + val isPMEnabled = remember(state.timeRange) { state.timeRange.endInclusive.hour >= 0 } - Box( - modifier = Modifier - .size(height = 40.dp, width = 52.dp) - .clip(bottomPeriodShape) - .background(state.colors.periodBackgroundColor(!state.selectedTime.isAM).value) - .then( - if (isPMEnabled) { - Modifier.clickable { - state.selectedTime = state.selectedTime - .toPM() - .coerceIn(state.timeRange) - } - } else { - Modifier - } - ), - contentAlignment = Alignment.Center + Spacer(modifier = Modifier.width(12.dp)) + + Column( + Modifier + .fillMaxHeight() + .width(52.dp) + .border(state.colors.border, MaterialTheme.shapes.medium), ) { - Text( - "PM", - style = TextStyle( - state.colors.textColor(!state.selectedTime.isAM).value.copy( - alpha = if (isPMEnabled) ContentAlpha.high else ContentAlpha.disabled - ) + Box( + modifier = Modifier + .size(height = 40.dp, width = 52.dp) + .clip(topPeriodShape) + .background(state.colors.periodBackgroundColor(state.selectedTime.isAM).value) + .then( + if (isAMEnabled) { + Modifier.clickable { + state.selectedTime = state.selectedTime + .toAM() + .coerceIn(state.timeRange) + } + } else { + Modifier + }, + ), + contentAlignment = Alignment.Center, + ) { + Text( + "AM", + style = TextStyle( + state.colors.textColor(state.selectedTime.isAM).value.copy(alpha = if (isAMEnabled) ContentAlpha.high else ContentAlpha.disabled), + ), + ) + } + + Spacer( + Modifier + .fillMaxWidth() + .height(1.dp) + .background(state.colors.border.brush), ) - ) + + Box( + modifier = Modifier + .size(height = 40.dp, width = 52.dp) + .clip(bottomPeriodShape) + .background(state.colors.periodBackgroundColor(!state.selectedTime.isAM).value) + .then( + if (isPMEnabled) { + Modifier.clickable { + state.selectedTime = state.selectedTime + .toPM() + .coerceIn(state.timeRange) + } + } else { + Modifier + }, + ), + contentAlignment = Alignment.Center, + ) { + Text( + "PM", + style = TextStyle( + state.colors.textColor(!state.selectedTime.isAM).value.copy( + alpha = if (isPMEnabled) ContentAlpha.high else ContentAlpha.disabled, + ), + ), + ) + } } - } } @Composable private fun HorizontalPeriodPicker(state: TimePickerState) { - val leftPeriodShape = MaterialTheme.shapes.medium.copy( - bottomEnd = CornerSize(0.dp), - topEnd = CornerSize(0.dp) - ) - val rightPeriodShape = MaterialTheme.shapes.medium.copy( - topStart = CornerSize(0.dp), - bottomStart = CornerSize(0.dp) - ) - val isAMEnabled = remember(state.timeRange) { state.timeRange.start.hour <= 12 } - val isPMEnabled = remember(state.timeRange) { state.timeRange.endInclusive.hour >= 0 } - - Spacer(modifier = Modifier.width(12.dp)) - - Row( - Modifier - .fillMaxWidth() - .height(height = 40.dp) - .border(state.colors.border, MaterialTheme.shapes.medium) - ) { - Box( - modifier = Modifier - .fillMaxHeight() - .fillMaxWidth(0.5f) - .clip(leftPeriodShape) - .background(state.colors.periodBackgroundColor(state.selectedTime.isAM).value) - .then( - if (isAMEnabled) { - Modifier.clickable { - state.selectedTime = state.selectedTime - .toAM() - .coerceIn(state.timeRange) - } - } else { - Modifier - } - ), - contentAlignment = Alignment.Center - ) { - Text( - "AM", - style = TextStyle( - state.colors.textColor(state.selectedTime.isAM).value.copy(alpha = if (isAMEnabled) ContentAlpha.high else ContentAlpha.disabled) - ) - ) - } - - Spacer( - Modifier - .fillMaxHeight() - .width(1.dp) - .background(state.colors.border.brush) + val leftPeriodShape = MaterialTheme.shapes.medium.copy( + bottomEnd = CornerSize(0.dp), + topEnd = CornerSize(0.dp), ) + val rightPeriodShape = MaterialTheme.shapes.medium.copy( + topStart = CornerSize(0.dp), + bottomStart = CornerSize(0.dp), + ) + val isAMEnabled = remember(state.timeRange) { state.timeRange.start.hour <= 12 } + val isPMEnabled = remember(state.timeRange) { state.timeRange.endInclusive.hour >= 0 } - Box( - modifier = Modifier - .fillMaxSize() - .clip(rightPeriodShape) - .background(state.colors.periodBackgroundColor(!state.selectedTime.isAM).value) - .then( - if (isPMEnabled) { - Modifier.clickable { - state.selectedTime = state.selectedTime - .toPM() - .coerceIn(state.timeRange) - } - } else { - Modifier - } - ), - contentAlignment = Alignment.Center + Spacer(modifier = Modifier.width(12.dp)) + + Row( + Modifier + .fillMaxWidth() + .height(height = 40.dp) + .border(state.colors.border, MaterialTheme.shapes.medium), ) { - Text( - "PM", - style = TextStyle( - state.colors.textColor(!state.selectedTime.isAM).value.copy( - alpha = if (isPMEnabled) ContentAlpha.high else ContentAlpha.disabled - ) + Box( + modifier = Modifier + .fillMaxHeight() + .fillMaxWidth(0.5f) + .clip(leftPeriodShape) + .background(state.colors.periodBackgroundColor(state.selectedTime.isAM).value) + .then( + if (isAMEnabled) { + Modifier.clickable { + state.selectedTime = state.selectedTime + .toAM() + .coerceIn(state.timeRange) + } + } else { + Modifier + }, + ), + contentAlignment = Alignment.Center, + ) { + Text( + "AM", + style = TextStyle( + state.colors.textColor(state.selectedTime.isAM).value.copy(alpha = if (isAMEnabled) ContentAlpha.high else ContentAlpha.disabled), + ), + ) + } + + Spacer( + Modifier + .fillMaxHeight() + .width(1.dp) + .background(state.colors.border.brush), ) - ) + + Box( + modifier = Modifier + .fillMaxSize() + .clip(rightPeriodShape) + .background(state.colors.periodBackgroundColor(!state.selectedTime.isAM).value) + .then( + if (isPMEnabled) { + Modifier.clickable { + state.selectedTime = state.selectedTime + .toPM() + .coerceIn(state.timeRange) + } + } else { + Modifier + }, + ), + contentAlignment = Alignment.Center, + ) { + Text( + "PM", + style = TextStyle( + state.colors.textColor(!state.selectedTime.isAM).value.copy( + alpha = if (isPMEnabled) ContentAlpha.high else ContentAlpha.disabled, + ), + ), + ) + } } - } } @Composable private fun ClockLayout( - isNamedAnchor: (Int) -> Boolean = { true }, - anchorPoints: Int, - innerAnchorPoints: Int = 0, - label: (Int) -> String, - startAnchor: Int, - colors: TimePickerColors, - isAnchorEnabled: (Int) -> Boolean, - onAnchorChange: (Int) -> Unit = {}, - onLift: () -> Unit = {} + isNamedAnchor: (Int) -> Boolean = { true }, + anchorPoints: Int, + innerAnchorPoints: Int = 0, + label: (Int) -> String, + startAnchor: Int, + colors: TimePickerColors, + isAnchorEnabled: (Int) -> Boolean, + onAnchorChange: (Int) -> Unit = {}, + onLift: () -> Unit = {}, ) { - BoxWithConstraints { - val faceDiameter = min(maxHeight.value, maxWidth.value).coerceAtMost(256f).dp - val faceDiameterPx = with(LocalDensity.current) { faceDiameter.toPx() } + BoxWithConstraints { + val faceDiameter = min(maxHeight.value, maxWidth.value).coerceAtMost(256f).dp + val faceDiameterPx = with(LocalDensity.current) { faceDiameter.toPx() } - val faceRadiusPx = faceDiameterPx / 2f + val faceRadiusPx = faceDiameterPx / 2f - val outerRadiusPx = faceRadiusPx * 0.8f - val innerRadiusPx = remember(outerRadiusPx) { outerRadiusPx * 0.6f } + val outerRadiusPx = faceRadiusPx * 0.8f + val innerRadiusPx = remember(outerRadiusPx) { outerRadiusPx * 0.6f } - val textSizePx = with(LocalDensity.current) { 18.sp.toPx() } - val innerTextSizePx = remember(textSizePx) { textSizePx * 0.8f } + val textSizePx = with(LocalDensity.current) { 18.sp.toPx() } + val innerTextSizePx = remember(textSizePx) { textSizePx * 0.8f } - val selectedRadius = remember(outerRadiusPx) { outerRadiusPx * 0.2f } - val selectedInnerDotRadius = remember(selectedRadius) { selectedRadius * 0.2f } - val innerSelectedRadius = remember(innerRadiusPx) { innerRadiusPx * 0.3f } + val selectedRadius = remember(outerRadiusPx) { outerRadiusPx * 0.2f } + val selectedInnerDotRadius = remember(selectedRadius) { selectedRadius * 0.2f } + val innerSelectedRadius = remember(innerRadiusPx) { innerRadiusPx * 0.3f } - val centerCircleRadius = remember(selectedRadius) { selectedRadius * 0.4f } - val selectedLineWidth = remember(centerCircleRadius) { centerCircleRadius * 0.5f } + val centerCircleRadius = remember(selectedRadius) { selectedRadius * 0.4f } + val selectedLineWidth = remember(centerCircleRadius) { centerCircleRadius * 0.5f } - val center = remember { Offset(faceRadiusPx, faceRadiusPx) } + val center = remember { Offset(faceRadiusPx, faceRadiusPx) } - val namedAnchor = remember(isNamedAnchor) { mutableStateOf(isNamedAnchor(startAnchor)) } - val selectedAnchor = remember { mutableStateOf(startAnchor) } + val namedAnchor = remember(isNamedAnchor) { mutableStateOf(isNamedAnchor(startAnchor)) } + val selectedAnchor = remember { mutableIntStateOf(startAnchor) } - val anchors = remember(anchorPoints, innerAnchorPoints) { - val anchors = mutableListOf() - for (x in 0 until anchorPoints) { - val angle = (2 * PI / anchorPoints) * (x - 15) - val selectedOuterOffset = outerRadiusPx.getOffset(angle) - val lineOuterOffset = (outerRadiusPx - selectedRadius).getOffset(angle) + val anchors = remember(anchorPoints, innerAnchorPoints) { + val anchors = mutableListOf() + for (x in 0 until anchorPoints) { + val angle = (2 * PI / anchorPoints) * (x - 15) + val selectedOuterOffset = outerRadiusPx.getOffset(angle) + val lineOuterOffset = (outerRadiusPx - selectedRadius).getOffset(angle) - anchors.add( - SelectedOffset( - lineOuterOffset, - selectedOuterOffset, - selectedRadius - ) - ) - } - for (x in 0 until innerAnchorPoints) { - val angle = (2 * PI / innerAnchorPoints) * (x - 15) - val selectedOuterOffset = innerRadiusPx.getOffset(angle) - val lineOuterOffset = (innerRadiusPx - innerSelectedRadius).getOffset(angle) - - anchors.add( - SelectedOffset( - lineOuterOffset, - selectedOuterOffset, - innerSelectedRadius - ) - ) - } - anchors - } - - val anchoredOffset = remember(anchors, startAnchor) { mutableStateOf(anchors[startAnchor]) } + anchors.add( + SelectedOffset( + lineOuterOffset, + selectedOuterOffset, + selectedRadius, + ), + ) + } + for (x in 0 until innerAnchorPoints) { + val angle = (2 * PI / innerAnchorPoints) * (x - 15) + val selectedOuterOffset = innerRadiusPx.getOffset(angle) + val lineOuterOffset = (innerRadiusPx - innerSelectedRadius).getOffset(angle) + + anchors.add( + SelectedOffset( + lineOuterOffset, + selectedOuterOffset, + innerSelectedRadius, + ), + ) + } + anchors + } - val updateAnchor: (Offset) -> Boolean = remember(anchors, isAnchorEnabled) { - { newOffset -> - val absDiff = anchors.map { - val diff = it.selectedOffset - newOffset + center - diff.x.pow(2) + diff.y.pow(2) + val anchoredOffset = remember(anchors, startAnchor) { mutableStateOf(anchors[startAnchor]) } + + val updateAnchor: (Offset) -> Boolean = remember(anchors, isAnchorEnabled) { + { newOffset -> + val absDiff = anchors.map { + val diff = it.selectedOffset - newOffset + center + diff.x.pow(2) + diff.y.pow(2) + } + + val minAnchor = absDiff.withIndex().minByOrNull { (_, f) -> f }!!.index + if (isAnchorEnabled(minAnchor)) { + if (anchoredOffset.value.selectedOffset != anchors[minAnchor].selectedOffset) { + onAnchorChange(minAnchor) + + anchoredOffset.value = anchors[minAnchor] + namedAnchor.value = isNamedAnchor(minAnchor) + selectedAnchor.value = minAnchor + } + true + } else { + false + } + } } - val minAnchor = absDiff.withIndex().minByOrNull { (_, f) -> f }!!.index - if (isAnchorEnabled(minAnchor)) { - if (anchoredOffset.value.selectedOffset != anchors[minAnchor].selectedOffset) { - onAnchorChange(minAnchor) + val dragSuccess = remember { mutableStateOf(false) } - anchoredOffset.value = anchors[minAnchor] - namedAnchor.value = isNamedAnchor(minAnchor) - selectedAnchor.value = minAnchor - } - true - } else { - false + val dragObserver: suspend PointerInputScope.() -> Unit = { + detectDragGestures( + onDragStart = { dragSuccess.value = true }, + onDragCancel = { dragSuccess.value = false }, + onDragEnd = { if (dragSuccess.value) onLift() }, + ) { change, _ -> + dragSuccess.value = updateAnchor(change.position) + if (change.positionChange() != Offset.Zero) change.consume() + } } - } - } - - val dragSuccess = remember { mutableStateOf(false) } - - val dragObserver: suspend PointerInputScope.() -> Unit = { - detectDragGestures( - onDragStart = { dragSuccess.value = true }, - onDragCancel = { dragSuccess.value = false }, - onDragEnd = { if (dragSuccess.value) onLift() } - ) { change, _ -> - dragSuccess.value = updateAnchor(change.position) - if (change.positionChange() != Offset.Zero) change.consume() - } - } - val tapObserver: suspend PointerInputScope.() -> Unit = { - detectTapGestures(onPress = { - val anchorsChanged = updateAnchor(it) - val success = tryAwaitRelease() + val tapObserver: suspend PointerInputScope.() -> Unit = { + detectTapGestures(onPress = { + val anchorsChanged = updateAnchor(it) + val success = tryAwaitRelease() - if ((success || !dragSuccess.value) && anchorsChanged) { - onLift() + if ((success || !dragSuccess.value) && anchorsChanged) { + onLift() + } + }) } - }) - } - val inactiveTextColor = colors.textColor(false).value.toArgb() - val clockBackgroundColor = colors.backgroundColor(false).value - val selectorColor = remember { colors.selectorColor() } - val selectorTextColor = remember { colors.selectorTextColor().toArgb() } + val inactiveTextColor = colors.textColor(false).value.toArgb() + val clockBackgroundColor = colors.backgroundColor(false).value + val selectorColor = remember { colors.selectorColor() } + val selectorTextColor = remember { colors.selectorTextColor().toArgb() } - val enabledAlpha = ContentAlpha.high - val disabledAlpha = ContentAlpha.disabled + val enabledAlpha = ContentAlpha.high + val disabledAlpha = ContentAlpha.disabled - Canvas( - modifier = Modifier - .size(faceDiameter) - .pointerInput(null, dragObserver) - .pointerInput(null, tapObserver) - ) { - drawCircle(clockBackgroundColor, radius = faceRadiusPx, center = center) - drawCircle(selectorColor, radius = centerCircleRadius, center = center) - drawLine( - color = selectorColor, - start = center, - end = center + anchoredOffset.value.lineOffset, - strokeWidth = selectedLineWidth, - alpha = 0.8f - ) - - drawCircle( - selectorColor, - center = center + anchoredOffset.value.selectedOffset, - radius = anchoredOffset.value.selectedRadius, - alpha = 0.7f - ) - - if (!namedAnchor.value) { - drawCircle( - Color.White, - center = center + anchoredOffset.value.selectedOffset, - radius = selectedInnerDotRadius, - alpha = 0.8f - ) - } - - drawIntoCanvas { canvas -> - fun drawAnchorText( - anchor: Int, - textSize: Float, - radius: Float, - angle: Double, - alpha: Int = 255 + Canvas( + modifier = Modifier + .size(faceDiameter) + .pointerInput(null, dragObserver) + .pointerInput(null, tapObserver), ) { - val textOuter = label(anchor) - val textColor = if (selectedAnchor.value == anchor) { - selectorTextColor - } else { - inactiveTextColor - } - - val contentAlpha = if (isAnchorEnabled(anchor)) enabledAlpha else disabledAlpha - - drawText( - textSize, - textOuter, - center, - angle.toFloat(), - canvas, - radius, - alpha = (255f * contentAlpha).roundToInt().coerceAtMost(alpha), - color = textColor - ) - } + drawCircle(clockBackgroundColor, radius = faceRadiusPx, center = center) + drawCircle(selectorColor, radius = centerCircleRadius, center = center) + drawLine( + color = selectorColor, + start = center, + end = center + anchoredOffset.value.lineOffset, + strokeWidth = selectedLineWidth, + alpha = 0.8f, + ) - for (x in 0 until 12) { - val angle = (2 * PI / 12) * (x - 15) - drawAnchorText(x * anchorPoints / 12, textSizePx, outerRadiusPx, angle) - - if (innerAnchorPoints > 0) { - drawAnchorText( - x * innerAnchorPoints / 12 + anchorPoints, - innerTextSizePx, - innerRadiusPx, - angle, - alpha = (255 * 0.8f).toInt() + drawCircle( + selectorColor, + center = center + anchoredOffset.value.selectedOffset, + radius = anchoredOffset.value.selectedRadius, + alpha = 0.7f, ) - } + + if (!namedAnchor.value) { + drawCircle( + Color.White, + center = center + anchoredOffset.value.selectedOffset, + radius = selectedInnerDotRadius, + alpha = 0.8f, + ) + } + + drawIntoCanvas { canvas -> + fun drawAnchorText( + anchor: Int, + textSize: Float, + radius: Float, + angle: Double, + alpha: Int = 255, + ) { + val textOuter = label(anchor) + val textColor = if (selectedAnchor.value == anchor) { + selectorTextColor + } else { + inactiveTextColor + } + + val contentAlpha = if (isAnchorEnabled(anchor)) enabledAlpha else disabledAlpha + + drawText( + textSize, + textOuter, + center, + angle.toFloat(), + canvas, + radius, + alpha = (255f * contentAlpha).roundToInt().coerceAtMost(alpha), + color = textColor, + ) + } + + for (x in 0 until 12) { + val angle = (2 * PI / 12) * (x - 15) + drawAnchorText(x * anchorPoints / 12, textSizePx, outerRadiusPx, angle) + + if (innerAnchorPoints > 0) { + drawAnchorText( + x * innerAnchorPoints / 12 + anchorPoints, + innerTextSizePx, + innerRadiusPx, + angle, + alpha = (255 * 0.8f).toInt(), + ) + } + } + } } - } } - } } private fun drawText( - textSize: Float, - text: String, - center: Offset, - angle: Float, - canvas: Canvas, - radius: Float, - alpha: Int = 255, - color: Int = android.graphics.Color.WHITE + textSize: Float, + text: String, + center: Offset, + angle: Float, + canvas: Canvas, + radius: Float, + alpha: Int = 255, + color: Int = android.graphics.Color.WHITE, ) { - val outerText = Paint() - outerText.color = color - outerText.textSize = textSize - outerText.textAlign = Paint.Align.CENTER - outerText.alpha = alpha - - val r = Rect() - outerText.getTextBounds(text, 0, text.length, r) - - canvas.nativeCanvas.drawText( - text, - center.x + (radius * cos(angle)), - center.y + (radius * sin(angle)) + (abs(r.height())) / 2, - outerText - ) + val outerText = Paint() + outerText.color = color + outerText.textSize = textSize + outerText.textAlign = Paint.Align.CENTER + outerText.alpha = alpha + + val r = Rect() + outerText.getTextBounds(text, 0, text.length, r) + + canvas.nativeCanvas.drawText( + text, + center.x + (radius * cos(angle)), + center.y + (radius * sin(angle)) + (abs(r.height())) / 2, + outerText, + ) } diff --git a/app/src/main/java/com/example/android/january2022/ui/datetimedialog/time/TimePickerColors.kt b/app/src/main/java/com/example/android/january2022/ui/datetimedialog/time/TimePickerColors.kt index dea7e31..35a9cc3 100644 --- a/app/src/main/java/com/example/android/january2022/ui/datetimedialog/time/TimePickerColors.kt +++ b/app/src/main/java/com/example/android/january2022/ui/datetimedialog/time/TimePickerColors.kt @@ -1,6 +1,5 @@ package com.example.android.january2022.ui.datetimedialog.time - import androidx.compose.foundation.BorderStroke import androidx.compose.runtime.Composable import androidx.compose.runtime.State @@ -9,83 +8,83 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp /** - * Represents the colors used by a [timepicker] and its parts in different states + * Represents the colors used by a [Timepicker] and its parts in different states * * See [TimePickerDefaults.colors] for the default implementation */ interface TimePickerColors { - val border: BorderStroke + val border: BorderStroke - /** - * Gets the background color dependant on if the item is active or not - * - * @param active true if the component/item is selected and false otherwise - * @return background color as a State - */ - @Composable - fun backgroundColor(active: Boolean): State + /** + * Gets the background color dependant on if the item is active or not + * + * @param active true if the component/item is selected and false otherwise + * @return background color as a State + */ + @Composable + fun backgroundColor(active: Boolean): State - /** - * Gets the text color dependant on if the item is active or not - * - * @param active true if the component/item is selected and false otherwise - * @return text color as a State - */ - @Composable - fun textColor(active: Boolean): State + /** + * Gets the text color dependant on if the item is active or not + * + * @param active true if the component/item is selected and false otherwise + * @return text color as a State + */ + @Composable + fun textColor(active: Boolean): State - /** - * Get the color of clock hand and color of text in clock hand - */ - fun selectorColor(): Color - fun selectorTextColor(): Color + /** + * Get the color of clock hand and color of text in clock hand + */ + fun selectorColor(): Color + fun selectorTextColor(): Color - /** - * Get color of title text - */ - fun headerTextColor(): Color + /** + * Get color of title text + */ + fun headerTextColor(): Color - @Composable - fun periodBackgroundColor(active: Boolean): State + @Composable + fun periodBackgroundColor(active: Boolean): State } internal class DefaultTimePickerColors( - private val activeBackgroundColor: Color, - private val inactiveBackgroundColor: Color, - private val activeTextColor: Color, - private val inactiveTextColor: Color, - private val inactivePeriodBackground: Color, - private val selectorColor: Color, - private val selectorTextColor: Color, - private val headerTextColor: Color, - borderColor: Color + private val activeBackgroundColor: Color, + private val inactiveBackgroundColor: Color, + private val activeTextColor: Color, + private val inactiveTextColor: Color, + private val inactivePeriodBackground: Color, + private val selectorColor: Color, + private val selectorTextColor: Color, + private val headerTextColor: Color, + borderColor: Color, ) : TimePickerColors { - override val border = BorderStroke(1.dp, borderColor) + override val border = BorderStroke(1.dp, borderColor) - @Composable - override fun backgroundColor(active: Boolean): State { - return rememberUpdatedState(if (active) activeBackgroundColor else inactiveBackgroundColor) - } + @Composable + override fun backgroundColor(active: Boolean): State { + return rememberUpdatedState(if (active) activeBackgroundColor else inactiveBackgroundColor) + } - @Composable - override fun textColor(active: Boolean): State { - return rememberUpdatedState(if (active) activeTextColor else inactiveTextColor) - } + @Composable + override fun textColor(active: Boolean): State { + return rememberUpdatedState(if (active) activeTextColor else inactiveTextColor) + } - override fun selectorColor(): Color { - return selectorColor - } + override fun selectorColor(): Color { + return selectorColor + } - override fun selectorTextColor(): Color { - return selectorTextColor - } + override fun selectorTextColor(): Color { + return selectorTextColor + } - override fun headerTextColor(): Color { - return headerTextColor - } + override fun headerTextColor(): Color { + return headerTextColor + } - @Composable - override fun periodBackgroundColor(active: Boolean): State { - return rememberUpdatedState(if (active) activeBackgroundColor else inactivePeriodBackground) - } + @Composable + override fun periodBackgroundColor(active: Boolean): State { + return rememberUpdatedState(if (active) activeBackgroundColor else inactivePeriodBackground) + } } diff --git a/app/src/main/java/com/example/android/january2022/ui/datetimedialog/time/TimePickerDefaults.kt b/app/src/main/java/com/example/android/january2022/ui/datetimedialog/time/TimePickerDefaults.kt index 8bcca60..a1109b9 100644 --- a/app/src/main/java/com/example/android/january2022/ui/datetimedialog/time/TimePickerDefaults.kt +++ b/app/src/main/java/com/example/android/january2022/ui/datetimedialog/time/TimePickerDefaults.kt @@ -1,51 +1,50 @@ package com.example.android.january2022.ui.datetimedialog.time - import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.Color /** - * Object to hold default values used by [timepicker] + * Object to hold default values used by [Timepicker] */ object TimePickerDefaults { - /** - * Initialises a [TimePickerColors] object which represents the different colors used by - * the [timepicker] composable - * - * @param activeBackgroundColor background color of selected time unit or period (AM/PM) - * @param inactiveBackgroundColor background color of inactive items in the dialog including - * the clock face - * @param activeTextColor color of text on the activeBackgroundColor - * @param inactiveTextColor color of text on the inactiveBackgroundColor - * @param inactivePeriodBackground background color of the inactive period (AM/PM) selector - * @param selectorColor color of clock hand/selector - * @param selectorTextColor color of text on selectedColor - * @param headerTextColor Get color of title text - * @param borderColor border color of the period (AM/PM) selector - */ - @Composable - fun colors( - activeBackgroundColor: Color = MaterialTheme.colorScheme.surfaceVariant.copy(0.6f), - inactiveBackgroundColor: Color = MaterialTheme.colorScheme.surfaceVariant.copy(0.3f), - activeTextColor: Color = MaterialTheme.colorScheme.primary, - inactiveTextColor: Color = MaterialTheme.colorScheme.onBackground, - inactivePeriodBackground: Color = Color.Transparent, - selectorColor: Color = MaterialTheme.colorScheme.primary, - selectorTextColor: Color = MaterialTheme.colorScheme.onPrimary, - headerTextColor: Color = MaterialTheme.colorScheme.onBackground, - borderColor: Color = MaterialTheme.colorScheme.onBackground - ): TimePickerColors { - return DefaultTimePickerColors( - activeBackgroundColor = activeBackgroundColor, - inactiveBackgroundColor = inactiveBackgroundColor, - activeTextColor = activeTextColor, - inactiveTextColor = inactiveTextColor, - inactivePeriodBackground = inactivePeriodBackground, - selectorColor = selectorColor, - selectorTextColor = selectorTextColor, - headerTextColor = headerTextColor, - borderColor = borderColor - ) - } + /** + * Initialises a [TimePickerColors] object which represents the different colors used by + * the [Timepicker] composable + * + * @param activeBackgroundColor background color of selected time unit or period (AM/PM) + * @param inactiveBackgroundColor background color of inactive items in the dialog including + * the clock face + * @param activeTextColor color of text on the activeBackgroundColor + * @param inactiveTextColor color of text on the inactiveBackgroundColor + * @param inactivePeriodBackground background color of the inactive period (AM/PM) selector + * @param selectorColor color of clock hand/selector + * @param selectorTextColor color of text on selectedColor + * @param headerTextColor Get color of title text + * @param borderColor border color of the period (AM/PM) selector + */ + @Composable + fun colors( + activeBackgroundColor: Color = MaterialTheme.colorScheme.surfaceVariant.copy(0.6f), + inactiveBackgroundColor: Color = MaterialTheme.colorScheme.surfaceVariant.copy(0.3f), + activeTextColor: Color = MaterialTheme.colorScheme.primary, + inactiveTextColor: Color = MaterialTheme.colorScheme.onBackground, + inactivePeriodBackground: Color = Color.Transparent, + selectorColor: Color = MaterialTheme.colorScheme.primary, + selectorTextColor: Color = MaterialTheme.colorScheme.onPrimary, + headerTextColor: Color = MaterialTheme.colorScheme.onBackground, + borderColor: Color = MaterialTheme.colorScheme.onBackground, + ): TimePickerColors { + return DefaultTimePickerColors( + activeBackgroundColor = activeBackgroundColor, + inactiveBackgroundColor = inactiveBackgroundColor, + activeTextColor = activeTextColor, + inactiveTextColor = inactiveTextColor, + inactivePeriodBackground = inactivePeriodBackground, + selectorColor = selectorColor, + selectorTextColor = selectorTextColor, + headerTextColor = headerTextColor, + borderColor = borderColor, + ) + } } diff --git a/app/src/main/java/com/example/android/january2022/ui/datetimedialog/time/TimePickerState.kt b/app/src/main/java/com/example/android/january2022/ui/datetimedialog/time/TimePickerState.kt index 3b957a7..a142fde 100644 --- a/app/src/main/java/com/example/android/january2022/ui/datetimedialog/time/TimePickerState.kt +++ b/app/src/main/java/com/example/android/january2022/ui/datetimedialog/time/TimePickerState.kt @@ -1,6 +1,5 @@ package com.example.android.january2022.ui.datetimedialog.time - import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue @@ -8,54 +7,55 @@ import com.example.android.january2022.ui.datetimedialog.isAM import java.time.LocalTime internal enum class ClockScreen { - Hour, - Minute; + Hour, + Minute, + ; - fun isHour() = this == Hour - fun isMinute() = this == Minute + fun isHour() = this == Hour + fun isMinute() = this == Minute } internal class TimePickerState( - val colors: TimePickerColors, - selectedTime: LocalTime, - currentScreen: ClockScreen = ClockScreen.Hour, - clockInput: Boolean = true, - timeRange: ClosedRange, - is24Hour: Boolean + val colors: TimePickerColors, + selectedTime: LocalTime, + currentScreen: ClockScreen = ClockScreen.Hour, + clockInput: Boolean = true, + timeRange: ClosedRange, + is24Hour: Boolean, ) { - var selectedTime by mutableStateOf(selectedTime) - var timeRange by mutableStateOf(timeRange) - var is24Hour by mutableStateOf(is24Hour) - var currentScreen by mutableStateOf(currentScreen) - var clockInput by mutableStateOf(clockInput) - - private fun minimumMinute(isAM: Boolean, hour: Int): Int { - return when { - isAM == timeRange.start.isAM -> - if (timeRange.start.hour == hour) { - timeRange.start.minute - } else { - 0 + var selectedTime by mutableStateOf(selectedTime) + var timeRange by mutableStateOf(timeRange) + var is24Hour by mutableStateOf(is24Hour) + var currentScreen by mutableStateOf(currentScreen) + var clockInput by mutableStateOf(clockInput) + + private fun minimumMinute(isAM: Boolean, hour: Int): Int { + return when { + isAM == timeRange.start.isAM -> + if (timeRange.start.hour == hour) { + timeRange.start.minute + } else { + 0 + } + isAM -> 61 + else -> 0 } - isAM -> 61 - else -> 0 } - } - - private fun maximumMinute(isAM: Boolean, hour: Int): Int { - return when { - isAM == timeRange.endInclusive.isAM -> - if (timeRange.endInclusive.hour == hour) { - timeRange.endInclusive.minute - } else { - 60 + + private fun maximumMinute(isAM: Boolean, hour: Int): Int { + return when { + isAM == timeRange.endInclusive.isAM -> + if (timeRange.endInclusive.hour == hour) { + timeRange.endInclusive.minute + } else { + 60 + } + isAM -> 60 + else -> 0 } - isAM -> 60 - else -> 0 } - } - fun hourRange() = timeRange.start.hour..timeRange.endInclusive.hour + fun hourRange() = timeRange.start.hour..timeRange.endInclusive.hour - fun minuteRange(isAM: Boolean, hour: Int) = minimumMinute(isAM, hour)..maximumMinute(isAM, hour) + fun minuteRange(isAM: Boolean, hour: Int) = minimumMinute(isAM, hour)..maximumMinute(isAM, hour) } diff --git a/app/src/main/java/com/example/android/january2022/ui/exercisepicker/ExercisePickerScreen.kt b/app/src/main/java/com/example/android/january2022/ui/exercisepicker/ExercisePickerScreen.kt index 84c8495..5868a4f 100644 --- a/app/src/main/java/com/example/android/january2022/ui/exercisepicker/ExercisePickerScreen.kt +++ b/app/src/main/java/com/example/android/january2022/ui/exercisepicker/ExercisePickerScreen.kt @@ -1,7 +1,21 @@ package com.example.android.january2022.ui.exercisepicker -import androidx.compose.animation.* -import androidx.compose.foundation.layout.* +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CutCornerShape @@ -10,8 +24,25 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.AccessibilityNew import androidx.compose.material.icons.filled.ArrowDropDown import androidx.compose.material.icons.filled.FitnessCenter -import androidx.compose.material3.* -import androidx.compose.runtime.* +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.FilterChipDefaults +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +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.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier @@ -36,216 +67,220 @@ import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class) @Composable fun ExercisePickerScreen( - navController: NavController, - viewModel: PickerViewModel = hiltViewModel() + navController: NavController, + viewModel: PickerViewModel = hiltViewModel(), ) { - val exercises by viewModel.filteredExercises.collectAsState(initial = emptyList()) - val selectedExercises by viewModel.selectedExercises.collectAsState() - val muscleFilter by viewModel.muscleFilter.collectAsState() - val equipmentFilter by viewModel.equipmentFilter.collectAsState() - val filterSelected by viewModel.filterSelected.collectAsState() - val filterUsed by viewModel.filterUsed.collectAsState() - val searchText by viewModel.searchText.collectAsState() + val exercises by viewModel.filteredExercises.collectAsState(initial = emptyList()) + val selectedExercises by viewModel.selectedExercises.collectAsState() + val muscleFilter by viewModel.muscleFilter.collectAsState() + val equipmentFilter by viewModel.equipmentFilter.collectAsState() + val filterSelected by viewModel.filterSelected.collectAsState() + val filterUsed by viewModel.filterUsed.collectAsState() + val searchText by viewModel.searchText.collectAsState() - val controller = LocalSoftwareKeyboardController.current - val uriHandler = LocalUriHandler.current + val controller = LocalSoftwareKeyboardController.current + val uriHandler = LocalUriHandler.current - LaunchedEffect(true) { - viewModel.uiEvent.collect { event -> - when (event) { - is UiEvent.OpenWebsite -> { - uriHandler.openUri(event.url) + LaunchedEffect(true) { + viewModel.uiEvent.collect { event -> + when (event) { + is UiEvent.OpenWebsite -> { + uriHandler.openUri(event.url) + } + else -> Unit + } } - else -> Unit - } } - } - val sheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden) - val coroutineScope = rememberCoroutineScope() - val equipmentBottomsheet = remember { mutableStateOf(false) } - val filterColors = FilterChipDefaults.filterChipColors( - selectedContainerColor = MaterialTheme.colorScheme.primary, - selectedLabelColor = MaterialTheme.colorScheme.onPrimary, - selectedTrailingIconColor = MaterialTheme.colorScheme.onPrimary, - labelColor = MaterialTheme.colorScheme.onSurfaceVariant, - iconColor = MaterialTheme.colorScheme.onSurfaceVariant - ) + val sheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden) + val coroutineScope = rememberCoroutineScope() + val equipmentBottomsheet = remember { mutableStateOf(false) } + val filterColors = FilterChipDefaults.filterChipColors( + selectedContainerColor = MaterialTheme.colorScheme.primary, + selectedLabelColor = MaterialTheme.colorScheme.onPrimary, + selectedTrailingIconColor = MaterialTheme.colorScheme.onPrimary, + labelColor = MaterialTheme.colorScheme.onSurfaceVariant, + iconColor = MaterialTheme.colorScheme.onSurfaceVariant, + ) - ModalBottomSheetLayout( - sheetContent = { - if (equipmentBottomsheet.value) { - EquipmentSheet(equipmentFilter, viewModel::onEvent) - } else { - MuscleSheet(muscleFilter, viewModel::onEvent) - } - }, - sheetState = sheetState, - sheetShape = MaterialTheme.shapes.large.onlyTop() - ) { - Scaffold( - floatingActionButton = { - Box( - modifier = Modifier - .height(64.dp) - .width(80.dp) - ) { - AnimatedVisibility( - visible = selectedExercises.isNotEmpty(), - enter = scaleIn() + fadeIn(), - exit = scaleOut() + fadeOut() - ) { - FloatingActionButton( - onClick = { - viewModel.onEvent(PickerEvent.AddExercises) - navController.popBackStack() - }, - containerColor = MaterialTheme.colorScheme.primary, - modifier = Modifier.align(Alignment.BottomEnd) - ) { - Text( - text = "ADD ${selectedExercises.size}", - modifier = Modifier - .padding(vertical = 4.dp, horizontal = 10.dp) - .fillMaxWidth(), - style = MaterialTheme.typography.labelLarge, - textAlign = TextAlign.Center - ) + ModalBottomSheetLayout( + sheetContent = { + if (equipmentBottomsheet.value) { + EquipmentSheet(equipmentFilter, viewModel::onEvent) + } else { + MuscleSheet(muscleFilter, viewModel::onEvent) } - } - } - }, - topBar = { - Surface( - shape = CutCornerShape(0.dp), - tonalElevation = 2.dp - ) { - Column { - Spacer(Modifier.height(40.dp)) - TextField( - value = searchText, - label = { - Text( - text = "search for exercise", - textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth() - ) - }, - onValueChange = { viewModel.onEvent(PickerEvent.SearchChanged(it)) }, - modifier = Modifier - .fillMaxWidth() - .padding(top = 4.dp, start = 8.dp, end = 8.dp) - .clearFocusOnKeyboardDismiss() - .align(Alignment.CenterHorizontally), - colors = TextFieldDefaults.textFieldColors( - focusedIndicatorColor = Color.Transparent, - unfocusedIndicatorColor = Color.Transparent - ), - shape = RoundedCornerShape(8.dp), - textStyle = LocalTextStyle.current.copy(textAlign = TextAlign.Center) - ) - Row( - modifier = Modifier - .fillMaxWidth() - .padding(end = 8.dp, bottom = 0.dp), - horizontalArrangement = Arrangement.End - ) { - FilterChip( - selected = filterSelected, - onClick = { viewModel.onEvent(PickerEvent.FilterSelected) }, - label = { - Text(text = "Selected") - }, - colors = filterColors - ) - Spacer(Modifier.width(8.dp)) - FilterChip( - selected = filterUsed, - onClick = { viewModel.onEvent(PickerEvent.FilterUsed) }, - label = { - Text(text = "Used") - }, - colors = filterColors - ) - Spacer(Modifier.width(8.dp)) - FilterChip( - selected = muscleFilter.isNotEmpty(), - onClick = { - equipmentBottomsheet.value = false - coroutineScope.launch { - if (sheetState.isVisible) sheetState.hide() else { - controller?.hide() - sheetState.expand() + }, + sheetState = sheetState, + sheetShape = MaterialTheme.shapes.large.onlyTop(), + ) { + Scaffold( + floatingActionButton = { + Box( + modifier = Modifier + .height(64.dp) + .width(80.dp), + ) { + AnimatedVisibility( + visible = selectedExercises.isNotEmpty(), + enter = scaleIn() + fadeIn(), + exit = scaleOut() + fadeOut(), + ) { + FloatingActionButton( + onClick = { + viewModel.onEvent(PickerEvent.AddExercises) + navController.popBackStack() + }, + containerColor = MaterialTheme.colorScheme.primary, + modifier = Modifier.align(Alignment.BottomEnd), + ) { + Text( + text = "ADD ${selectedExercises.size}", + modifier = Modifier + .padding(vertical = 4.dp, horizontal = 10.dp) + .fillMaxWidth(), + style = MaterialTheme.typography.labelLarge, + textAlign = TextAlign.Center, + ) + } } - } - }, - label = { - Icon( - imageVector = Icons.Default.AccessibilityNew, - contentDescription = "Equipment", - modifier = Modifier.size(18.dp) - ) - }, - trailingIcon = { - Icon( - imageVector = Icons.Default.ArrowDropDown, - contentDescription = "Dropdown", - modifier = Modifier.size(22.dp) - ) - }, - colors = filterColors - ) - Spacer(Modifier.width(8.dp)) - FilterChip( - selected = equipmentFilter.isNotEmpty(), - onClick = { - equipmentBottomsheet.value = true - coroutineScope.launch { - if (sheetState.isVisible) sheetState.hide() else { - controller?.hide() - sheetState.show() + } + }, + topBar = { + Surface( + shape = CutCornerShape(0.dp), + tonalElevation = 2.dp, + ) { + Column { + Spacer(Modifier.height(40.dp)) + TextField( + value = searchText, + label = { + Text( + text = "search for exercise", + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + }, + onValueChange = { viewModel.onEvent(PickerEvent.SearchChanged(it)) }, + modifier = Modifier + .fillMaxWidth() + .padding(top = 4.dp, start = 8.dp, end = 8.dp) + .clearFocusOnKeyboardDismiss() + .align(Alignment.CenterHorizontally), + colors = TextFieldDefaults.colors( + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + ), + shape = RoundedCornerShape(8.dp), + textStyle = LocalTextStyle.current.copy(textAlign = TextAlign.Center), + ) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(end = 8.dp, bottom = 0.dp), + horizontalArrangement = Arrangement.End, + ) { + FilterChip( + selected = filterSelected, + onClick = { viewModel.onEvent(PickerEvent.FilterSelected) }, + label = { + Text(text = "Selected") + }, + colors = filterColors, + ) + Spacer(Modifier.width(8.dp)) + FilterChip( + selected = filterUsed, + onClick = { viewModel.onEvent(PickerEvent.FilterUsed) }, + label = { + Text(text = "Used") + }, + colors = filterColors, + ) + Spacer(Modifier.width(8.dp)) + FilterChip( + selected = muscleFilter.isNotEmpty(), + onClick = { + equipmentBottomsheet.value = false + coroutineScope.launch { + if (sheetState.isVisible) { + sheetState.hide() + } else { + controller?.hide() + sheetState.expand() + } + } + }, + label = { + Icon( + imageVector = Icons.Default.AccessibilityNew, + contentDescription = "Equipment", + modifier = Modifier.size(18.dp), + ) + }, + trailingIcon = { + Icon( + imageVector = Icons.Default.ArrowDropDown, + contentDescription = "Dropdown", + modifier = Modifier.size(22.dp), + ) + }, + colors = filterColors, + ) + Spacer(Modifier.width(8.dp)) + FilterChip( + selected = equipmentFilter.isNotEmpty(), + onClick = { + equipmentBottomsheet.value = true + coroutineScope.launch { + if (sheetState.isVisible) { + sheetState.hide() + } else { + controller?.hide() + sheetState.show() + } + } + }, + label = { + Icon( + imageVector = Icons.Default.FitnessCenter, + contentDescription = "Equipment", + modifier = Modifier.size(18.dp), + ) + }, + trailingIcon = { + Icon( + imageVector = Icons.Default.ArrowDropDown, + contentDescription = "Dropdown", + modifier = Modifier.size(22.dp), + ) + }, + colors = filterColors, + ) + } } - } - }, - label = { - Icon( - imageVector = Icons.Default.FitnessCenter, - contentDescription = "Equipment", - modifier = Modifier.size(18.dp) - ) - }, - trailingIcon = { - Icon( - imageVector = Icons.Default.ArrowDropDown, - contentDescription = "Dropdown", - modifier = Modifier.size(22.dp) - ) - }, - colors = filterColors - ) + } + }, + ) { paddingValues -> + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 8.dp), + ) { + item { + Spacer(modifier = Modifier.height(paddingValues.calculateTopPadding() + 8.dp)) + } + items(exercises) { exercise -> + ExerciseCard( + exercise = exercise, + selected = selectedExercises.contains(exercise), + onEvent = viewModel::onEvent, + ) { + viewModel.onEvent(PickerEvent.ExerciseSelected(exercise)) + } + } } - } - } - }, - ) { paddingValues -> - LazyColumn( - modifier = Modifier - .fillMaxSize() - .padding(horizontal = 8.dp) - ) { - item { - Spacer(modifier = Modifier.height(paddingValues.calculateTopPadding() + 8.dp)) - } - items(exercises) { exercise -> - ExerciseCard( - exercise = exercise, - selected = selectedExercises.contains(exercise), - onEvent = viewModel::onEvent - ) { - viewModel.onEvent(PickerEvent.ExerciseSelected(exercise)) - } } - } } - } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/example/android/january2022/ui/exercisepicker/PickerEvent.kt b/app/src/main/java/com/example/android/january2022/ui/exercisepicker/PickerEvent.kt index e9471e5..104efd9 100644 --- a/app/src/main/java/com/example/android/january2022/ui/exercisepicker/PickerEvent.kt +++ b/app/src/main/java/com/example/android/january2022/ui/exercisepicker/PickerEvent.kt @@ -4,14 +4,14 @@ import com.example.android.january2022.db.entities.Exercise import com.example.android.january2022.utils.Event sealed class PickerEvent : Event { - data class ExerciseSelected(val exercise: Exercise) : PickerEvent() - data class OpenGuide(val exercise: Exercise) : PickerEvent() - object FilterSelected : PickerEvent() - object FilterUsed : PickerEvent() - data class SelectMuscle(val muscle: String) : PickerEvent() - object DeselectMuscles : PickerEvent() - data class SelectEquipment(val equipment: String) : PickerEvent() - object DeselectEquipment : PickerEvent() - object AddExercises : PickerEvent() - data class SearchChanged(val text: String) : PickerEvent() + data class ExerciseSelected(val exercise: Exercise) : PickerEvent() + data class OpenGuide(val exercise: Exercise) : PickerEvent() + object FilterSelected : PickerEvent() + object FilterUsed : PickerEvent() + data class SelectMuscle(val muscle: String) : PickerEvent() + object DeselectMuscles : PickerEvent() + data class SelectEquipment(val equipment: String) : PickerEvent() + object DeselectEquipment : PickerEvent() + object AddExercises : PickerEvent() + data class SearchChanged(val text: String) : PickerEvent() } diff --git a/app/src/main/java/com/example/android/january2022/ui/exercisepicker/PickerViewModel.kt b/app/src/main/java/com/example/android/january2022/ui/exercisepicker/PickerViewModel.kt index 652fcfd..4e0e2b3 100644 --- a/app/src/main/java/com/example/android/january2022/ui/exercisepicker/PickerViewModel.kt +++ b/app/src/main/java/com/example/android/january2022/ui/exercisepicker/PickerViewModel.kt @@ -10,161 +10,165 @@ import com.example.android.january2022.utils.Event import com.example.android.january2022.utils.UiEvent import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.flow.* +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel class PickerViewModel @Inject constructor( - private val repo: GymRepository, - private val savedStateHandle: SavedStateHandle + private val repo: GymRepository, + private val savedStateHandle: SavedStateHandle, ) : ViewModel() { - private val _selectedExercises = MutableStateFlow>(emptyList()) - val selectedExercises = _selectedExercises.asStateFlow() + private val _selectedExercises = MutableStateFlow>(emptyList()) + val selectedExercises = _selectedExercises.asStateFlow() - private val _equipmentFilter = MutableStateFlow>(emptyList()) - val equipmentFilter = _equipmentFilter.asStateFlow() + private val _equipmentFilter = MutableStateFlow>(emptyList()) + val equipmentFilter = _equipmentFilter.asStateFlow() - private val _muscleFilter = MutableStateFlow>(emptyList()) - val muscleFilter = _muscleFilter.asStateFlow() + private val _muscleFilter = MutableStateFlow>(emptyList()) + val muscleFilter = _muscleFilter.asStateFlow() - private val _filterSelected = MutableStateFlow(false) - val filterSelected = _filterSelected.asStateFlow() + private val _filterSelected = MutableStateFlow(false) + val filterSelected = _filterSelected.asStateFlow() - private val _filterUsed = MutableStateFlow(false) - val filterUsed = _filterUsed.asStateFlow() + private val _filterUsed = MutableStateFlow(false) + val filterUsed = _filterUsed.asStateFlow() - private val _searchText = MutableStateFlow("") - val searchText = _searchText.asStateFlow() + private val _searchText = MutableStateFlow("") + val searchText = _searchText.asStateFlow() - val filteredExercises: Flow> = combine( - repo.getAllExercises(), - selectedExercises, - equipmentFilter, - muscleFilter, - filterSelected, - filterUsed, - searchText - ) { exercises, selectedExercises, equipmentFilter, muscleFilter, selected, used, text -> - exercises.filter { exercise -> - val muscleCondition = - (muscleFilter.isEmpty() || exercise.getMuscleGroups().any { muscleFilter.contains(it) }) - val equipmentCondition = - (equipmentFilter.isEmpty() || exercise.equipment.any { equipmentFilter.contains(it) }) - val selectedCondition = (!selected || selectedExercises.contains(exercise)) + val filteredExercises: Flow> = combine( + repo.getAllExercises(), + selectedExercises, + equipmentFilter, + muscleFilter, + filterSelected, + filterUsed, + searchText, + ) { exercises, selectedExercises, equipmentFilter, muscleFilter, selected, _, text -> + exercises.filter { exercise -> + val muscleCondition = + (muscleFilter.isEmpty() || exercise.getMuscleGroups().any { muscleFilter.contains(it) }) + val equipmentCondition = + (equipmentFilter.isEmpty() || exercise.equipment.any { equipmentFilter.contains(it) }) + val selectedCondition = (!selected || selectedExercises.contains(exercise)) - muscleCondition && equipmentCondition && selectedCondition && exercise.getStringMatch(text) - }.sortedBy { exercise -> - if (text.isNotBlank()) { - exercise.title.length - } else { - exercise.title.first().code - } + muscleCondition && equipmentCondition && selectedCondition && exercise.getStringMatch(text) + }.sortedBy { exercise -> + if (text.isNotBlank()) { + exercise.title.length + } else { + exercise.title.first().code + } + } } - } - fun onEvent(event: Event) { - when (event) { - is PickerEvent.OpenGuide -> openGuide(event.exercise) - is PickerEvent.ExerciseSelected -> { - _selectedExercises.value = buildList { - if (_selectedExercises.value.contains(event.exercise)) { - addAll(_selectedExercises.value.minusElement(event.exercise)) - } else { - addAll(_selectedExercises.value) - add(event.exercise) - } - } - } - is PickerEvent.FilterSelected -> { - _filterSelected.value = !_filterSelected.value - } - is PickerEvent.FilterUsed -> { - _filterUsed.value = !_filterUsed.value - } - is PickerEvent.SelectMuscle -> { - _muscleFilter.value = if (_muscleFilter.value.contains(event.muscle)) { - _muscleFilter.value.minus(event.muscle) - } else { - _muscleFilter.value.plus(event.muscle) - } - } - is PickerEvent.DeselectMuscles -> { - _muscleFilter.value = emptyList() - } - is PickerEvent.SelectEquipment -> { - _equipmentFilter.value = if (_equipmentFilter.value.contains(event.equipment)) { - _equipmentFilter.value.minus(event.equipment) - } else { - _equipmentFilter.value.plus(event.equipment) - } - } - is PickerEvent.DeselectEquipment -> { - _equipmentFilter.value = emptyList() - } - is PickerEvent.AddExercises -> { - viewModelScope.launch { - _selectedExercises.value.forEach { exercise -> - savedStateHandle.get("session_id")?.let { sessionId -> - repo.insertSessionExercise( - SessionExercise( - parentSessionId = sessionId, - parentExerciseId = exercise.id - ) - ) + fun onEvent(event: Event) { + when (event) { + is PickerEvent.OpenGuide -> openGuide(event.exercise) + is PickerEvent.ExerciseSelected -> { + _selectedExercises.value = buildList { + if (_selectedExercises.value.contains(event.exercise)) { + addAll(_selectedExercises.value.minusElement(event.exercise)) + } else { + addAll(_selectedExercises.value) + add(event.exercise) + } + } + } + is PickerEvent.FilterSelected -> { + _filterSelected.value = !_filterSelected.value + } + is PickerEvent.FilterUsed -> { + _filterUsed.value = !_filterUsed.value + } + is PickerEvent.SelectMuscle -> { + _muscleFilter.value = if (_muscleFilter.value.contains(event.muscle)) { + _muscleFilter.value.minus(event.muscle) + } else { + _muscleFilter.value.plus(event.muscle) + } + } + is PickerEvent.DeselectMuscles -> { + _muscleFilter.value = emptyList() + } + is PickerEvent.SelectEquipment -> { + _equipmentFilter.value = if (_equipmentFilter.value.contains(event.equipment)) { + _equipmentFilter.value.minus(event.equipment) + } else { + _equipmentFilter.value.plus(event.equipment) + } + } + is PickerEvent.DeselectEquipment -> { + _equipmentFilter.value = emptyList() + } + is PickerEvent.AddExercises -> { + viewModelScope.launch { + _selectedExercises.value.forEach { exercise -> + savedStateHandle.get("session_id")?.let { sessionId -> + repo.insertSessionExercise( + SessionExercise( + parentSessionId = sessionId, + parentExerciseId = exercise.id, + ), + ) + } + } + } + } + is PickerEvent.SearchChanged -> { + _searchText.value = event.text } - } } - } - is PickerEvent.SearchChanged -> { - _searchText.value = event.text - } } - } - private val _uiEvent = Channel() - val uiEvent = _uiEvent.receiveAsFlow() + private val _uiEvent = Channel() + val uiEvent = _uiEvent.receiveAsFlow() - private fun openGuide(exercise: Exercise) { - sendUiEvent(UiEvent.OpenWebsite(url = "https://duckduckgo.com/?q=! exrx ${exercise.title}")) - } + private fun openGuide(exercise: Exercise) { + sendUiEvent(UiEvent.OpenWebsite(url = "https://duckduckgo.com/?q=! exrx ${exercise.title}")) + } - private fun sendUiEvent(event: UiEvent) { - viewModelScope.launch { - _uiEvent.send(event) + private fun sendUiEvent(event: UiEvent) { + viewModelScope.launch { + _uiEvent.send(event) + } } - } } -inline fun combine( - flow: Flow, - flow2: Flow, - flow3: Flow, - flow4: Flow, - flow5: Flow, - flow6: Flow, - flow7: Flow, - crossinline transform: suspend (T1, T2, T3, T4, T5, T6, T7) -> R +inline fun combine( + flow: Flow, + flow2: Flow, + flow3: Flow, + flow4: Flow, + flow5: Flow, + flow6: Flow, + flow7: Flow, + crossinline transform: suspend (T1, T2, T3, T4, T5, T6, T7) -> R, ): Flow { - return combine( - flow, - flow2, - flow3, - flow4, - flow5, - flow6, - flow7 - ) { args: Array<*> -> - @Suppress("UNCHECKED_CAST") - transform( - args[0] as T1, - args[1] as T2, - args[2] as T3, - args[3] as T4, - args[4] as T5, - args[5] as T6, - args[6] as T7, - ) - } -} \ No newline at end of file + return combine( + flow, + flow2, + flow3, + flow4, + flow5, + flow6, + flow7, + ) { args: Array<*> -> + @Suppress("UNCHECKED_CAST") + transform( + args[0] as T1, + args[1] as T2, + args[2] as T3, + args[3] as T4, + args[4] as T5, + args[5] as T6, + args[6] as T7, + ) + } +} diff --git a/app/src/main/java/com/example/android/january2022/ui/exercisepicker/components/EquipmentSheet.kt b/app/src/main/java/com/example/android/january2022/ui/exercisepicker/components/EquipmentSheet.kt index 7dd7b20..c2ee420 100644 --- a/app/src/main/java/com/example/android/january2022/ui/exercisepicker/components/EquipmentSheet.kt +++ b/app/src/main/java/com/example/android/january2022/ui/exercisepicker/components/EquipmentSheet.kt @@ -7,15 +7,15 @@ import com.example.android.january2022.utils.Event @Composable fun EquipmentSheet( - selectedEquipment: List, - onEvent: (Event) -> Unit + selectedEquipment: List, + onEvent: (Event) -> Unit, ) { - Sheet( - items = Equipment.getAllEquipment().sorted(), - selectedItems = selectedEquipment, - title = "Filter by Equipment", - onSelect = { onEvent(PickerEvent.SelectEquipment(it)) } - ) { - onEvent(PickerEvent.DeselectEquipment) - } -} \ No newline at end of file + Sheet( + items = Equipment.getAllEquipment().sorted(), + selectedItems = selectedEquipment, + title = "Filter by Equipment", + onSelect = { onEvent(PickerEvent.SelectEquipment(it)) }, + ) { + onEvent(PickerEvent.DeselectEquipment) + } +} diff --git a/app/src/main/java/com/example/android/january2022/ui/exercisepicker/components/ExerciseCard.kt b/app/src/main/java/com/example/android/january2022/ui/exercisepicker/components/ExerciseCard.kt index 17538d6..b8029d2 100644 --- a/app/src/main/java/com/example/android/january2022/ui/exercisepicker/components/ExerciseCard.kt +++ b/app/src/main/java/com/example/android/january2022/ui/exercisepicker/components/ExerciseCard.kt @@ -2,12 +2,25 @@ package com.example.android.january2022.ui.exercisepicker.components import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.animateDpAsState -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.defaultMinSize +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.runtime.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +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.graphics.Color @@ -24,91 +37,90 @@ import com.example.android.january2022.utils.Event @OptIn(ExperimentalMaterial3Api::class) @Composable fun ExerciseCard( - exercise: Exercise, - selected: Boolean, - onEvent: (Event) -> Unit, - onClick: () -> Unit + exercise: Exercise, + selected: Boolean, + onEvent: (Event) -> Unit, + onClick: () -> Unit, ) { + val targets = exercise.getMuscleGroups() + val equipment = exercise.equipment + val tonalElevation by animateDpAsState(targetValue = if (selected) 2.dp else 0.dp) + val indicatorColor by + animateColorAsState(if (selected) MaterialTheme.colorScheme.primary else Color.Transparent) - val targets = exercise.getMuscleGroups() - val equipment = exercise.equipment - val tonalElevation by animateDpAsState(targetValue = if (selected) 2.dp else 0.dp) - val indicatorColor by - animateColorAsState(if (selected) MaterialTheme.colorScheme.primary else Color.Transparent) + val localDensity = LocalDensity.current + var rowHeightDp by remember { mutableStateOf(0.dp) } - val localDensity = LocalDensity.current - var rowHeightDp by remember { mutableStateOf(0.dp) } + val indicatorHeight by + animateDpAsState(targetValue = if (selected) rowHeightDp else 0.dp) - val indicatorHeight by - animateDpAsState(targetValue = if (selected) rowHeightDp else 0.dp) - - Row( - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 8.dp) - .onGloballyPositioned { coordinates -> - // Set column height using the LayoutCoordinates - rowHeightDp = with(localDensity) { - coordinates.size.height - .minus(95) - .toDp() - } - }, - verticalAlignment = Alignment.CenterVertically - ) { - Surface( - color = indicatorColor, - shape = MaterialTheme.shapes.small, - modifier = Modifier - .width(3.dp) - .height(indicatorHeight) - ) {} - Spacer(modifier = Modifier.width(4.dp)) - Surface( - onClick = onClick, - modifier = Modifier - .fillMaxWidth() - .defaultMinSize(minHeight = 80.dp), - tonalElevation = tonalElevation, - shape = MaterialTheme.shapes.medium - ) { - Row( + Row( modifier = Modifier - .padding(start = 14.dp, top = 4.dp, bottom = 4.dp, end = 4.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Column( - modifier = Modifier - .padding(top = 8.dp), - verticalArrangement = Arrangement.SpaceBetween - ) { - Text( - text = exercise.title, + .fillMaxWidth() + .padding(bottom = 8.dp) + .onGloballyPositioned { coordinates -> + // Set column height using the LayoutCoordinates + rowHeightDp = with(localDensity) { + coordinates.size.height + .minus(95) + .toDp() + } + }, + verticalAlignment = Alignment.CenterVertically, + ) { + Surface( + color = indicatorColor, + shape = MaterialTheme.shapes.small, modifier = Modifier - .padding(bottom = 8.dp) - .fillMaxWidth(0.65f), - style = MaterialTheme.typography.titleMedium - ) - Row( - modifier = Modifier.padding(bottom = 4.dp) - ) { - targets.forEach { target -> - SmallPill(text = target, modifier = Modifier.padding(end = 4.dp)) - } - equipment.forEach { eq -> - SmallPill(text = eq) - } - } - } - Row( - modifier = Modifier.fillMaxHeight(), - verticalAlignment = Alignment.CenterVertically + .width(3.dp) + .height(indicatorHeight), + ) {} + Spacer(modifier = Modifier.width(4.dp)) + Surface( + onClick = onClick, + modifier = Modifier + .fillMaxWidth() + .defaultMinSize(minHeight = 80.dp), + tonalElevation = tonalElevation, + shape = MaterialTheme.shapes.medium, ) { - OpenStatsAction {} - OpenInNewAction { onEvent(PickerEvent.OpenGuide(exercise)) } + Row( + modifier = Modifier + .padding(start = 14.dp, top = 4.dp, bottom = 4.dp, end = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column( + modifier = Modifier + .padding(top = 8.dp), + verticalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = exercise.title, + modifier = Modifier + .padding(bottom = 8.dp) + .fillMaxWidth(0.65f), + style = MaterialTheme.typography.titleMedium, + ) + Row( + modifier = Modifier.padding(bottom = 4.dp), + ) { + targets.forEach { target -> + SmallPill(text = target, modifier = Modifier.padding(end = 4.dp)) + } + equipment.forEach { eq -> + SmallPill(text = eq) + } + } + } + Row( + modifier = Modifier.fillMaxHeight(), + verticalAlignment = Alignment.CenterVertically, + ) { + OpenStatsAction {} + OpenInNewAction { onEvent(PickerEvent.OpenGuide(exercise)) } + } + } } - } } - } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/example/android/january2022/ui/exercisepicker/components/MuscleButton.kt b/app/src/main/java/com/example/android/january2022/ui/exercisepicker/components/MuscleButton.kt index 8adb5e8..1c722c1 100644 --- a/app/src/main/java/com/example/android/january2022/ui/exercisepicker/components/MuscleButton.kt +++ b/app/src/main/java/com/example/android/january2022/ui/exercisepicker/components/MuscleButton.kt @@ -1,8 +1,12 @@ package com.example.android.january2022.ui.exercisepicker.components import androidx.compose.animation.animateColorAsState -import androidx.compose.foundation.layout.* -import androidx.compose.material3.* +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier @@ -12,24 +16,24 @@ import androidx.compose.ui.unit.dp @OptIn(ExperimentalMaterial3Api::class) @Composable fun MuscleButton( - muscle: String, - selected: Boolean, - onClick: () -> Unit + muscle: String, + selected: Boolean, + onClick: () -> Unit, ) { - val containerColor by animateColorAsState( - targetValue = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.23f) - ) - - Surface( - onClick = onClick, - color = containerColor, - shape = MaterialTheme.shapes.medium, - modifier = Modifier.padding(vertical = 2.dp, horizontal = 12.dp) - ) { - Text( - text = muscle.uppercase(), - modifier = Modifier.padding(8.dp).fillMaxWidth(), - textAlign = TextAlign.Center + val containerColor by animateColorAsState( + targetValue = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.23f), ) - } -} \ No newline at end of file + + Surface( + onClick = onClick, + color = containerColor, + shape = MaterialTheme.shapes.medium, + modifier = Modifier.padding(vertical = 2.dp, horizontal = 12.dp), + ) { + Text( + text = muscle.uppercase(), + modifier = Modifier.padding(8.dp).fillMaxWidth(), + textAlign = TextAlign.Center, + ) + } +} diff --git a/app/src/main/java/com/example/android/january2022/ui/exercisepicker/components/MuscleSheet.kt b/app/src/main/java/com/example/android/january2022/ui/exercisepicker/components/MuscleSheet.kt index 88d836f..68ec55d 100644 --- a/app/src/main/java/com/example/android/january2022/ui/exercisepicker/components/MuscleSheet.kt +++ b/app/src/main/java/com/example/android/january2022/ui/exercisepicker/components/MuscleSheet.kt @@ -7,15 +7,15 @@ import com.example.android.january2022.utils.Event @Composable fun MuscleSheet( - selectedMusclegroups: List, - onEvent: (Event) -> Unit + selectedMusclegroups: List, + onEvent: (Event) -> Unit, ) { - Sheet( - items = MuscleGroup.getAllMuscleGroups().sorted(), - selectedItems = selectedMusclegroups, - title = "Filter by Body-part", - onSelect = { onEvent(PickerEvent.SelectMuscle(it)) } - ) { - onEvent(PickerEvent.DeselectMuscles) - } -} \ No newline at end of file + Sheet( + items = MuscleGroup.getAllMuscleGroups().sorted(), + selectedItems = selectedMusclegroups, + title = "Filter by Body-part", + onSelect = { onEvent(PickerEvent.SelectMuscle(it)) }, + ) { + onEvent(PickerEvent.DeselectMuscles) + } +} diff --git a/app/src/main/java/com/example/android/january2022/ui/exercisepicker/components/Sheet.kt b/app/src/main/java/com/example/android/january2022/ui/exercisepicker/components/Sheet.kt index a773c63..0c76de6 100644 --- a/app/src/main/java/com/example/android/january2022/ui/exercisepicker/components/Sheet.kt +++ b/app/src/main/java/com/example/android/january2022/ui/exercisepicker/components/Sheet.kt @@ -18,43 +18,43 @@ import androidx.compose.ui.unit.dp @Composable fun Sheet( - items: List, - title: String, - selectedItems: List, - onSelect: (String) -> Unit, - onDeselectAll: () -> Unit + items: List, + title: String, + selectedItems: List, + onSelect: (String) -> Unit, + onDeselectAll: () -> Unit, ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(top = 16.dp, bottom = 20.dp) - .padding(horizontal = 16.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Text( - text = title, - textAlign = TextAlign.Center, - style = MaterialTheme.typography.titleLarge, - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 12.dp) - ) - LazyVerticalGrid( - columns = GridCells.Adaptive(120.dp), - horizontalArrangement = Arrangement.Center + Column( + modifier = Modifier + .fillMaxWidth() + .padding(top = 16.dp, bottom = 20.dp) + .padding(horizontal = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, ) { - items(items) { item -> - MuscleButton( - muscle = item, - selected = selectedItems.contains(item) - ) { onSelect(item) } - } + Text( + text = title, + textAlign = TextAlign.Center, + style = MaterialTheme.typography.titleLarge, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 12.dp), + ) + LazyVerticalGrid( + columns = GridCells.Adaptive(120.dp), + horizontalArrangement = Arrangement.Center, + ) { + items(items) { item -> + MuscleButton( + muscle = item, + selected = selectedItems.contains(item), + ) { onSelect(item) } + } + } + TextButton(onClick = { onDeselectAll() }) { + Text( + text = "Deselect All".uppercase(), + style = MaterialTheme.typography.labelLarge, + ) + } } - TextButton(onClick = { onDeselectAll() }) { - Text( - text = "Deselect All".uppercase(), - style = MaterialTheme.typography.labelLarge - ) - } - } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/example/android/january2022/ui/home/HomeEvent.kt b/app/src/main/java/com/example/android/january2022/ui/home/HomeEvent.kt index 96ddcaf..c0f118e 100644 --- a/app/src/main/java/com/example/android/january2022/ui/home/HomeEvent.kt +++ b/app/src/main/java/com/example/android/january2022/ui/home/HomeEvent.kt @@ -4,7 +4,7 @@ import com.example.android.january2022.ui.SessionWrapper import com.example.android.january2022.utils.Event sealed class HomeEvent : Event { - data class SessionClicked(val sessionWrapper: SessionWrapper) : HomeEvent() - object NewSession : HomeEvent() - object OpenSettings : HomeEvent() -} \ No newline at end of file + data class SessionClicked(val sessionWrapper: SessionWrapper) : HomeEvent() + object NewSession : HomeEvent() + object OpenSettings : HomeEvent() +} diff --git a/app/src/main/java/com/example/android/january2022/ui/home/HomeScreen.kt b/app/src/main/java/com/example/android/january2022/ui/home/HomeScreen.kt index 622bb9e..74b1c74 100644 --- a/app/src/main/java/com/example/android/january2022/ui/home/HomeScreen.kt +++ b/app/src/main/java/com/example/android/january2022/ui/home/HomeScreen.kt @@ -20,38 +20,38 @@ import com.example.android.january2022.utils.UiEvent @Composable fun HomeScreen( - onNavigate: (UiEvent.Navigate) -> Unit, - viewModel: HomeViewModel = hiltViewModel() + onNavigate: (UiEvent.Navigate) -> Unit, + viewModel: HomeViewModel = hiltViewModel(), ) { - val sessions by viewModel.sessions.collectAsState(initial = emptyList()) + val sessions by viewModel.sessions.collectAsState(initial = emptyList()) - LaunchedEffect(true) { - viewModel.uiEvent.collect { event -> - when (event) { - is UiEvent.Navigate -> onNavigate(event) - else -> Unit - } + LaunchedEffect(true) { + viewModel.uiEvent.collect { event -> + when (event) { + is UiEvent.Navigate -> onNavigate(event) + else -> Unit + } + } } - } - Scaffold( - bottomBar = { - HomeAppBar(onEvent = viewModel::onEvent) - } - ) { paddingValues -> - LazyColumn( - modifier = Modifier - .fillMaxSize() - .padding(horizontal = 8.dp, vertical = 2.dp) - ) { - item { - Spacer(modifier = Modifier.height(paddingValues.calculateTopPadding())) - } - items(items = sessions, key = { it.session.sessionId }) { session -> - SessionCard(sessionWrapper = session) { - viewModel.onEvent(HomeEvent.SessionClicked(session)) + Scaffold( + bottomBar = { + HomeAppBar(onEvent = viewModel::onEvent) + }, + ) { paddingValues -> + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 8.dp, vertical = 2.dp), + ) { + item { + Spacer(modifier = Modifier.height(paddingValues.calculateTopPadding())) + } + items(items = sessions, key = { it.session.sessionId }) { session -> + SessionCard(sessionWrapper = session) { + viewModel.onEvent(HomeEvent.SessionClicked(session)) + } + } } - } } - } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/example/android/january2022/ui/home/HomeViewModel.kt b/app/src/main/java/com/example/android/january2022/ui/home/HomeViewModel.kt index 39d3090..b3418d7 100644 --- a/app/src/main/java/com/example/android/january2022/ui/home/HomeViewModel.kt +++ b/app/src/main/java/com/example/android/january2022/ui/home/HomeViewModel.kt @@ -5,7 +5,10 @@ import androidx.lifecycle.viewModelScope import com.example.android.january2022.db.GymRepository import com.example.android.january2022.db.entities.Session import com.example.android.january2022.ui.SessionWrapper -import com.example.android.january2022.utils.* +import com.example.android.january2022.utils.Event +import com.example.android.january2022.utils.Routes +import com.example.android.january2022.utils.UiEvent +import com.example.android.january2022.utils.sortedListOfMuscleGroups import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.Channel @@ -17,44 +20,44 @@ import javax.inject.Inject @HiltViewModel class HomeViewModel @Inject constructor( - private val repo: GymRepository + private val repo: GymRepository, ) : ViewModel() { - val sessions = combine(repo.getAllSessionExercises(), repo.getAllSessions()) { sewes, sessions -> - sessions.map { session -> - val muscleGroups = sewes.filter { it.sessionExercise.parentSessionId == session.sessionId } - .sortedListOfMuscleGroups() - SessionWrapper(session, muscleGroups) + val sessions = combine(repo.getAllSessionExercises(), repo.getAllSessions()) { sewes, sessions -> + sessions.map { session -> + val muscleGroups = sewes.filter { it.sessionExercise.parentSessionId == session.sessionId } + .sortedListOfMuscleGroups() + SessionWrapper(session, muscleGroups) + } } - } - private val _uiEvent = Channel() - val uiEvent = _uiEvent.receiveAsFlow() + private val _uiEvent = Channel() + val uiEvent = _uiEvent.receiveAsFlow() - fun onEvent(event: Event) { - when (event) { - is HomeEvent.SessionClicked -> { - sendUiEvent(UiEvent.Navigate("${Routes.SESSION}/${event.sessionWrapper.session.sessionId}")) - } - is HomeEvent.OpenSettings -> { - sendUiEvent(UiEvent.Navigate(Routes.SETTINGS)) - } - is HomeEvent.NewSession -> { - viewModelScope.launch { - withContext(Dispatchers.IO) { - repo.insertSession(Session()) - val session = repo.getLastSession() - sendUiEvent(UiEvent.Navigate("${Routes.SESSION}/${session.sessionId}")) - } + fun onEvent(event: Event) { + when (event) { + is HomeEvent.SessionClicked -> { + sendUiEvent(UiEvent.Navigate("${Routes.SESSION}/${event.sessionWrapper.session.sessionId}")) + } + is HomeEvent.OpenSettings -> { + sendUiEvent(UiEvent.Navigate(Routes.SETTINGS)) + } + is HomeEvent.NewSession -> { + viewModelScope.launch { + withContext(Dispatchers.IO) { + repo.insertSession(Session()) + val session = repo.getLastSession() + sendUiEvent(UiEvent.Navigate("${Routes.SESSION}/${session.sessionId}")) + } + } + } + else -> Unit } - } - else -> Unit } - } - private fun sendUiEvent(event: UiEvent) { - viewModelScope.launch { - _uiEvent.send(event) + private fun sendUiEvent(event: UiEvent) { + viewModelScope.launch { + _uiEvent.send(event) + } } - } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/example/android/january2022/ui/home/components/HomeAppBar.kt b/app/src/main/java/com/example/android/january2022/ui/home/components/HomeAppBar.kt index 0169c94..6ce0e89 100644 --- a/app/src/main/java/com/example/android/january2022/ui/home/components/HomeAppBar.kt +++ b/app/src/main/java/com/example/android/january2022/ui/home/components/HomeAppBar.kt @@ -4,7 +4,11 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.Settings -import androidx.compose.material3.* +import androidx.compose.material3.BottomAppBar +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import com.example.android.january2022.ui.home.HomeEvent import com.example.android.january2022.ui.session.actions.ActionSpacer @@ -13,26 +17,26 @@ import com.example.android.january2022.utils.Event @Composable fun HomeAppBar( - onEvent: (Event) -> Unit + onEvent: (Event) -> Unit, ) { - BottomAppBar( - actions = { - ActionSpacerStart() - IconButton(onClick = { /*TODO*/ }) { - Icon(imageVector = Icons.Default.MoreVert, contentDescription = "Options") - } - ActionSpacer() - IconButton(onClick = { onEvent(HomeEvent.OpenSettings) }) { - Icon(imageVector = Icons.Default.Settings, contentDescription = "Settings") - } - }, - floatingActionButton = { - FloatingActionButton( - onClick = { onEvent(HomeEvent.NewSession) }, - containerColor = MaterialTheme.colorScheme.primary - ) { - Icon(Icons.Default.Add, "Add Session") - } - } - ) -} \ No newline at end of file + BottomAppBar( + actions = { + ActionSpacerStart() + IconButton(onClick = { /*TODO*/ }) { + Icon(imageVector = Icons.Default.MoreVert, contentDescription = "Options") + } + ActionSpacer() + IconButton(onClick = { onEvent(HomeEvent.OpenSettings) }) { + Icon(imageVector = Icons.Default.Settings, contentDescription = "Settings") + } + }, + floatingActionButton = { + FloatingActionButton( + onClick = { onEvent(HomeEvent.NewSession) }, + containerColor = MaterialTheme.colorScheme.primary, + ) { + Icon(Icons.Default.Add, "Add Session") + } + }, + ) +} diff --git a/app/src/main/java/com/example/android/january2022/ui/home/components/SessionCard.kt b/app/src/main/java/com/example/android/january2022/ui/home/components/SessionCard.kt index b40ad17..38cd03e 100644 --- a/app/src/main/java/com/example/android/january2022/ui/home/components/SessionCard.kt +++ b/app/src/main/java/com/example/android/january2022/ui/home/components/SessionCard.kt @@ -1,6 +1,11 @@ package com.example.android.january2022.ui.home.components -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.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredHeight import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface @@ -16,52 +21,52 @@ import com.example.android.january2022.ui.SessionWrapper @Composable fun SessionCard( - sessionWrapper: SessionWrapper, - onClick: () -> Unit + sessionWrapper: SessionWrapper, + onClick: () -> Unit, ) { - val session = sessionWrapper.session - val muscleGroups = sessionWrapper.muscleGroups - val muscleTitle by remember { - derivedStateOf { - if (muscleGroups.isNotEmpty()) muscleGroups[0].uppercase() else "" + val session = sessionWrapper.session + val muscleGroups = sessionWrapper.muscleGroups + val muscleTitle by remember { + derivedStateOf { + if (muscleGroups.isNotEmpty()) muscleGroups[0].uppercase() else "" + } } - } - val muscleSubtitle by remember { - derivedStateOf { - muscleGroups.drop(1).take(3).toString().drop(1).dropLast(1).uppercase() + val muscleSubtitle by remember { + derivedStateOf { + muscleGroups.drop(1).take(3).toString().drop(1).dropLast(1).uppercase() + } } - } - Surface( - onClick = { onClick() }, - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 8.dp) - .requiredHeight(75.dp), - shape = MaterialTheme.shapes.medium - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(12.dp), - verticalAlignment = Alignment.CenterVertically + Surface( + onClick = { onClick() }, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 8.dp) + .requiredHeight(75.dp), + shape = MaterialTheme.shapes.medium, ) { - SessionDate(session, Modifier.padding(start = 4.dp, end = 14.dp)) - Column(verticalArrangement = Arrangement.Center) { - Text( - text = muscleTitle, - style = MaterialTheme.typography.headlineSmall - ) - Row { - if (muscleSubtitle.isNotEmpty()) { - Text( - text = muscleSubtitle, - style = MaterialTheme.typography.bodySmall, - color = LocalContentColor.current.copy(alpha = 0.7f) - ) - } + Row( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + SessionDate(session, Modifier.padding(start = 4.dp, end = 14.dp)) + Column(verticalArrangement = Arrangement.Center) { + Text( + text = muscleTitle, + style = MaterialTheme.typography.headlineSmall, + ) + Row { + if (muscleSubtitle.isNotEmpty()) { + Text( + text = muscleSubtitle, + style = MaterialTheme.typography.bodySmall, + color = LocalContentColor.current.copy(alpha = 0.7f), + ) + } + } + } } - } } - } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/example/android/january2022/ui/home/components/SessionDate.kt b/app/src/main/java/com/example/android/january2022/ui/home/components/SessionDate.kt index 8a8863a..157f0af 100644 --- a/app/src/main/java/com/example/android/january2022/ui/home/components/SessionDate.kt +++ b/app/src/main/java/com/example/android/january2022/ui/home/components/SessionDate.kt @@ -1,7 +1,6 @@ package com.example.android.january2022.ui.home.components -import androidx.compose.foundation.layout.* -import androidx.compose.material3.LocalContentColor +import androidx.compose.foundation.layout.Column import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -10,9 +9,6 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.BlurEffect -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.text.font.FontWeight import com.example.android.january2022.db.entities.Session import java.time.format.TextStyle @@ -20,30 +16,30 @@ import java.util.* @Composable fun SessionDate( - session: Session, - modifier: Modifier = Modifier + session: Session, + modifier: Modifier = Modifier, ) { - val month by remember { - derivedStateOf { - session.start.month.getDisplayName(TextStyle.SHORT, Locale.ENGLISH) + val month by remember { + derivedStateOf { + session.start.month.getDisplayName(TextStyle.SHORT, Locale.ENGLISH) + } } - } - val day by remember { derivedStateOf { session.start.dayOfMonth.toString() } } + val day by remember { derivedStateOf { session.start.dayOfMonth.toString() } } - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = modifier - ) { - DateText(text = month) - DateText(text = day) - } + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = modifier, + ) { + DateText(text = month) + DateText(text = day) + } } @Composable fun DateText(text: String) { - Text( - text = text, - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.SemiBold - ) -} \ No newline at end of file + Text( + text = text, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + ) +} diff --git a/app/src/main/java/com/example/android/january2022/ui/modalbottomsheet/ModalBottomSheet.kt b/app/src/main/java/com/example/android/january2022/ui/modalbottomsheet/ModalBottomSheet.kt index f2f5ce7..8c3cd62 100644 --- a/app/src/main/java/com/example/android/january2022/ui/modalbottomsheet/ModalBottomSheet.kt +++ b/app/src/main/java/com/example/android/january2022/ui/modalbottomsheet/ModalBottomSheet.kt @@ -22,12 +22,23 @@ import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.Canvas import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.gestures.detectTapGestures -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.offset import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.contentColorFor -import androidx.compose.runtime.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +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.Saver import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Modifier @@ -37,7 +48,12 @@ import androidx.compose.ui.graphics.isSpecified import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.onGloballyPositioned -import androidx.compose.ui.semantics.* +import androidx.compose.ui.semantics.collapse +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.dismiss +import androidx.compose.ui.semantics.expand +import androidx.compose.ui.semantics.onClick +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp @@ -52,21 +68,21 @@ import kotlin.math.roundToInt */ @ExperimentalMaterial3Api enum class ModalBottomSheetValue { - /** - * The bottom sheet is not visible. - */ - Hidden, + /** + * The bottom sheet is not visible. + */ + Hidden, - /** - * The bottom sheet is visible at full height. - */ - Expanded, + /** + * The bottom sheet is visible at full height. + */ + Expanded, - /** - * The bottom sheet is partially visible at 50% of the screen height. This state is only - * enabled if the height of the bottom sheet is more than 50% of the screen height. - */ - HalfExpanded + /** + * The bottom sheet is partially visible at 50% of the screen height. This state is only + * enabled if the height of the bottom sheet is more than 50% of the screen height. + */ + HalfExpanded, } /** @@ -78,85 +94,88 @@ enum class ModalBottomSheetValue { */ @ExperimentalMaterial3Api class ModalBottomSheetState( - initialValue: ModalBottomSheetValue, - animationSpec: AnimationSpec = SwipeableDefaults.AnimationSpec, - confirmStateChange: (ModalBottomSheetValue) -> Boolean = { true } + initialValue: ModalBottomSheetValue, + animationSpec: AnimationSpec = SwipeableDefaults.AnimationSpec, + confirmStateChange: (ModalBottomSheetValue) -> Boolean = { true }, ) : SwipeableState( - initialValue = initialValue, - animationSpec = animationSpec, - confirmStateChange = confirmStateChange + initialValue = initialValue, + animationSpec = animationSpec, + confirmStateChange = confirmStateChange, ) { - /** - * Whether the bottom sheet is visible. - */ - val isVisible: Boolean - get() = currentValue != ModalBottomSheetValue.Hidden - - internal val isHalfExpandedEnabled: Boolean - get() = anchors.values.contains(ModalBottomSheetValue.HalfExpanded) + /** + * Whether the bottom sheet is visible. + */ + val isVisible: Boolean + get() = currentValue != ModalBottomSheetValue.Hidden - /** - * Show the bottom sheet with animation and suspend until it's shown. If half expand is - * enabled, the bottom sheet will be half expanded. Otherwise it will be fully expanded. - * - * @throws [CancellationException] if the animation is interrupted - */ - suspend fun show() { - val targetValue = - if (isHalfExpandedEnabled) ModalBottomSheetValue.HalfExpanded - else ModalBottomSheetValue.Expanded - animateTo(targetValue = targetValue) - } + internal val isHalfExpandedEnabled: Boolean + get() = anchors.values.contains(ModalBottomSheetValue.HalfExpanded) - /** - * Half expand the bottom sheet if half expand is enabled with animation and suspend until it - * animation is complete or cancelled - * - * @throws [CancellationException] if the animation is interrupted - */ - internal suspend fun halfExpand() { - if (!isHalfExpandedEnabled) { - return + /** + * Show the bottom sheet with animation and suspend until it's shown. If half expand is + * enabled, the bottom sheet will be half expanded. Otherwise it will be fully expanded. + * + * @throws [CancellationException] if the animation is interrupted + */ + suspend fun show() { + val targetValue = + if (isHalfExpandedEnabled) { + ModalBottomSheetValue.HalfExpanded + } else { + ModalBottomSheetValue.Expanded + } + animateTo(targetValue = targetValue) } - animateTo(ModalBottomSheetValue.HalfExpanded) - } - /** - * Fully expand the bottom sheet with animation and suspend until it if fully expanded or - * animation has been cancelled. - * * - * @throws [CancellationException] if the animation is interrupted - */ - internal suspend fun expand() = animateTo(ModalBottomSheetValue.Expanded) - - /** - * Hide the bottom sheet with animation and suspend until it if fully hidden or animation has - * been cancelled. - * - * @throws [CancellationException] if the animation is interrupted - */ - suspend fun hide() = animateTo(ModalBottomSheetValue.Hidden) + /** + * Half expand the bottom sheet if half expand is enabled with animation and suspend until it + * animation is complete or cancelled + * + * @throws [CancellationException] if the animation is interrupted + */ + internal suspend fun halfExpand() { + if (!isHalfExpandedEnabled) { + return + } + animateTo(ModalBottomSheetValue.HalfExpanded) + } - internal val nestedScrollConnection = this.PreUpPostDownNestedScrollConnection + /** + * Fully expand the bottom sheet with animation and suspend until it if fully expanded or + * animation has been cancelled. + * * + * @throws [CancellationException] if the animation is interrupted + */ + internal suspend fun expand() = animateTo(ModalBottomSheetValue.Expanded) - companion object { /** - * The default [Saver] implementation for [ModalBottomSheetState]. + * Hide the bottom sheet with animation and suspend until it if fully hidden or animation has + * been cancelled. + * + * @throws [CancellationException] if the animation is interrupted */ - fun Saver( - animationSpec: AnimationSpec, - confirmStateChange: (ModalBottomSheetValue) -> Boolean - ): Saver = Saver( - save = { it.currentValue }, - restore = { - ModalBottomSheetState( - initialValue = it, - animationSpec = animationSpec, - confirmStateChange = confirmStateChange + suspend fun hide() = animateTo(ModalBottomSheetValue.Hidden) + + internal val nestedScrollConnection = this.PreUpPostDownNestedScrollConnection + + companion object { + /** + * The default [Saver] implementation for [ModalBottomSheetState]. + */ + fun Saver( + animationSpec: AnimationSpec, + confirmStateChange: (ModalBottomSheetValue) -> Boolean, + ): Saver = Saver( + save = { it.currentValue }, + restore = { + ModalBottomSheetState( + initialValue = it, + animationSpec = animationSpec, + confirmStateChange = confirmStateChange, + ) + }, ) - } - ) - } + } } /** @@ -169,22 +188,22 @@ class ModalBottomSheetState( @Composable @ExperimentalMaterial3Api fun rememberModalBottomSheetState( - initialValue: ModalBottomSheetValue, - animationSpec: AnimationSpec = SwipeableDefaults.AnimationSpec, - confirmStateChange: (ModalBottomSheetValue) -> Boolean = { true } + initialValue: ModalBottomSheetValue, + animationSpec: AnimationSpec = SwipeableDefaults.AnimationSpec, + confirmStateChange: (ModalBottomSheetValue) -> Boolean = { true }, ): ModalBottomSheetState { - return rememberSaveable( - saver = ModalBottomSheetState.Saver( - animationSpec = animationSpec, - confirmStateChange = confirmStateChange - ) - ) { - ModalBottomSheetState( - initialValue = initialValue, - animationSpec = animationSpec, - confirmStateChange = confirmStateChange - ) - } + return rememberSaveable( + saver = ModalBottomSheetState.Saver( + animationSpec = animationSpec, + confirmStateChange = confirmStateChange, + ), + ) { + ModalBottomSheetState( + initialValue = initialValue, + animationSpec = animationSpec, + confirmStateChange = confirmStateChange, + ) + } } /** @@ -218,152 +237,152 @@ fun rememberModalBottomSheetState( @Composable @ExperimentalMaterial3Api fun ModalBottomSheetLayout( - sheetContent: @Composable ColumnScope.() -> Unit, - modifier: Modifier = Modifier, - sheetState: ModalBottomSheetState = - rememberModalBottomSheetState(ModalBottomSheetValue.Hidden), - sheetShape: Shape = MaterialTheme.shapes.large, - sheetElevation: Dp = ModalBottomSheetDefaults.Elevation, - sheetBackgroundColor: Color = MaterialTheme.colorScheme.surface, - sheetContentColor: Color = contentColorFor(sheetBackgroundColor), - scrimColor: Color = ModalBottomSheetDefaults.scrimColor, - content: @Composable () -> Unit + sheetContent: @Composable ColumnScope.() -> Unit, + modifier: Modifier = Modifier, + sheetState: ModalBottomSheetState = + rememberModalBottomSheetState(ModalBottomSheetValue.Hidden), + sheetShape: Shape = MaterialTheme.shapes.large, + sheetElevation: Dp = ModalBottomSheetDefaults.Elevation, + sheetBackgroundColor: Color = MaterialTheme.colorScheme.surface, + sheetContentColor: Color = contentColorFor(sheetBackgroundColor), + scrimColor: Color = ModalBottomSheetDefaults.scrimColor, + content: @Composable () -> Unit, ) { - val scope = rememberCoroutineScope() - BoxWithConstraints(modifier) { - val fullHeight = constraints.maxHeight.toFloat() - val sheetHeightState = remember { mutableStateOf(null) } - Box(Modifier.fillMaxSize()) { - content() - Scrim( - color = scrimColor, - onDismiss = { - if (sheetState.confirmStateChange(ModalBottomSheetValue.Hidden)) { - scope.launch { sheetState.hide() } - } - }, - visible = sheetState.targetValue != ModalBottomSheetValue.Hidden - ) - } - Surface( - modifier = Modifier - .fillMaxWidth() - .nestedScroll(sheetState.nestedScrollConnection) - .offset { - val y = if (sheetState.anchors.isEmpty()) { - // if we don't know our anchors yet, render the sheet as hidden - fullHeight.roundToInt() - } else { - // if we do know our anchors, respect them - sheetState.offset.value.roundToInt() - } - IntOffset(0, y) - } - .bottomSheetSwipeable(sheetState, fullHeight, sheetHeightState) - .onGloballyPositioned { - sheetHeightState.value = it.size.height.toFloat() + val scope = rememberCoroutineScope() + BoxWithConstraints(modifier) { + val fullHeight = constraints.maxHeight.toFloat() + val sheetHeightState = remember { mutableStateOf(null) } + Box(Modifier.fillMaxSize()) { + content() + Scrim( + color = scrimColor, + onDismiss = { + if (sheetState.confirmStateChange(ModalBottomSheetValue.Hidden)) { + scope.launch { sheetState.hide() } + } + }, + visible = sheetState.targetValue != ModalBottomSheetValue.Hidden, + ) } - .semantics { - if (sheetState.isVisible) { - dismiss { - if (sheetState.confirmStateChange(ModalBottomSheetValue.Hidden)) { - scope.launch { sheetState.hide() } - } - true - } - if (sheetState.currentValue == ModalBottomSheetValue.HalfExpanded) { - expand { - if (sheetState.confirmStateChange(ModalBottomSheetValue.Expanded)) { - scope.launch { sheetState.expand() } + Surface( + modifier = Modifier + .fillMaxWidth() + .nestedScroll(sheetState.nestedScrollConnection) + .offset { + val y = if (sheetState.anchors.isEmpty()) { + // if we don't know our anchors yet, render the sheet as hidden + fullHeight.roundToInt() + } else { + // if we do know our anchors, respect them + sheetState.offset.value.roundToInt() + } + IntOffset(0, y) } - true - } - } else if (sheetState.isHalfExpandedEnabled) { - collapse { - if (sheetState.confirmStateChange(ModalBottomSheetValue.HalfExpanded)) { - scope.launch { sheetState.halfExpand() } + .bottomSheetSwipeable(sheetState, fullHeight, sheetHeightState) + .onGloballyPositioned { + sheetHeightState.value = it.size.height.toFloat() } - true - } - } - } - }, - shape = sheetShape, - tonalElevation = sheetElevation, - color = sheetBackgroundColor, - contentColor = sheetContentColor - ) { - Column(content = sheetContent) + .semantics { + if (sheetState.isVisible) { + dismiss { + if (sheetState.confirmStateChange(ModalBottomSheetValue.Hidden)) { + scope.launch { sheetState.hide() } + } + true + } + if (sheetState.currentValue == ModalBottomSheetValue.HalfExpanded) { + expand { + if (sheetState.confirmStateChange(ModalBottomSheetValue.Expanded)) { + scope.launch { sheetState.expand() } + } + true + } + } else if (sheetState.isHalfExpandedEnabled) { + collapse { + if (sheetState.confirmStateChange(ModalBottomSheetValue.HalfExpanded)) { + scope.launch { sheetState.halfExpand() } + } + true + } + } + } + }, + shape = sheetShape, + tonalElevation = sheetElevation, + color = sheetBackgroundColor, + contentColor = sheetContentColor, + ) { + Column(content = sheetContent) + } } - } } @Suppress("ModifierInspectorInfo") @OptIn(ExperimentalMaterial3Api::class) private fun Modifier.bottomSheetSwipeable( - sheetState: ModalBottomSheetState, - fullHeight: Float, - sheetHeightState: State + sheetState: ModalBottomSheetState, + fullHeight: Float, + sheetHeightState: State, ): Modifier { - val sheetHeight = sheetHeightState.value - val modifier = if (sheetHeight != null) { - val anchors = if (sheetHeight < fullHeight / 2) { - mapOf( - fullHeight to ModalBottomSheetValue.Hidden, - fullHeight - sheetHeight to ModalBottomSheetValue.Expanded - ) + val sheetHeight = sheetHeightState.value + val modifier = if (sheetHeight != null) { + val anchors = if (sheetHeight < fullHeight / 2) { + mapOf( + fullHeight to ModalBottomSheetValue.Hidden, + fullHeight - sheetHeight to ModalBottomSheetValue.Expanded, + ) + } else { + mapOf( + fullHeight to ModalBottomSheetValue.Hidden, + fullHeight / 2 to ModalBottomSheetValue.HalfExpanded, + max(0f, fullHeight - sheetHeight) to ModalBottomSheetValue.Expanded, + ) + } + Modifier.swipeable( + state = sheetState, + anchors = anchors, + orientation = Orientation.Vertical, + enabled = sheetState.currentValue != ModalBottomSheetValue.Hidden, + resistance = null, + ) } else { - mapOf( - fullHeight to ModalBottomSheetValue.Hidden, - fullHeight / 2 to ModalBottomSheetValue.HalfExpanded, - max(0f, fullHeight - sheetHeight) to ModalBottomSheetValue.Expanded - ) + Modifier } - Modifier.swipeable( - state = sheetState, - anchors = anchors, - orientation = Orientation.Vertical, - enabled = sheetState.currentValue != ModalBottomSheetValue.Hidden, - resistance = null - ) - } else { - Modifier - } - return this.then(modifier) + return this.then(modifier) } @Composable private fun Scrim( - color: Color, - onDismiss: () -> Unit, - visible: Boolean + color: Color, + onDismiss: () -> Unit, + visible: Boolean, ) { - if (color.isSpecified) { - val alpha by animateFloatAsState( - targetValue = if (visible) 1f else 0f, - animationSpec = TweenSpec() - ) - val closeSheet = getString(CloseSheet) - val dismissModifier = if (visible) { - Modifier - .pointerInput(onDismiss) { detectTapGestures { onDismiss() } } - .semantics(mergeDescendants = true) { - contentDescription = closeSheet - onClick { onDismiss(); true } + if (color.isSpecified) { + val alpha by animateFloatAsState( + targetValue = if (visible) 1f else 0f, + animationSpec = TweenSpec(), + ) + val closeSheet = getString(CloseSheet) + val dismissModifier = if (visible) { + Modifier + .pointerInput(onDismiss) { detectTapGestures { onDismiss() } } + .semantics(mergeDescendants = true) { + contentDescription = closeSheet + onClick { onDismiss(); true } + } + } else { + Modifier } - } else { - Modifier - } - Canvas( - Modifier - .fillMaxSize() - .then(dismissModifier) - ) { - drawRect(color = color, alpha = alpha) + Canvas( + Modifier + .fillMaxSize() + .then(dismissModifier), + ) { + drawRect(color = color, alpha = alpha) + } } - } } /** @@ -371,15 +390,15 @@ private fun Scrim( */ object ModalBottomSheetDefaults { - /** - * The default elevation used by [ModalBottomSheetLayout]. - */ - val Elevation = 2.dp + /** + * The default elevation used by [ModalBottomSheetLayout]. + */ + val Elevation = 2.dp - /** - * The default scrim color used by [ModalBottomSheetLayout]. - */ - val scrimColor: Color - @Composable - get() = MaterialTheme.colorScheme.scrim.copy(alpha = 0.4f) -} \ No newline at end of file + /** + * The default scrim color used by [ModalBottomSheetLayout]. + */ + val scrimColor: Color + @Composable + get() = MaterialTheme.colorScheme.scrim.copy(alpha = 0.4f) +} diff --git a/app/src/main/java/com/example/android/january2022/ui/modalbottomsheet/Strings.kt b/app/src/main/java/com/example/android/january2022/ui/modalbottomsheet/Strings.kt index 1e83f58..8cfaca6 100644 --- a/app/src/main/java/com/example/android/january2022/ui/modalbottomsheet/Strings.kt +++ b/app/src/main/java/com/example/android/january2022/ui/modalbottomsheet/Strings.kt @@ -1,6 +1,5 @@ package com.example.android.january2022.ui.modalbottomsheet - /* * Copyright 2021 The Android Open Source Project * @@ -26,29 +25,29 @@ import androidx.compose.ui.platform.LocalContext @Immutable @kotlin.jvm.JvmInline value class Strings private constructor(@Suppress("unused") private val value: Int) { - companion object { - val NavigationMenu = Strings(0) - val CloseDrawer = Strings(1) - val CloseSheet = Strings(2) - val DefaultErrorMessage = Strings(3) - val ExposedDropdownMenu = Strings(4) - val SliderRangeStart = Strings(5) - val SliderRangeEnd = Strings(6) - } + companion object { + val NavigationMenu = Strings(0) + val CloseDrawer = Strings(1) + val CloseSheet = Strings(2) + val DefaultErrorMessage = Strings(3) + val ExposedDropdownMenu = Strings(4) + val SliderRangeStart = Strings(5) + val SliderRangeEnd = Strings(6) + } } @Composable fun getString(string: Strings): String { - LocalConfiguration.current - val resources = LocalContext.current.resources - return when (string) { - Strings.NavigationMenu -> resources.getString(R.string.navigation_menu) - Strings.CloseDrawer -> resources.getString(R.string.close_drawer) - Strings.CloseSheet -> resources.getString(R.string.close_sheet) - Strings.DefaultErrorMessage -> resources.getString(R.string.default_error_message) - Strings.ExposedDropdownMenu -> resources.getString(R.string.dropdown_menu) - Strings.SliderRangeStart -> resources.getString(R.string.range_start) - Strings.SliderRangeEnd -> resources.getString(R.string.range_end) - else -> "" - } -} \ No newline at end of file + LocalConfiguration.current + val resources = LocalContext.current.resources + return when (string) { + Strings.NavigationMenu -> resources.getString(R.string.navigation_menu) + Strings.CloseDrawer -> resources.getString(R.string.close_drawer) + Strings.CloseSheet -> resources.getString(R.string.close_sheet) + Strings.DefaultErrorMessage -> resources.getString(R.string.default_error_message) + Strings.ExposedDropdownMenu -> resources.getString(R.string.dropdown_menu) + Strings.SliderRangeStart -> resources.getString(R.string.range_start) + Strings.SliderRangeEnd -> resources.getString(R.string.range_end) + else -> "" + } +} diff --git a/app/src/main/java/com/example/android/january2022/ui/modalbottomsheet/Swipeable.kt b/app/src/main/java/com/example/android/january2022/ui/modalbottomsheet/Swipeable.kt index 37aa94d..b8be359 100644 --- a/app/src/main/java/com/example/android/january2022/ui/modalbottomsheet/Swipeable.kt +++ b/app/src/main/java/com/example/android/january2022/ui/modalbottomsheet/Swipeable.kt @@ -1,6 +1,5 @@ package com.example.android.january2022.ui.modalbottomsheet - /* * Copyright 2020 The Android Open Source Project * @@ -25,9 +24,18 @@ import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.gestures.draggable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.runtime.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.State +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.Saver import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Modifier import androidx.compose.ui.composed import androidx.compose.ui.geometry.Offset @@ -67,342 +75,345 @@ import kotlin.math.sin @Stable @ExperimentalMaterial3Api open class SwipeableState( - initialValue: T, - internal val animationSpec: AnimationSpec = SwipeableDefaults.AnimationSpec, - internal val confirmStateChange: (newValue: T) -> Boolean = { true } + initialValue: T, + internal val animationSpec: AnimationSpec = SwipeableDefaults.AnimationSpec, + internal val confirmStateChange: (newValue: T) -> Boolean = { true }, ) { - /** - * The current value of the state. - * - * If no swipe or animation is in progress, this corresponds to the anchor at which the - * [swipeable] is currently settled. If a swipe or animation is in progress, this corresponds - * the last anchor at which the [swipeable] was settled before the swipe or animation started. - */ - var currentValue: T by mutableStateOf(initialValue) - private set - - /** - * Whether the state is currently animating. - */ - var isAnimationRunning: Boolean by mutableStateOf(false) - private set - - /** - * The current position (in pixels) of the [swipeable]. - * - * You should use this state to offset your content accordingly. The recommended way is to - * use `Modifier.offsetPx`. This includes the resistance by default, if resistance is enabled. - */ - val offset: State get() = offsetState - - /** - * The amount by which the [swipeable] has been swiped past its bounds. - */ - val overflow: State get() = overflowState - - // Use `Float.NaN` as a placeholder while the state is uninitialised. - private val offsetState = mutableStateOf(0f) - private val overflowState = mutableStateOf(0f) - - // the source of truth for the "real"(non ui) position - // basically position in bounds + overflow - private val absoluteOffset = mutableStateOf(0f) - - // current animation target, if animating, otherwise null - private val animationTarget = mutableStateOf(null) - - internal var anchors by mutableStateOf(emptyMap()) - - private val latestNonEmptyAnchorsFlow: Flow> = - snapshotFlow { anchors } - .filter { it.isNotEmpty() } - .take(1) - - internal var minBound = Float.NEGATIVE_INFINITY - internal var maxBound = Float.POSITIVE_INFINITY - - internal fun ensureInit(newAnchors: Map) { - if (anchors.isEmpty()) { - // need to do initial synchronization synchronously :( - val initialOffset = newAnchors.getOffset(currentValue) - requireNotNull(initialOffset) { - "The initial value must have an associated anchor." - } - offsetState.value = initialOffset - absoluteOffset.value = initialOffset - } - } - - internal suspend fun processNewAnchors( - oldAnchors: Map, - newAnchors: Map - ) { - if (oldAnchors.isEmpty()) { - // If this is the first time that we receive anchors, then we need to initialise - // the state so we snap to the offset associated to the initial value. - minBound = newAnchors.keys.minOrNull()!! - maxBound = newAnchors.keys.maxOrNull()!! - val initialOffset = newAnchors.getOffset(currentValue) - requireNotNull(initialOffset) { - "The initial value must have an associated anchor." - } - snapInternalToOffset(initialOffset) - } else if (newAnchors != oldAnchors) { - // If we have received new anchors, then the offset of the current value might - // have changed, so we need to animate to the new offset. If the current value - // has been removed from the anchors then we animate to the closest anchor - // instead. Note that this stops any ongoing animation. - minBound = Float.NEGATIVE_INFINITY - maxBound = Float.POSITIVE_INFINITY - val animationTargetValue = animationTarget.value - // if we're in the animation already, let's find it a new home - val targetOffset = if (animationTargetValue != null) { - // first, try to map old state to the new state - val oldState = oldAnchors[animationTargetValue] - val newState = newAnchors.getOffset(oldState) - // return new state if exists, or find the closes one among new anchors - newState ?: newAnchors.keys.minByOrNull { abs(it - animationTargetValue) }!! - } else { - // we're not animating, proceed by finding the new anchors for an old value - val actualOldValue = oldAnchors[offset.value] - val value = if (actualOldValue == currentValue) currentValue else actualOldValue - newAnchors.getOffset(value) ?: newAnchors - .keys.minByOrNull { abs(it - offset.value) }!! - } - try { - animateInternalToOffset(targetOffset, animationSpec) - } catch (c: CancellationException) { - // If the animation was interrupted for any reason, snap as a last resort. - snapInternalToOffset(targetOffset) - } finally { - currentValue = newAnchors.getValue(targetOffset) - minBound = newAnchors.keys.minOrNull()!! - maxBound = newAnchors.keys.maxOrNull()!! - } + /** + * The current value of the state. + * + * If no swipe or animation is in progress, this corresponds to the anchor at which the + * [swipeable] is currently settled. If a swipe or animation is in progress, this corresponds + * the last anchor at which the [swipeable] was settled before the swipe or animation started. + */ + var currentValue: T by mutableStateOf(initialValue) + private set + + /** + * Whether the state is currently animating. + */ + var isAnimationRunning: Boolean by mutableStateOf(false) + private set + + /** + * The current position (in pixels) of the [swipeable]. + * + * You should use this state to offset your content accordingly. The recommended way is to + * use `Modifier.offsetPx`. This includes the resistance by default, if resistance is enabled. + */ + val offset: State get() = offsetState + + /** + * The amount by which the [swipeable] has been swiped past its bounds. + */ + val overflow: State get() = overflowState + + // Use `Float.NaN` as a placeholder while the state is uninitialised. + private val offsetState = mutableFloatStateOf(0f) + private val overflowState = mutableFloatStateOf(0f) + + // the source of truth for the "real"(non ui) position + // basically position in bounds + overflow + private val absoluteOffset = mutableFloatStateOf(0f) + + // current animation target, if animating, otherwise null + private val animationTarget = mutableStateOf(null) + + internal var anchors by mutableStateOf(emptyMap()) + + private val latestNonEmptyAnchorsFlow: Flow> = + snapshotFlow { anchors } + .filter { it.isNotEmpty() } + .take(1) + + internal var minBound = Float.NEGATIVE_INFINITY + internal var maxBound = Float.POSITIVE_INFINITY + + internal fun ensureInit(newAnchors: Map) { + if (anchors.isEmpty()) { + // need to do initial synchronization synchronously :( + val initialOffset = newAnchors.getOffset(currentValue) + requireNotNull(initialOffset) { + "The initial value must have an associated anchor." + } + offsetState.value = initialOffset + absoluteOffset.value = initialOffset + } } - } - internal var thresholds: (Float, Float) -> Float by mutableStateOf({ _, _ -> 0f }) + internal suspend fun processNewAnchors( + oldAnchors: Map, + newAnchors: Map, + ) { + if (oldAnchors.isEmpty()) { + // If this is the first time that we receive anchors, then we need to initialise + // the state so we snap to the offset associated to the initial value. + minBound = newAnchors.keys.minOrNull()!! + maxBound = newAnchors.keys.maxOrNull()!! + val initialOffset = newAnchors.getOffset(currentValue) + requireNotNull(initialOffset) { + "The initial value must have an associated anchor." + } + snapInternalToOffset(initialOffset) + } else if (newAnchors != oldAnchors) { + // If we have received new anchors, then the offset of the current value might + // have changed, so we need to animate to the new offset. If the current value + // has been removed from the anchors then we animate to the closest anchor + // instead. Note that this stops any ongoing animation. + minBound = Float.NEGATIVE_INFINITY + maxBound = Float.POSITIVE_INFINITY + val animationTargetValue = animationTarget.value + // if we're in the animation already, let's find it a new home + val targetOffset = if (animationTargetValue != null) { + // first, try to map old state to the new state + val oldState = oldAnchors[animationTargetValue] + val newState = newAnchors.getOffset(oldState) + // return new state if exists, or find the closes one among new anchors + newState ?: newAnchors.keys.minByOrNull { abs(it - animationTargetValue) }!! + } else { + // we're not animating, proceed by finding the new anchors for an old value + val actualOldValue = oldAnchors[offset.value] + val value = if (actualOldValue == currentValue) currentValue else actualOldValue + newAnchors.getOffset(value) ?: newAnchors + .keys.minByOrNull { abs(it - offset.value) }!! + } + try { + animateInternalToOffset(targetOffset, animationSpec) + } catch (c: CancellationException) { + // If the animation was interrupted for any reason, snap as a last resort. + snapInternalToOffset(targetOffset) + } finally { + currentValue = newAnchors.getValue(targetOffset) + minBound = newAnchors.keys.minOrNull()!! + maxBound = newAnchors.keys.maxOrNull()!! + } + } + } - internal var velocityThreshold by mutableStateOf(0f) + internal var thresholds: (Float, Float) -> Float by mutableStateOf({ _, _ -> 0f }) - internal var resistance: ResistanceConfig? by mutableStateOf(null) + internal var velocityThreshold by mutableFloatStateOf(0f) - internal val draggableState = DraggableState { - val newAbsolute = absoluteOffset.value + it - val clamped = newAbsolute.coerceIn(minBound, maxBound) - val overflow = newAbsolute - clamped - val resistanceDelta = resistance?.computeResistance(overflow) ?: 0f - offsetState.value = clamped + resistanceDelta - overflowState.value = overflow - absoluteOffset.value = newAbsolute - } + internal var resistance: ResistanceConfig? by mutableStateOf(null) - private suspend fun snapInternalToOffset(target: Float) { - draggableState.drag { - dragBy(target - absoluteOffset.value) + internal val draggableState = DraggableState { + val newAbsolute = absoluteOffset.value + it + val clamped = newAbsolute.coerceIn(minBound, maxBound) + val overflow = newAbsolute - clamped + val resistanceDelta = resistance?.computeResistance(overflow) ?: 0f + offsetState.value = clamped + resistanceDelta + overflowState.value = overflow + absoluteOffset.value = newAbsolute } - } - - private suspend fun animateInternalToOffset(target: Float, spec: AnimationSpec) { - draggableState.drag { - var prevValue = absoluteOffset.value - animationTarget.value = target - isAnimationRunning = true - try { - Animatable(prevValue).animateTo(target, spec) { - dragBy(this.value - prevValue) - prevValue = this.value + + private suspend fun snapInternalToOffset(target: Float) { + draggableState.drag { + dragBy(target - absoluteOffset.value) } - } finally { - animationTarget.value = null - isAnimationRunning = false - } } - } - - /** - * The target value of the state. - * - * If a swipe is in progress, this is the value that the [swipeable] would animate to if the - * swipe finished. If an animation is running, this is the target value of that animation. - * Finally, if no swipe or animation is in progress, this is the same as the [currentValue]. - */ - @ExperimentalMaterial3Api - val targetValue: T - get() { - // TODO(calintat): Track current velocity (b/149549482) and use that here. - val target = animationTarget.value ?: computeTarget( - offset = offset.value, - lastValue = anchors.getOffset(currentValue) ?: offset.value, - anchors = anchors.keys, - thresholds = thresholds, - velocity = 0f, - velocityThreshold = Float.POSITIVE_INFINITY - ) - return anchors[target] ?: currentValue + + private suspend fun animateInternalToOffset(target: Float, spec: AnimationSpec) { + draggableState.drag { + var prevValue = absoluteOffset.value + animationTarget.value = target + isAnimationRunning = true + try { + Animatable(prevValue).animateTo(target, spec) { + dragBy(this.value - prevValue) + prevValue = this.value + } + } finally { + animationTarget.value = null + isAnimationRunning = false + } + } } - /** - * Information about the ongoing swipe or animation, if any. See [SwipeProgress] for details. - * - * If no swipe or animation is in progress, this returns `SwipeProgress(value, value, 1f)`. - */ - @ExperimentalMaterial3Api - val progress: SwipeProgress - get() { - val bounds = findBounds(offset.value, anchors.keys) - val from: T - val to: T - val fraction: Float - when (bounds.size) { - 0 -> { - from = currentValue - to = currentValue - fraction = 1f + /** + * The target value of the state. + * + * If a swipe is in progress, this is the value that the [swipeable] would animate to if the + * swipe finished. If an animation is running, this is the target value of that animation. + * Finally, if no swipe or animation is in progress, this is the same as the [currentValue]. + */ + @ExperimentalMaterial3Api + val targetValue: T + get() { + // TODO(calintat): Track current velocity (b/149549482) and use that here. + val target = animationTarget.value ?: computeTarget( + offset = offset.value, + lastValue = anchors.getOffset(currentValue) ?: offset.value, + anchors = anchors.keys, + thresholds = thresholds, + velocity = 0f, + velocityThreshold = Float.POSITIVE_INFINITY, + ) + return anchors[target] ?: currentValue } - 1 -> { - from = anchors.getValue(bounds[0]) - to = anchors.getValue(bounds[0]) - fraction = 1f + + /** + * Information about the ongoing swipe or animation, if any. See [SwipeProgress] for details. + * + * If no swipe or animation is in progress, this returns `SwipeProgress(value, value, 1f)`. + */ + @ExperimentalMaterial3Api + val progress: SwipeProgress + get() { + val bounds = findBounds(offset.value, anchors.keys) + val from: T + val to: T + val fraction: Float + when (bounds.size) { + 0 -> { + from = currentValue + to = currentValue + fraction = 1f + } + 1 -> { + from = anchors.getValue(bounds[0]) + to = anchors.getValue(bounds[0]) + fraction = 1f + } + else -> { + val (a, b) = + if (direction > 0f) { + bounds[0] to bounds[1] + } else { + bounds[1] to bounds[0] + } + from = anchors.getValue(a) + to = anchors.getValue(b) + fraction = (offset.value - a) / (b - a) + } + } + return SwipeProgress(from, to, fraction) } - else -> { - val (a, b) = - if (direction > 0f) { - bounds[0] to bounds[1] - } else { - bounds[1] to bounds[0] + + /** + * The direction in which the [swipeable] is moving, relative to the current [currentValue]. + * + * This will be either 1f if it is is moving from left to right or top to bottom, -1f if it is + * moving from right to left or bottom to top, or 0f if no swipe or animation is in progress. + */ + @ExperimentalMaterial3Api + val direction: Float + get() = anchors.getOffset(currentValue)?.let { sign(offset.value - it) } ?: 0f + + /** + * Set the state without any animation and suspend until it's set + * + * @param targetValue The new target value to set [currentValue] to. + */ + @ExperimentalMaterial3Api + suspend fun snapTo(targetValue: T) { + latestNonEmptyAnchorsFlow.collect { anchors -> + val targetOffset = anchors.getOffset(targetValue) + requireNotNull(targetOffset) { + "The target value must have an associated anchor." } - from = anchors.getValue(a) - to = anchors.getValue(b) - fraction = (offset.value - a) / (b - a) + snapInternalToOffset(targetOffset) + currentValue = targetValue } - } - return SwipeProgress(from, to, fraction) } - /** - * The direction in which the [swipeable] is moving, relative to the current [currentValue]. - * - * This will be either 1f if it is is moving from left to right or top to bottom, -1f if it is - * moving from right to left or bottom to top, or 0f if no swipe or animation is in progress. - */ - @ExperimentalMaterial3Api - val direction: Float - get() = anchors.getOffset(currentValue)?.let { sign(offset.value - it) } ?: 0f - - /** - * Set the state without any animation and suspend until it's set - * - * @param targetValue The new target value to set [currentValue] to. - */ - @ExperimentalMaterial3Api - suspend fun snapTo(targetValue: T) { - latestNonEmptyAnchorsFlow.collect { anchors -> - val targetOffset = anchors.getOffset(targetValue) - requireNotNull(targetOffset) { - "The target value must have an associated anchor." - } - snapInternalToOffset(targetOffset) - currentValue = targetValue - } - } - - /** - * Set the state to the target value by starting an animation. - * - * @param targetValue The new value to animate to. - * @param anim The animation that will be used to animate to the new value. - */ - @ExperimentalMaterial3Api - suspend fun animateTo(targetValue: T, anim: AnimationSpec = animationSpec) { - latestNonEmptyAnchorsFlow.collect { anchors -> - try { - val targetOffset = anchors.getOffset(targetValue) - requireNotNull(targetOffset) { - "The target value must have an associated anchor." + /** + * Set the state to the target value by starting an animation. + * + * @param targetValue The new value to animate to. + * @param anim The animation that will be used to animate to the new value. + */ + @ExperimentalMaterial3Api + suspend fun animateTo(targetValue: T, anim: AnimationSpec = animationSpec) { + latestNonEmptyAnchorsFlow.collect { anchors -> + try { + val targetOffset = anchors.getOffset(targetValue) + requireNotNull(targetOffset) { + "The target value must have an associated anchor." + } + animateInternalToOffset(targetOffset, anim) + } finally { + val endOffset = absoluteOffset.value + val endValue = anchors + // fighting rounding error once again, anchor should be as close as 0.5 pixels + .filterKeys { anchorOffset -> abs(anchorOffset - endOffset) < 0.5f } + .values.firstOrNull() ?: currentValue + currentValue = endValue + } } - animateInternalToOffset(targetOffset, anim) - } finally { - val endOffset = absoluteOffset.value - val endValue = anchors - // fighting rounding error once again, anchor should be as close as 0.5 pixels - .filterKeys { anchorOffset -> abs(anchorOffset - endOffset) < 0.5f } - .values.firstOrNull() ?: currentValue - currentValue = endValue - } - } - } - - /** - * Perform fling with settling to one of the anchors which is determined by the given - * [velocity]. Fling with settling [swipeable] will always consume all the velocity provided - * since it will settle at the anchor. - * - * In general cases, [swipeable] flings by itself when being swiped. This method is to be - * used for nested scroll logic that wraps the [swipeable]. In nested scroll developer may - * want to trigger settling fling when the child scroll container reaches the bound. - * - * @param velocity velocity to fling and settle with - * - * @return the reason fling ended - */ - suspend fun performFling(velocity: Float) { - latestNonEmptyAnchorsFlow.collect { anchors -> - val lastAnchor = anchors.getOffset(currentValue)!! - val targetValue = computeTarget( - offset = offset.value, - lastValue = lastAnchor, - anchors = anchors.keys, - thresholds = thresholds, - velocity = velocity, - velocityThreshold = velocityThreshold - ) - val targetState = anchors[targetValue] - if (targetState != null && confirmStateChange(targetState)) animateTo(targetState) - // If the user vetoed the state change, rollback to the previous state. - else animateInternalToOffset(lastAnchor, animationSpec) } - } - - /** - * Force [swipeable] to consume drag delta provided from outside of the regular [swipeable] - * gesture flow. - * - * Note: This method performs generic drag and it won't settle to any particular anchor, * - * leaving swipeable in between anchors. When done dragging, [performFling] must be - * called as well to ensure swipeable will settle at the anchor. - * - * In general cases, [swipeable] drags by itself when being swiped. This method is to be - * used for nested scroll logic that wraps the [swipeable]. In nested scroll developer may - * want to force drag when the child scroll container reaches the bound. - * - * @param delta delta in pixels to drag by - * - * @return the amount of [delta] consumed - */ - fun performDrag(delta: Float): Float { - val potentiallyConsumed = absoluteOffset.value + delta - val clamped = potentiallyConsumed.coerceIn(minBound, maxBound) - val deltaToConsume = clamped - absoluteOffset.value - if (abs(deltaToConsume) > 0) { - draggableState.dispatchRawDelta(deltaToConsume) + + /** + * Perform fling with settling to one of the anchors which is determined by the given + * [velocity]. Fling with settling [swipeable] will always consume all the velocity provided + * since it will settle at the anchor. + * + * In general cases, [swipeable] flings by itself when being swiped. This method is to be + * used for nested scroll logic that wraps the [swipeable]. In nested scroll developer may + * want to trigger settling fling when the child scroll container reaches the bound. + * + * @param velocity velocity to fling and settle with + * + * @return the reason fling ended + */ + suspend fun performFling(velocity: Float) { + latestNonEmptyAnchorsFlow.collect { anchors -> + val lastAnchor = anchors.getOffset(currentValue)!! + val targetValue = computeTarget( + offset = offset.value, + lastValue = lastAnchor, + anchors = anchors.keys, + thresholds = thresholds, + velocity = velocity, + velocityThreshold = velocityThreshold, + ) + val targetState = anchors[targetValue] + if (targetState != null && confirmStateChange(targetState)) { + animateTo(targetState) + } // If the user vetoed the state change, rollback to the previous state. + else { + animateInternalToOffset(lastAnchor, animationSpec) + } + } } - return deltaToConsume - } - companion object { /** - * The default [Saver] implementation for [SwipeableState]. + * Force [swipeable] to consume drag delta provided from outside of the regular [swipeable] + * gesture flow. + * + * Note: This method performs generic drag and it won't settle to any particular anchor, * + * leaving swipeable in between anchors. When done dragging, [performFling] must be + * called as well to ensure swipeable will settle at the anchor. + * + * In general cases, [swipeable] drags by itself when being swiped. This method is to be + * used for nested scroll logic that wraps the [swipeable]. In nested scroll developer may + * want to force drag when the child scroll container reaches the bound. + * + * @param delta delta in pixels to drag by + * + * @return the amount of [delta] consumed */ - fun Saver( - animationSpec: AnimationSpec, - confirmStateChange: (T) -> Boolean - ) = Saver, T>( - save = { it.currentValue }, - restore = { SwipeableState(it, animationSpec, confirmStateChange) } - ) - } + fun performDrag(delta: Float): Float { + val potentiallyConsumed = absoluteOffset.value + delta + val clamped = potentiallyConsumed.coerceIn(minBound, maxBound) + val deltaToConsume = clamped - absoluteOffset.value + if (abs(deltaToConsume) > 0) { + draggableState.dispatchRawDelta(deltaToConsume) + } + return deltaToConsume + } + + companion object { + /** + * The default [Saver] implementation for [SwipeableState]. + */ + fun Saver( + animationSpec: AnimationSpec, + confirmStateChange: (T) -> Boolean, + ) = Saver, T>( + save = { it.currentValue }, + restore = { SwipeableState(it, animationSpec, confirmStateChange) }, + ) + } } /** @@ -418,32 +429,32 @@ open class SwipeableState( @Immutable @ExperimentalMaterial3Api class SwipeProgress( - val from: T, - val to: T, - /*@FloatRange(from = 0.0, to = 1.0)*/ - val fraction: Float + val from: T, + val to: T, + /*@FloatRange(from = 0.0, to = 1.0)*/ + val fraction: Float, ) { - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (other !is SwipeProgress<*>) return false - - if (from != other.from) return false - if (to != other.to) return false - if (fraction != other.fraction) return false - - return true - } - - override fun hashCode(): Int { - var result = from?.hashCode() ?: 0 - result = 31 * result + (to?.hashCode() ?: 0) - result = 31 * result + fraction.hashCode() - return result - } - - override fun toString(): String { - return "SwipeProgress(from=$from, to=$to, fraction=$fraction)" - } + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is SwipeProgress<*>) return false + + if (from != other.from) return false + if (to != other.to) return false + if (fraction != other.fraction) return false + + return true + } + + override fun hashCode(): Int { + var result = from?.hashCode() ?: 0 + result = 31 * result + (to?.hashCode() ?: 0) + result = 31 * result + fraction.hashCode() + return result + } + + override fun toString(): String { + return "SwipeProgress(from=$from, to=$to, fraction=$fraction)" + } } /** @@ -456,60 +467,22 @@ class SwipeProgress( @Composable @ExperimentalMaterial3Api fun rememberSwipeableState( - initialValue: T, - animationSpec: AnimationSpec = SwipeableDefaults.AnimationSpec, - confirmStateChange: (newValue: T) -> Boolean = { true } + initialValue: T, + animationSpec: AnimationSpec = SwipeableDefaults.AnimationSpec, + confirmStateChange: (newValue: T) -> Boolean = { true }, ): SwipeableState { - return rememberSaveable( - saver = SwipeableState.Saver( - animationSpec = animationSpec, - confirmStateChange = confirmStateChange - ) - ) { - SwipeableState( - initialValue = initialValue, - animationSpec = animationSpec, - confirmStateChange = confirmStateChange - ) - } -} - -/** - * Create and [remember] a [SwipeableState] which is kept in sync with another state, i.e.: - * 1. Whenever the [value] changes, the [SwipeableState] will be animated to that new value. - * 2. Whenever the value of the [SwipeableState] changes (e.g. after a swipe), the owner of the - * [value] will be notified to update their state to the new value of the [SwipeableState] by - * invoking [onValueChange]. If the owner does not update their state to the provided value for - * some reason, then the [SwipeableState] will perform a rollback to the previous, correct value. - */ -@Composable -@ExperimentalMaterial3Api -internal fun rememberSwipeableStateFor( - value: T, - onValueChange: (T) -> Unit, - animationSpec: AnimationSpec = SwipeableDefaults.AnimationSpec -): SwipeableState { - val swipeableState = remember { - SwipeableState( - initialValue = value, - animationSpec = animationSpec, - confirmStateChange = { true } - ) - } - val forceAnimationCheck = remember { mutableStateOf(false) } - LaunchedEffect(value, forceAnimationCheck.value) { - if (value != swipeableState.currentValue) { - swipeableState.animateTo(value) - } - } - DisposableEffect(swipeableState.currentValue) { - if (value != swipeableState.currentValue) { - onValueChange(swipeableState.currentValue) - forceAnimationCheck.value = !forceAnimationCheck.value + return rememberSaveable( + saver = SwipeableState.Saver( + animationSpec = animationSpec, + confirmStateChange = confirmStateChange, + ), + ) { + SwipeableState( + initialValue = initialValue, + animationSpec = animationSpec, + confirmStateChange = confirmStateChange, + ) } - onDispose { } - } - return swipeableState } /** @@ -551,62 +524,64 @@ internal fun rememberSwipeableStateFor( */ @ExperimentalMaterial3Api fun Modifier.swipeable( - state: SwipeableState, - anchors: Map, - orientation: Orientation, - enabled: Boolean = true, - reverseDirection: Boolean = false, - interactionSource: MutableInteractionSource? = null, - thresholds: (from: T, to: T) -> ThresholdConfig = { _, _ -> FixedThreshold(56.dp) }, - resistance: ResistanceConfig? = resistanceConfig(anchors.keys), - velocityThreshold: Dp = VelocityThreshold -) = composed( - inspectorInfo = debugInspectorInfo { - name = "swipeable" - properties["state"] = state - properties["anchors"] = anchors - properties["orientation"] = orientation - properties["enabled"] = enabled - properties["reverseDirection"] = reverseDirection - properties["interactionSource"] = interactionSource - properties["thresholds"] = thresholds - properties["resistance"] = resistance - properties["velocityThreshold"] = velocityThreshold - } -) { - require(anchors.isNotEmpty()) { - "You must have at least one anchor." - } - require(anchors.values.distinct().count() == anchors.size) { - "You cannot have two anchors mapped to the same state." - } - val density = LocalDensity.current - state.ensureInit(anchors) - LaunchedEffect(anchors, state) { - val oldAnchors = state.anchors - state.anchors = anchors - state.resistance = resistance - state.thresholds = { a, b -> - val from = anchors.getValue(a) - val to = anchors.getValue(b) - with(thresholds(from, to)) { density.computeThreshold(a, b) } - } - with(density) { - state.velocityThreshold = velocityThreshold.toPx() - } - state.processNewAnchors(oldAnchors, anchors) - } - - Modifier.draggable( - orientation = orientation, - enabled = enabled, - reverseDirection = reverseDirection, - interactionSource = interactionSource, - startDragImmediately = state.isAnimationRunning, - onDragStopped = { velocity -> launch { state.performFling(velocity) } }, - state = state.draggableState - ) -} + state: SwipeableState, + anchors: Map, + orientation: Orientation, + enabled: Boolean = true, + reverseDirection: Boolean = false, + interactionSource: MutableInteractionSource? = null, + thresholds: (from: T, to: T) -> ThresholdConfig = { _, _ -> FixedThreshold(56.dp) }, + resistance: ResistanceConfig? = resistanceConfig(anchors.keys), + velocityThreshold: Dp = VelocityThreshold, +) = this.then( + composed( + inspectorInfo = debugInspectorInfo { + name = "swipeable" + properties["state"] = state + properties["anchors"] = anchors + properties["orientation"] = orientation + properties["enabled"] = enabled + properties["reverseDirection"] = reverseDirection + properties["interactionSource"] = interactionSource + properties["thresholds"] = thresholds + properties["resistance"] = resistance + properties["velocityThreshold"] = velocityThreshold + }, + ) { + require(anchors.isNotEmpty()) { + "You must have at least one anchor." + } + require(anchors.values.distinct().count() == anchors.size) { + "You cannot have two anchors mapped to the same state." + } + val density = LocalDensity.current + state.ensureInit(anchors) + LaunchedEffect(anchors, state) { + val oldAnchors = state.anchors + state.anchors = anchors + state.resistance = resistance + state.thresholds = { a, b -> + val from = anchors.getValue(a) + val to = anchors.getValue(b) + with(thresholds(from, to)) { density.computeThreshold(a, b) } + } + with(density) { + state.velocityThreshold = velocityThreshold.toPx() + } + state.processNewAnchors(oldAnchors, anchors) + } + + Modifier.draggable( + orientation = orientation, + enabled = enabled, + reverseDirection = reverseDirection, + interactionSource = interactionSource, + startDragImmediately = state.isAnimationRunning, + onDragStopped = { velocity -> launch { state.performFling(velocity) } }, + state = state.draggableState, + ) + }, +) /** * Interface to compute a threshold between two anchors/states in a [swipeable]. @@ -616,10 +591,10 @@ fun Modifier.swipeable( @Stable @ExperimentalMaterial3Api interface ThresholdConfig { - /** - * Compute the value of the threshold (in pixels), once the values of the anchors are known. - */ - fun Density.computeThreshold(fromValue: Float, toValue: Float): Float + /** + * Compute the value of the threshold (in pixels), once the values of the anchors are known. + */ + fun Density.computeThreshold(fromValue: Float, toValue: Float): Float } /** @@ -630,9 +605,9 @@ interface ThresholdConfig { @Immutable @ExperimentalMaterial3Api data class FixedThreshold(private val offset: Dp) : ThresholdConfig { - override fun Density.computeThreshold(fromValue: Float, toValue: Float): Float { - return fromValue + offset.toPx() * sign(toValue - fromValue) - } + override fun Density.computeThreshold(fromValue: Float, toValue: Float): Float { + return fromValue + offset.toPx() * sign(toValue - fromValue) + } } /** @@ -643,12 +618,12 @@ data class FixedThreshold(private val offset: Dp) : ThresholdConfig { @Immutable @ExperimentalMaterial3Api data class FractionalThreshold( - /*@FloatRange(from = 0.0, to = 1.0)*/ - private val fraction: Float + /*@FloatRange(from = 0.0, to = 1.0)*/ + private val fraction: Float, ) : ThresholdConfig { - override fun Density.computeThreshold(fromValue: Float, toValue: Float): Float { - return lerp(fromValue, toValue, fraction) - } + override fun Density.computeThreshold(fromValue: Float, toValue: Float): Float { + return lerp(fromValue, toValue, fraction) + } } /** @@ -675,41 +650,41 @@ data class FractionalThreshold( */ @Immutable class ResistanceConfig( - /*@FloatRange(from = 0.0, fromInclusive = false)*/ - val basis: Float, - /*@FloatRange(from = 0.0)*/ - val factorAtMin: Float = StandardResistanceFactor, - /*@FloatRange(from = 0.0)*/ - val factorAtMax: Float = StandardResistanceFactor + /*@FloatRange(from = 0.0, fromInclusive = false)*/ + val basis: Float, + /*@FloatRange(from = 0.0)*/ + val factorAtMin: Float = StandardResistanceFactor, + /*@FloatRange(from = 0.0)*/ + val factorAtMax: Float = StandardResistanceFactor, ) { - fun computeResistance(overflow: Float): Float { - val factor = if (overflow < 0) factorAtMin else factorAtMax - if (factor == 0f) return 0f - val progress = (overflow / basis).coerceIn(-1f, 1f) - return basis / factor * sin(progress * PI.toFloat() / 2) - } - - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (other !is ResistanceConfig) return false - - if (basis != other.basis) return false - if (factorAtMin != other.factorAtMin) return false - if (factorAtMax != other.factorAtMax) return false - - return true - } - - override fun hashCode(): Int { - var result = basis.hashCode() - result = 31 * result + factorAtMin.hashCode() - result = 31 * result + factorAtMax.hashCode() - return result - } - - override fun toString(): String { - return "ResistanceConfig(basis=$basis, factorAtMin=$factorAtMin, factorAtMax=$factorAtMax)" - } + fun computeResistance(overflow: Float): Float { + val factor = if (overflow < 0) factorAtMin else factorAtMax + if (factor == 0f) return 0f + val progress = (overflow / basis).coerceIn(-1f, 1f) + return basis / factor * sin(progress * PI.toFloat() / 2) + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is ResistanceConfig) return false + + if (basis != other.basis) return false + if (factorAtMin != other.factorAtMin) return false + if (factorAtMax != other.factorAtMax) return false + + return true + } + + override fun hashCode(): Int { + var result = basis.hashCode() + result = 31 * result + factorAtMin.hashCode() + result = 31 * result + factorAtMax.hashCode() + return result + } + + override fun toString(): String { + return "ResistanceConfig(basis=$basis, factorAtMin=$factorAtMin, factorAtMax=$factorAtMax)" + } } /** @@ -722,158 +697,158 @@ class ResistanceConfig( * 5. [ a , b ] if a and b are anchors such that a < x < b and b - a is minimal. */ private fun findBounds( - offset: Float, - anchors: Set + offset: Float, + anchors: Set, ): List { - // Find the anchors the target lies between with a little bit of rounding error. - val a = anchors.filter { it <= offset + 0.001 }.maxOrNull() - val b = anchors.filter { it >= offset - 0.001 }.minOrNull() - - return when { - a == null -> - // case 1 or 3 - listOfNotNull(b) - b == null -> - // case 4 - listOf(a) - a == b -> - // case 2 - // Can't return offset itself here since it might not be exactly equal - // to the anchor, despite being considered an exact match. - listOf(a) - else -> - // case 5 - listOf(a, b) - } + // Find the anchors the target lies between with a little bit of rounding error. + val a = anchors.filter { it <= offset + 0.001 }.maxOrNull() + val b = anchors.filter { it >= offset - 0.001 }.minOrNull() + + return when { + a == null -> + // case 1 or 3 + listOfNotNull(b) + b == null -> + // case 4 + listOf(a) + a == b -> + // case 2 + // Can't return offset itself here since it might not be exactly equal + // to the anchor, despite being considered an exact match. + listOf(a) + else -> + // case 5 + listOf(a, b) + } } private fun computeTarget( - offset: Float, - lastValue: Float, - anchors: Set, - thresholds: (Float, Float) -> Float, - velocity: Float, - velocityThreshold: Float + offset: Float, + lastValue: Float, + anchors: Set, + thresholds: (Float, Float) -> Float, + velocity: Float, + velocityThreshold: Float, ): Float { - val bounds = findBounds(offset, anchors) - return when (bounds.size) { - 0 -> lastValue - 1 -> bounds[0] - else -> { - val lower = bounds[0] - val upper = bounds[1] - if (lastValue <= offset) { - // Swiping from lower to upper (positive). - if (velocity >= velocityThreshold) { - return upper - } else { - val threshold = thresholds(lower, upper) - if (offset < threshold) lower else upper - } - } else { - // Swiping from upper to lower (negative). - if (velocity <= -velocityThreshold) { - return lower - } else { - val threshold = thresholds(upper, lower) - if (offset > threshold) upper else lower + val bounds = findBounds(offset, anchors) + return when (bounds.size) { + 0 -> lastValue + 1 -> bounds[0] + else -> { + val lower = bounds[0] + val upper = bounds[1] + if (lastValue <= offset) { + // Swiping from lower to upper (positive). + if (velocity >= velocityThreshold) { + return upper + } else { + val threshold = thresholds(lower, upper) + if (offset < threshold) lower else upper + } + } else { + // Swiping from upper to lower (negative). + if (velocity <= -velocityThreshold) { + return lower + } else { + val threshold = thresholds(upper, lower) + if (offset > threshold) upper else lower + } + } } - } } - } } private fun Map.getOffset(state: T): Float? { - return entries.firstOrNull { it.value == state }?.key + return entries.firstOrNull { it.value == state }?.key } /** * Contains useful defaults for [swipeable] and [SwipeableState]. */ object SwipeableDefaults { - /** - * The default animation used by [SwipeableState]. - */ - val AnimationSpec = SpringSpec() - - /** - * The default velocity threshold (1.8 dp per millisecond) used by [swipeable]. - */ - val VelocityThreshold = 125.dp - - /** - * A stiff resistance factor which indicates that swiping isn't available right now. - */ - const val StiffResistanceFactor = 20f - - /** - * A standard resistance factor which indicates that the user has run out of things to see. - */ - const val StandardResistanceFactor = 10f - - /** - * The default resistance config used by [swipeable]. - * - * This returns `null` if there is one anchor. If there are at least two anchors, it returns - * a [ResistanceConfig] with the resistance basis equal to the distance between the two bounds. - */ - fun resistanceConfig( - anchors: Set, - factorAtMin: Float = StandardResistanceFactor, - factorAtMax: Float = StandardResistanceFactor - ): ResistanceConfig? { - return if (anchors.size <= 1) { - null - } else { - val basis = anchors.maxOrNull()!! - anchors.minOrNull()!! - ResistanceConfig(basis, factorAtMin, factorAtMax) + /** + * The default animation used by [SwipeableState]. + */ + val AnimationSpec = SpringSpec() + + /** + * The default velocity threshold (1.8 dp per millisecond) used by [swipeable]. + */ + val VelocityThreshold = 125.dp + + /** + * A stiff resistance factor which indicates that swiping isn't available right now. + */ + const val StiffResistanceFactor = 20f + + /** + * A standard resistance factor which indicates that the user has run out of things to see. + */ + const val StandardResistanceFactor = 10f + + /** + * The default resistance config used by [swipeable]. + * + * This returns `null` if there is one anchor. If there are at least two anchors, it returns + * a [ResistanceConfig] with the resistance basis equal to the distance between the two bounds. + */ + fun resistanceConfig( + anchors: Set, + factorAtMin: Float = StandardResistanceFactor, + factorAtMax: Float = StandardResistanceFactor, + ): ResistanceConfig? { + return if (anchors.size <= 1) { + null + } else { + val basis = anchors.maxOrNull()!! - anchors.minOrNull()!! + ResistanceConfig(basis, factorAtMin, factorAtMax) + } } - } } // temp default nested scroll connection for swipeables which desire as an opt in // revisit in b/174756744 as all types will have their own specific connection probably @ExperimentalMaterial3Api internal val SwipeableState.PreUpPostDownNestedScrollConnection: NestedScrollConnection - get() = object : NestedScrollConnection { - override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { - val delta = available.toFloat() - return if (delta < 0 && source == NestedScrollSource.Drag) { - performDrag(delta).toOffset() - } else { - Offset.Zero - } - } + get() = object : NestedScrollConnection { + override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { + val delta = available.toFloat() + return if (delta < 0 && source == NestedScrollSource.Drag) { + performDrag(delta).toOffset() + } else { + Offset.Zero + } + } - override fun onPostScroll( - consumed: Offset, - available: Offset, - source: NestedScrollSource - ): Offset { - return if (source == NestedScrollSource.Drag) { - performDrag(available.toFloat()).toOffset() - } else { - Offset.Zero - } - } + override fun onPostScroll( + consumed: Offset, + available: Offset, + source: NestedScrollSource, + ): Offset { + return if (source == NestedScrollSource.Drag) { + performDrag(available.toFloat()).toOffset() + } else { + Offset.Zero + } + } - override suspend fun onPreFling(available: Velocity): Velocity { - val toFling = Offset(available.x, available.y).toFloat() - return if (toFling < 0 && offset.value > minBound) { - performFling(velocity = toFling) - // since we go to the anchor with tween settling, consume all for the best UX - available - } else { - Velocity.Zero - } - } + override suspend fun onPreFling(available: Velocity): Velocity { + val toFling = Offset(available.x, available.y).toFloat() + return if (toFling < 0 && offset.value > minBound) { + performFling(velocity = toFling) + // since we go to the anchor with tween settling, consume all for the best UX + available + } else { + Velocity.Zero + } + } - override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity { - performFling(velocity = Offset(available.x, available.y).toFloat()) - return available - } + override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity { + performFling(velocity = Offset(available.x, available.y).toFloat()) + return available + } - private fun Float.toOffset(): Offset = Offset(0f, this) + private fun Float.toOffset(): Offset = Offset(0f, this) - private fun Offset.toFloat(): Float = this.y - } + private fun Offset.toFloat(): Float = this.y + } diff --git a/app/src/main/java/com/example/android/january2022/ui/session/SessionEvent.kt b/app/src/main/java/com/example/android/january2022/ui/session/SessionEvent.kt index a1e4fb6..02a51e3 100644 --- a/app/src/main/java/com/example/android/january2022/ui/session/SessionEvent.kt +++ b/app/src/main/java/com/example/android/january2022/ui/session/SessionEvent.kt @@ -6,24 +6,24 @@ import com.example.android.january2022.utils.Event import java.time.LocalTime sealed class SessionEvent : Event { - data class ExerciseExpanded(val exercise: ExerciseWrapper) : SessionEvent() - data class ExerciseSelected(val exercise: ExerciseWrapper) : SessionEvent() - data class SetChanged(val updatedSet: GymSet) : SessionEvent() - data class SetCreated(val sessionExercise: ExerciseWrapper) : SessionEvent() - data class SetDeleted(val set: GymSet) : SessionEvent() + data class ExerciseExpanded(val exercise: ExerciseWrapper) : SessionEvent() + data class ExerciseSelected(val exercise: ExerciseWrapper) : SessionEvent() + data class SetChanged(val updatedSet: GymSet) : SessionEvent() + data class SetCreated(val sessionExercise: ExerciseWrapper) : SessionEvent() + data class SetDeleted(val set: GymSet) : SessionEvent() - object RemoveSelectedExercises : SessionEvent() - object RemoveSession : SessionEvent() - object DeselectExercises : SessionEvent() + object RemoveSelectedExercises : SessionEvent() + object RemoveSession : SessionEvent() + object DeselectExercises : SessionEvent() - object TimerToggled : SessionEvent() - object TimerReset : SessionEvent() - object TimerIncreased : SessionEvent() - object TimerDecreased : SessionEvent() + object TimerToggled : SessionEvent() + object TimerReset : SessionEvent() + object TimerIncreased : SessionEvent() + object TimerDecreased : SessionEvent() - object OpenGuide : SessionEvent() - object AddExercise : SessionEvent() + object OpenGuide : SessionEvent() + object AddExercise : SessionEvent() - data class StartTimeChanged(val newTime: LocalTime) : SessionEvent() - data class EndTimeChanged(val newTime: LocalTime) : SessionEvent() -} \ No newline at end of file + data class StartTimeChanged(val newTime: LocalTime) : SessionEvent() + data class EndTimeChanged(val newTime: LocalTime) : SessionEvent() +} diff --git a/app/src/main/java/com/example/android/january2022/ui/session/SessionScreen.kt b/app/src/main/java/com/example/android/january2022/ui/session/SessionScreen.kt index f4d3e76..caf69c2 100644 --- a/app/src/main/java/com/example/android/january2022/ui/session/SessionScreen.kt +++ b/app/src/main/java/com/example/android/january2022/ui/session/SessionScreen.kt @@ -4,16 +4,33 @@ import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter -import androidx.compose.animation.* -import androidx.compose.animation.core.RepeatMode -import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.tween -import androidx.compose.foundation.layout.* +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.material3.* -import androidx.compose.runtime.* +import androidx.compose.material3.BottomAppBar +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +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.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext @@ -29,11 +46,17 @@ import com.example.android.january2022.ui.SessionWrapper import com.example.android.january2022.ui.TimerState import com.example.android.january2022.ui.datetimedialog.MaterialDialog import com.example.android.january2022.ui.datetimedialog.rememberMaterialDialogState -import com.example.android.january2022.ui.datetimedialog.time.timepicker +import com.example.android.january2022.ui.datetimedialog.time.Timepicker import com.example.android.january2022.ui.modalbottomsheet.ModalBottomSheetLayout import com.example.android.january2022.ui.modalbottomsheet.ModalBottomSheetValue import com.example.android.january2022.ui.modalbottomsheet.rememberModalBottomSheetState -import com.example.android.january2022.ui.session.components.* +import com.example.android.january2022.ui.session.components.DeletionAlertDialog +import com.example.android.january2022.ui.session.components.ExerciseCard +import com.example.android.january2022.ui.session.components.SessionAppBar +import com.example.android.january2022.ui.session.components.SessionAppBarExpanded +import com.example.android.january2022.ui.session.components.SessionAppBarSelected +import com.example.android.january2022.ui.session.components.SessionHeader +import com.example.android.january2022.ui.session.components.TimerBar import com.example.android.january2022.ui.theme.onlyTop import com.example.android.january2022.utils.UiEvent import kotlinx.coroutines.launch @@ -45,285 +68,285 @@ import java.util.* @OptIn(ExperimentalMaterial3Api::class) @Composable fun SessionScreen( - onNavigate: (UiEvent.Navigate) -> Unit, - viewModel: SessionViewModel = hiltViewModel() + onNavigate: (UiEvent.Navigate) -> Unit, + viewModel: SessionViewModel = hiltViewModel(), ) { - val uriHandler = LocalUriHandler.current - val context = LocalContext.current + val uriHandler = LocalUriHandler.current + val context = LocalContext.current - LaunchedEffect(true) { - viewModel.uiEvent.collect { event -> - Timber.d("UiEvent Received: $event") - when (event) { - is UiEvent.OpenWebsite -> { - uriHandler.openUri(event.url) + LaunchedEffect(true) { + viewModel.uiEvent.collect { event -> + Timber.d("UiEvent Received: $event") + when (event) { + is UiEvent.OpenWebsite -> { + uriHandler.openUri(event.url) + } + is UiEvent.Navigate -> onNavigate(event) + is UiEvent.ToggleTimer -> context.sendTimerAction(TimerService.Actions.TOGGLE) + is UiEvent.ResetTimer -> context.sendTimerAction(TimerService.Actions.RESET) + is UiEvent.IncrementTimer -> context.sendTimerAction(TimerService.Actions.INCREMENT) + is UiEvent.DecrementTimer -> context.sendTimerAction(TimerService.Actions.DECREMENT) + else -> Unit + } } - is UiEvent.Navigate -> onNavigate(event) - is UiEvent.ToggleTimer -> context.sendTimerAction(TimerService.Actions.TOGGLE) - is UiEvent.ResetTimer -> context.sendTimerAction(TimerService.Actions.RESET) - is UiEvent.IncrementTimer -> context.sendTimerAction(TimerService.Actions.INCREMENT) - is UiEvent.DecrementTimer -> context.sendTimerAction(TimerService.Actions.DECREMENT) - else -> Unit - } } - } - val session by viewModel.session.collectAsState(SessionWrapper(Session(), emptyList())) - val exercises by viewModel.exercises.collectAsState(initial = emptyList()) - val expandedExercise by viewModel.expandedExercise.collectAsState() - val selectedExercises by viewModel.selectedExercises.collectAsState() - val muscleGroups by viewModel.muscleGroups.collectAsState(emptyList()) + val session by viewModel.session.collectAsState(SessionWrapper(Session(), emptyList())) + val exercises by viewModel.exercises.collectAsState(initial = emptyList()) + val expandedExercise by viewModel.expandedExercise.collectAsState() + val selectedExercises by viewModel.selectedExercises.collectAsState() + val muscleGroups by viewModel.muscleGroups.collectAsState(emptyList()) - var timerState by remember { mutableStateOf(TimerState(0L, false, 0L)) } - val receiver = object : BroadcastReceiver() { - override fun onReceive(context: Context?, intent: Intent?) { - intent?.let { - timerState = TimerState( - time = it.getLongExtra(TimerService.Intents.Extras.TIME.toString(), 0L), - running = it.getBooleanExtra(TimerService.Intents.Extras.IS_RUNNING.toString(), false), - maxTime = it.getLongExtra(TimerService.Intents.Extras.MAX_TIME.toString(), 0L) - ) - } + var timerState by remember { mutableStateOf(TimerState(0L, false, 0L)) } + val receiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + intent?.let { + timerState = TimerState( + time = it.getLongExtra(TimerService.Intents.Extras.TIME.toString(), 0L), + running = it.getBooleanExtra(TimerService.Intents.Extras.IS_RUNNING.toString(), false), + maxTime = it.getLongExtra(TimerService.Intents.Extras.MAX_TIME.toString(), 0L), + ) + } + } + } + // Register local broadcast manager to update Timer State when Intents are received + ContextCompat.registerReceiver( + context, + receiver, + IntentFilter(TimerService.Intents.STATUS.toString()), + ContextCompat.RECEIVER_NOT_EXPORTED, + ).also { + context.sendTimerAction(TimerService.Actions.QUERY) } - } - // Register local broadcast manager to update Timer State when Intents are received - ContextCompat.registerReceiver( - context, - receiver, - IntentFilter(TimerService.Intents.STATUS.toString()), - ContextCompat.RECEIVER_NOT_EXPORTED - ).also { - context.sendTimerAction(TimerService.Actions.QUERY) - } - val scrollState = rememberLazyListState() - val headerHeight = 240.dp - val coroutineScope = rememberCoroutineScope() - val timerVisible = remember { mutableStateOf(false) } + val scrollState = rememberLazyListState() + val headerHeight = 240.dp + val coroutineScope = rememberCoroutineScope() + val timerVisible = remember { mutableStateOf(false) } - val deleteExerciseDialog = remember { mutableStateOf(false) } - val deleteSessionDialog = remember { mutableStateOf(false) } - val deleteSetDialog = remember { mutableStateOf(null) } + val deleteExerciseDialog = remember { mutableStateOf(false) } + val deleteSessionDialog = remember { mutableStateOf(false) } + val deleteSetDialog = remember { mutableStateOf(null) } - if (deleteExerciseDialog.value) { - DeletionAlertDialog( - onDismiss = { deleteExerciseDialog.value = false }, - onDelete = { - viewModel.onEvent(SessionEvent.RemoveSelectedExercises) - deleteExerciseDialog.value = false - }, - title = { - Text(text = "Remove ${selectedExercises.size} Exercise${if (selectedExercises.size > 1) "s" else ""}?") - }, - text = { - Text(text = "Are you sure you want to remove the selected exercises from this session? This action can not be undone.") - } - ) - } - if (deleteSessionDialog.value) { - DeletionAlertDialog( - onDismiss = { deleteSessionDialog.value = false }, - onDelete = { - viewModel.onEvent(SessionEvent.RemoveSession) - deleteSessionDialog.value = false - }, - title = { - Text(text = "Delete Session?") - }, - text = { - Text(text = "Are you sure you want to delete this session and all of its contents? This action can not be undone.") - } - ) - } - if (deleteSetDialog.value != null) { - DeletionAlertDialog( - onDismiss = { deleteSetDialog.value = null }, - onDelete = { - deleteSetDialog.value?.let { viewModel.onEvent(SessionEvent.SetDeleted(it)) } - deleteSetDialog.value = null - }, - title = { - Text(text = "Delete Set?") - }, - text = { - Text(text = "Are you sure you want to delete this set? This action can not be undone.") - } - ) - } - val startTimeDialogState = rememberMaterialDialogState() - MaterialDialog( - dialogState = startTimeDialogState, - buttons = { - positiveButton("Ok") - negativeButton("Cancel") + if (deleteExerciseDialog.value) { + DeletionAlertDialog( + onDismiss = { deleteExerciseDialog.value = false }, + onDelete = { + viewModel.onEvent(SessionEvent.RemoveSelectedExercises) + deleteExerciseDialog.value = false + }, + title = { + Text(text = "Remove ${selectedExercises.size} Exercise${if (selectedExercises.size > 1) "s" else ""}?") + }, + text = { + Text(text = "Are you sure you want to remove the selected exercises from this session? This action can not be undone.") + }, + ) + } + if (deleteSessionDialog.value) { + DeletionAlertDialog( + onDismiss = { deleteSessionDialog.value = false }, + onDelete = { + viewModel.onEvent(SessionEvent.RemoveSession) + deleteSessionDialog.value = false + }, + title = { + Text(text = "Delete Session?") + }, + text = { + Text(text = "Are you sure you want to delete this session and all of its contents? This action can not be undone.") + }, + ) } - ) { - timepicker( - initialTime = session.session.start.toLocalTime(), - is24HourClock = true, - waitForPositiveButton = true, - title = "Select end-time" - ) { time -> - viewModel.onEvent(SessionEvent.StartTimeChanged(time)) + if (deleteSetDialog.value != null) { + DeletionAlertDialog( + onDismiss = { deleteSetDialog.value = null }, + onDelete = { + deleteSetDialog.value?.let { viewModel.onEvent(SessionEvent.SetDeleted(it)) } + deleteSetDialog.value = null + }, + title = { + Text(text = "Delete Set?") + }, + text = { + Text(text = "Are you sure you want to delete this set? This action can not be undone.") + }, + ) } - } - val endTimeDialogState = rememberMaterialDialogState() - MaterialDialog( - dialogState = endTimeDialogState, - buttons = { - positiveButton("Ok") - negativeButton("Cancel") + val startTimeDialogState = rememberMaterialDialogState() + MaterialDialog( + dialogState = startTimeDialogState, + buttons = { + PositiveButton("Ok") + NegativeButton("Cancel") + }, + ) { + Timepicker( + initialTime = session.session.start.toLocalTime(), + is24HourClock = true, + waitForPositiveButton = true, + title = "Select end-time", + ) { time -> + viewModel.onEvent(SessionEvent.StartTimeChanged(time)) + } } - ) { - timepicker( - initialTime = LocalTime.now(), - is24HourClock = true, - waitForPositiveButton = true, - title = "Select end-time" - ) { time -> - viewModel.onEvent(SessionEvent.EndTimeChanged(time)) + val endTimeDialogState = rememberMaterialDialogState() + MaterialDialog( + dialogState = endTimeDialogState, + buttons = { + PositiveButton("Ok") + NegativeButton("Cancel") + }, + ) { + Timepicker( + initialTime = LocalTime.now(), + is24HourClock = true, + waitForPositiveButton = true, + title = "Select end-time", + ) { time -> + viewModel.onEvent(SessionEvent.EndTimeChanged(time)) + } } - } - val sessionInfoSheetState = - rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden) + val sessionInfoSheetState = + rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden) - ModalBottomSheetLayout( - sheetContent = { - Text("SESSION INFO", modifier = Modifier.height(400.dp)) - }, - sheetState = sessionInfoSheetState, - sheetShape = MaterialTheme.shapes.large.onlyTop() - ) { - Scaffold( - bottomBar = { - Box( - contentAlignment = Alignment.BottomCenter - ) { - BottomAppBar {} - AnimatedVisibility( - visible = timerVisible.value, - exit = slideOutVertically( - animationSpec = tween(250), - targetOffsetY = { height -> - height / 2 - } - ), - enter = slideInVertically( - animationSpec = tween(250), - initialOffsetY = { height -> - height / 2 - } - ) - ) { - Column { - TimerBar(timerState, viewModel::onEvent) - BottomAppBar {} - } - } - AnimatedVisibility( - visible = expandedExercise == null, - exit = fadeOut(tween(500)), - enter = fadeIn(tween(500)) - ) { - SessionAppBar( - onDeleteSession = { deleteSessionDialog.value = true }, - timerVisible = timerVisible.value, - timerState = timerState, - onTimerPress = { - timerVisible.value = !timerVisible.value - } + ModalBottomSheetLayout( + sheetContent = { + Text("SESSION INFO", modifier = Modifier.height(400.dp)) + }, + sheetState = sessionInfoSheetState, + sheetShape = MaterialTheme.shapes.large.onlyTop(), + ) { + Scaffold( + bottomBar = { + Box( + contentAlignment = Alignment.BottomCenter, + ) { + BottomAppBar {} + AnimatedVisibility( + visible = timerVisible.value, + exit = slideOutVertically( + animationSpec = tween(250), + targetOffsetY = { height -> + height / 2 + }, + ), + enter = slideInVertically( + animationSpec = tween(250), + initialOffsetY = { height -> + height / 2 + }, + ), + ) { + Column { + TimerBar(timerState, viewModel::onEvent) + BottomAppBar {} + } + } + AnimatedVisibility( + visible = expandedExercise == null, + exit = fadeOut(tween(500)), + enter = fadeIn(tween(500)), + ) { + SessionAppBar( + onDeleteSession = { deleteSessionDialog.value = true }, + timerVisible = timerVisible.value, + timerState = timerState, + onTimerPress = { + timerVisible.value = !timerVisible.value + }, + ) { + viewModel.onEvent(SessionEvent.AddExercise) + } + } + AnimatedVisibility( + visible = expandedExercise != null, + exit = fadeOut(tween(500)), + enter = fadeIn(tween(500)), + ) { + SessionAppBarExpanded( + timerVisible = timerVisible.value, + timerState = timerState, + onTimerPress = { + timerVisible.value = !timerVisible.value + }, + onDeleteSession = { deleteSessionDialog.value = true }, + onEvent = viewModel::onEvent, + ) + } + AnimatedVisibility( + visible = selectedExercises.isNotEmpty(), + exit = fadeOut(tween(500)), + enter = fadeIn(tween(500)), + ) { + SessionAppBarSelected( + timerVisible = timerVisible.value, + timerState = timerState, + onTimerPress = { + timerVisible.value = !timerVisible.value + }, + onDeleteExercise = { deleteExerciseDialog.value = true }, + onDeleteSession = { deleteSessionDialog.value = true }, + onEvent = viewModel::onEvent, + ) + } + } + }, + ) { paddingValues -> + LazyColumn( + modifier = Modifier + .fillMaxSize(), + state = scrollState, ) { - viewModel.onEvent(SessionEvent.AddExercise) + item { + SessionHeader( + sessionWrapper = session, + muscleGroups = muscleGroups, + scrollState = scrollState, + height = headerHeight, + topPadding = paddingValues.calculateTopPadding(), + onEndTime = { endTimeDialogState.show() }, + onStartTime = { startTimeDialogState.show() }, + ) + } + itemsIndexed( + items = exercises, + key = { _, exercise -> + exercise.sessionExercise.sessionExerciseId + }, + ) { index, exercise -> + val expanded = + exercise.sessionExercise.sessionExerciseId == expandedExercise?.sessionExercise?.sessionExerciseId + val selected = selectedExercises.contains(exercise) + ExerciseCard( + exerciseWrapper = exercise, + expanded = expanded, + selected = selected, + onEvent = viewModel::onEvent, + onLongClick = { viewModel.onEvent(SessionEvent.ExerciseSelected(exercise)) }, + onSetDeleted = { deleteSetDialog.value = it }, + ) { + viewModel.onEvent(SessionEvent.ExerciseExpanded(exercise)) + if (!expanded) { + coroutineScope.launch { + scrollState.animateScrollToItem(index = (index - 1).coerceAtLeast(0)) + } + } + } + } + item { + Spacer(modifier = Modifier.height(paddingValues.calculateBottomPadding())) + } } - } - AnimatedVisibility( - visible = expandedExercise != null, - exit = fadeOut(tween(500)), - enter = fadeIn(tween(500)) - ) { - SessionAppBarExpanded( - timerVisible = timerVisible.value, - timerState = timerState, - onTimerPress = { - timerVisible.value = !timerVisible.value - }, - onDeleteSession = { deleteSessionDialog.value = true }, - onEvent = viewModel::onEvent - ) - } - AnimatedVisibility( - visible = selectedExercises.isNotEmpty(), - exit = fadeOut(tween(500)), - enter = fadeIn(tween(500)) - ) { - SessionAppBarSelected( - timerVisible = timerVisible.value, - timerState = timerState, - onTimerPress = { - timerVisible.value = !timerVisible.value - }, - onDeleteExercise = { deleteExerciseDialog.value = true }, - onDeleteSession = { deleteSessionDialog.value = true }, - onEvent = viewModel::onEvent - ) - } } - } - ) { paddingValues -> - LazyColumn( - modifier = Modifier - .fillMaxSize(), - state = scrollState - ) { - item { - SessionHeader( - sessionWrapper = session, - muscleGroups = muscleGroups, - scrollState = scrollState, - height = headerHeight, - topPadding = paddingValues.calculateTopPadding(), - onEndTime = { endTimeDialogState.show() }, - onStartTime = { startTimeDialogState.show() } - ) - } - itemsIndexed( - items = exercises, - key = { _, exercise -> - exercise.sessionExercise.sessionExerciseId - } - ) { index, exercise -> - val expanded = - exercise.sessionExercise.sessionExerciseId == expandedExercise?.sessionExercise?.sessionExerciseId - val selected = selectedExercises.contains(exercise) - ExerciseCard( - exerciseWrapper = exercise, - expanded = expanded, - selected = selected, - onEvent = viewModel::onEvent, - onLongClick = { viewModel.onEvent(SessionEvent.ExerciseSelected(exercise)) }, - onSetDeleted = { deleteSetDialog.value = it } - ) { - viewModel.onEvent(SessionEvent.ExerciseExpanded(exercise)) - if (!expanded) { - coroutineScope.launch { - scrollState.animateScrollToItem(index = (index - 1).coerceAtLeast(0)) - } - } - } - } - item { - Spacer(modifier = Modifier.height(paddingValues.calculateBottomPadding())) - } - } } - } } fun Session.toSessionTitle(): String { - return try { - DateTimeFormatter.ofPattern("MMM d yyyy").format(this.start) - } catch (e: Exception) { - "no date" - } -} \ No newline at end of file + return try { + DateTimeFormatter.ofPattern("MMM d yyyy").format(this.start) + } catch (e: Exception) { + "no date" + } +} diff --git a/app/src/main/java/com/example/android/january2022/ui/session/SessionViewModel.kt b/app/src/main/java/com/example/android/january2022/ui/session/SessionViewModel.kt index 7333552..28c03bb 100644 --- a/app/src/main/java/com/example/android/january2022/ui/session/SessionViewModel.kt +++ b/app/src/main/java/com/example/android/january2022/ui/session/SessionViewModel.kt @@ -15,7 +15,11 @@ import com.example.android.january2022.utils.sortedListOfMuscleGroups import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.flow.* +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import timber.log.Timber @@ -24,167 +28,167 @@ import javax.inject.Inject @HiltViewModel class SessionViewModel @Inject constructor( - private val repo: GymRepository, - savedStateHandle: SavedStateHandle + private val repo: GymRepository, + savedStateHandle: SavedStateHandle, ) : ViewModel() { - private val _session = MutableStateFlow(Session()) - val session = _session.asStateFlow().map { - SessionWrapper(it, emptyList()) - } + private val _session = MutableStateFlow(Session()) + val session = _session.asStateFlow().map { + SessionWrapper(it, emptyList()) + } - private val _expandedExercise = MutableStateFlow(null) - val expandedExercise = _expandedExercise.asStateFlow() + private val _expandedExercise = MutableStateFlow(null) + val expandedExercise = _expandedExercise.asStateFlow() - private val _selectedExercises = MutableStateFlow>(emptyList()) - val selectedExercises = _selectedExercises.asStateFlow() + private val _selectedExercises = MutableStateFlow>(emptyList()) + val selectedExercises = _selectedExercises.asStateFlow() - val exercises = combine( - repo.getExercisesForSession(_session), - repo.getAllSets() - ) { exercises, sets -> - exercises.map { sewe -> - ExerciseWrapper( - sessionExercise = sewe.sessionExercise, - exercise = sewe.exercise, - sets = sets.filter { set -> - set.parentSessionExerciseId == sewe.sessionExercise.sessionExerciseId + val exercises = combine( + repo.getExercisesForSession(_session), + repo.getAllSets(), + ) { exercises, sets -> + exercises.map { sewe -> + ExerciseWrapper( + sessionExercise = sewe.sessionExercise, + exercise = sewe.exercise, + sets = sets.filter { set -> + set.parentSessionExerciseId == sewe.sessionExercise.sessionExerciseId + }, + ) } - ) } - } - val muscleGroups = exercises.map { exercises -> - exercises.map { it.exercise }.sortedListOfMuscleGroups() - } + val muscleGroups = exercises.map { exercises -> + exercises.map { it.exercise }.sortedListOfMuscleGroups() + } - private val _uiEvent = Channel() - val uiEvent = _uiEvent.receiveAsFlow() + private val _uiEvent = Channel() + val uiEvent = _uiEvent.receiveAsFlow() - init { - savedStateHandle.get("session_id")?.let { sessionId -> - Timber.d("Session ID: $sessionId") - viewModelScope.launch { - withContext(Dispatchers.IO) { - _session.value = repo.getSessionById(sessionId) + init { + savedStateHandle.get("session_id")?.let { sessionId -> + Timber.d("Session ID: $sessionId") + viewModelScope.launch { + withContext(Dispatchers.IO) { + _session.value = repo.getSessionById(sessionId) + } + } } - } } - } - fun onEvent(event: Event) { - when (event) { - is SessionEvent.ExerciseExpanded -> { - event.exercise.let { se -> - if (se.sessionExercise.sessionExerciseId == _expandedExercise.value?.sessionExercise?.sessionExerciseId) { - _expandedExercise.value = null - } else { - _expandedExercise.value = se - } - _selectedExercises.value = emptyList() - } - } - is SessionEvent.ExerciseSelected -> { - _selectedExercises.value = buildList { - if (_selectedExercises.value.contains(event.exercise)) { - addAll(_selectedExercises.value.minusElement(event.exercise)) - } else { - addAll(_selectedExercises.value) - add(event.exercise) - } - } - _expandedExercise.value = null - } - is SessionEvent.SetChanged -> { - viewModelScope.launch { - withContext(Dispatchers.IO) { - repo.updateSet(event.updatedSet) - } + fun onEvent(event: Event) { + when (event) { + is SessionEvent.ExerciseExpanded -> { + event.exercise.let { se -> + if (se.sessionExercise.sessionExerciseId == _expandedExercise.value?.sessionExercise?.sessionExerciseId) { + _expandedExercise.value = null + } else { + _expandedExercise.value = se + } + _selectedExercises.value = emptyList() + } + } + is SessionEvent.ExerciseSelected -> { + _selectedExercises.value = buildList { + if (_selectedExercises.value.contains(event.exercise)) { + addAll(_selectedExercises.value.minusElement(event.exercise)) + } else { + addAll(_selectedExercises.value) + add(event.exercise) + } + } + _expandedExercise.value = null + } + is SessionEvent.SetChanged -> { + viewModelScope.launch { + withContext(Dispatchers.IO) { + repo.updateSet(event.updatedSet) + } + } + } + is SessionEvent.SetCreated -> { + viewModelScope.launch { + withContext(Dispatchers.IO) { + repo.createSet(event.sessionExercise.sessionExercise) + } + } + } + is SessionEvent.SetDeleted -> { + viewModelScope.launch { + withContext(Dispatchers.IO) { + repo.deleteSet(event.set) + } + } + } + is SessionEvent.TimerToggled -> sendUiEvent(UiEvent.ToggleTimer) + is SessionEvent.TimerReset -> sendUiEvent(UiEvent.ResetTimer) + is SessionEvent.TimerIncreased -> sendUiEvent(UiEvent.IncrementTimer) + is SessionEvent.TimerDecreased -> sendUiEvent(UiEvent.DecrementTimer) + is SessionEvent.OpenGuide -> { + expandedExercise.value?.exercise?.let { openGuide(it) } + } + is SessionEvent.AddExercise -> { + _session.value.sessionId.let { id -> + sendUiEvent(UiEvent.Navigate("${Routes.EXERCISE_PICKER}/$id")) + } + } + is SessionEvent.RemoveSelectedExercises -> { + viewModelScope.launch { + _selectedExercises.value.forEach { + repo.removeSessionExercise(it.sessionExercise) + } + _selectedExercises.value = emptyList() + } + } + is SessionEvent.RemoveSession -> { + sendUiEvent(UiEvent.Navigate(Routes.HOME, popBackStack = true)) + viewModelScope.launch { + repo.removeSession(_session.value) + } + } + is SessionEvent.DeselectExercises -> { + _selectedExercises.value = emptyList() + } + is SessionEvent.EndTimeChanged -> { + var session = _session.value + val date = session.end?.toLocalDate() ?: session.start.toLocalDate() + val newEndTime = LocalDateTime.of(date, event.newTime) + viewModelScope.launch { + repo.updateSession( + session.copy( + end = newEndTime, + ).also { session = it }, + ) + withContext(Dispatchers.IO) { + _session.value = repo.getSessionById(_session.value.sessionId) + } + } + } + is SessionEvent.StartTimeChanged -> { + val session = _session.value + val newStartTime = LocalDateTime.of(session.start.toLocalDate(), event.newTime) + viewModelScope.launch { + repo.updateSession( + session.copy( + start = newStartTime, + ), + ) + withContext(Dispatchers.IO) { + _session.value = repo.getSessionById(_session.value.sessionId) + } + } + } + else -> Unit } - } - is SessionEvent.SetCreated -> { - viewModelScope.launch { - withContext(Dispatchers.IO) { - repo.createSet(event.sessionExercise.sessionExercise) - } - } - } - is SessionEvent.SetDeleted -> { - viewModelScope.launch { - withContext(Dispatchers.IO) { - repo.deleteSet(event.set) - } - } - } - is SessionEvent.TimerToggled -> sendUiEvent(UiEvent.ToggleTimer) - is SessionEvent.TimerReset -> sendUiEvent(UiEvent.ResetTimer) - is SessionEvent.TimerIncreased -> sendUiEvent(UiEvent.IncrementTimer) - is SessionEvent.TimerDecreased -> sendUiEvent(UiEvent.DecrementTimer) - is SessionEvent.OpenGuide -> { - expandedExercise.value?.exercise?.let { openGuide(it) } - } - is SessionEvent.AddExercise -> { - _session.value.sessionId.let { id -> - sendUiEvent(UiEvent.Navigate("${Routes.EXERCISE_PICKER}/$id")) - } - } - is SessionEvent.RemoveSelectedExercises -> { - viewModelScope.launch { - _selectedExercises.value.forEach { - repo.removeSessionExercise(it.sessionExercise) - } - _selectedExercises.value = emptyList() - } - } - is SessionEvent.RemoveSession -> { - sendUiEvent(UiEvent.Navigate(Routes.HOME, popBackStack = true)) - viewModelScope.launch { - repo.removeSession(_session.value) - } - } - is SessionEvent.DeselectExercises -> { - _selectedExercises.value = emptyList() - } - is SessionEvent.EndTimeChanged -> { - var session = _session.value - val date = session.end?.toLocalDate() ?: session.start.toLocalDate() - val newEndTime = LocalDateTime.of(date, event.newTime) - viewModelScope.launch { - repo.updateSession( - session.copy( - end = newEndTime - ).also { session = it } - ) - withContext(Dispatchers.IO) { - _session.value = repo.getSessionById(_session.value.sessionId) - } - } - } - is SessionEvent.StartTimeChanged -> { - val session = _session.value - val newStartTime = LocalDateTime.of(session.start.toLocalDate(), event.newTime) - viewModelScope.launch { - repo.updateSession( - session.copy( - start = newStartTime - ) - ) - withContext(Dispatchers.IO) { - _session.value = repo.getSessionById(_session.value.sessionId) - } - } - } - else -> Unit } - } - private fun openGuide(exercise: Exercise) { - sendUiEvent(UiEvent.OpenWebsite(url = "https://duckduckgo.com/?q=! exrx ${exercise.title}")) - } + private fun openGuide(exercise: Exercise) { + sendUiEvent(UiEvent.OpenWebsite(url = "https://duckduckgo.com/?q=! exrx ${exercise.title}")) + } - private fun sendUiEvent(event: UiEvent) { - viewModelScope.launch { - _uiEvent.send(event) + private fun sendUiEvent(event: UiEvent) { + viewModelScope.launch { + _uiEvent.send(event) + } } - } } diff --git a/app/src/main/java/com/example/android/january2022/ui/session/actions/MenuAction.kt b/app/src/main/java/com/example/android/january2022/ui/session/actions/MenuAction.kt index 3c3a4fd..b3e7411 100644 --- a/app/src/main/java/com/example/android/january2022/ui/session/actions/MenuAction.kt +++ b/app/src/main/java/com/example/android/january2022/ui/session/actions/MenuAction.kt @@ -2,27 +2,31 @@ package com.example.android.january2022.ui.session.actions import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.material.DropdownMenu import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.MoreVert -import androidx.compose.material3.* +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @Composable fun MenuAction( - onDelete: () -> Unit, + onDelete: () -> Unit, ) { - val expanded = remember { mutableStateOf(false) } + val expanded = remember { mutableStateOf(false) } - Column { - Box { - DropdownMenu(expanded = expanded.value, onDismissRequest = { expanded.value = false }) { - DropdownMenuItem(text = { Text(text = "Delete Session") }, onClick = onDelete ) - } + Column { + Box { + DropdownMenu(expanded = expanded.value, onDismissRequest = { expanded.value = false }) { + DropdownMenuItem(text = { Text(text = "Delete Session") }, onClick = onDelete) + } + } + IconButton(onClick = { expanded.value = true }) { + Icon(imageVector = Icons.Default.MoreVert, contentDescription = "Options") + } } - IconButton(onClick = { expanded.value = true }) { - Icon(imageVector = Icons.Default.MoreVert, contentDescription = "Options") - } - } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/example/android/january2022/ui/session/actions/OpenInNewAction.kt b/app/src/main/java/com/example/android/january2022/ui/session/actions/OpenInNewAction.kt index 7a3a668..49aca3e 100644 --- a/app/src/main/java/com/example/android/january2022/ui/session/actions/OpenInNewAction.kt +++ b/app/src/main/java/com/example/android/january2022/ui/session/actions/OpenInNewAction.kt @@ -8,9 +8,9 @@ import androidx.compose.runtime.Composable @Composable fun OpenInNewAction( - onClick: () -> Unit + onClick: () -> Unit, ) { - IconButton(onClick = onClick) { - Icon(imageVector = Icons.Outlined.OpenInNew, contentDescription = "Open exercise guide.") - } -} \ No newline at end of file + IconButton(onClick = onClick) { + Icon(imageVector = Icons.Outlined.OpenInNew, contentDescription = "Open exercise guide.") + } +} diff --git a/app/src/main/java/com/example/android/january2022/ui/session/actions/OpenStatsAction.kt b/app/src/main/java/com/example/android/january2022/ui/session/actions/OpenStatsAction.kt index d191d7c..62ef6a5 100644 --- a/app/src/main/java/com/example/android/january2022/ui/session/actions/OpenStatsAction.kt +++ b/app/src/main/java/com/example/android/january2022/ui/session/actions/OpenStatsAction.kt @@ -8,9 +8,9 @@ import androidx.compose.runtime.Composable @Composable fun OpenStatsAction( - onClick: () -> Unit + onClick: () -> Unit, ) { - IconButton(onClick = onClick) { - Icon(imageVector = Icons.Outlined.Analytics, contentDescription = "Open exercise guide.") - } -} \ No newline at end of file + IconButton(onClick = onClick) { + Icon(imageVector = Icons.Outlined.Analytics, contentDescription = "Open exercise guide.") + } +} diff --git a/app/src/main/java/com/example/android/january2022/ui/session/actions/Spacers.kt b/app/src/main/java/com/example/android/january2022/ui/session/actions/Spacers.kt index 04795c7..3bcb524 100644 --- a/app/src/main/java/com/example/android/january2022/ui/session/actions/Spacers.kt +++ b/app/src/main/java/com/example/android/january2022/ui/session/actions/Spacers.kt @@ -8,10 +8,10 @@ import androidx.compose.ui.unit.dp @Composable fun ActionSpacer() { - Spacer(modifier = Modifier.width(8.dp)) + Spacer(modifier = Modifier.width(8.dp)) } @Composable fun ActionSpacerStart() { - Spacer(modifier = Modifier.width(4.dp)) -} \ No newline at end of file + Spacer(modifier = Modifier.width(4.dp)) +} diff --git a/app/src/main/java/com/example/android/january2022/ui/session/actions/TimerAction.kt b/app/src/main/java/com/example/android/january2022/ui/session/actions/TimerAction.kt index bd67fef..6497df6 100644 --- a/app/src/main/java/com/example/android/january2022/ui/session/actions/TimerAction.kt +++ b/app/src/main/java/com/example/android/january2022/ui/session/actions/TimerAction.kt @@ -1,7 +1,6 @@ package com.example.android.january2022.ui.session.actions import androidx.compose.animation.animateColor -import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.RepeatMode import androidx.compose.animation.core.infiniteRepeatable import androidx.compose.animation.core.rememberInfiniteTransition @@ -18,27 +17,27 @@ import com.example.android.january2022.ui.TimerState @Composable fun TimerAction( - timerState: TimerState, - timerVisible: Boolean, - onClick: () -> Unit + timerState: TimerState, + timerVisible: Boolean, + onClick: () -> Unit, ) { - val infiniteTransition = rememberInfiniteTransition() - val activeColor by infiniteTransition.animateColor( - initialValue = LocalContentColor.current, - targetValue = MaterialTheme.colorScheme.primary, - animationSpec = infiniteRepeatable( - animation = tween(durationMillis = 1500), - repeatMode = RepeatMode.Reverse + val infiniteTransition = rememberInfiniteTransition() + val activeColor by infiniteTransition.animateColor( + initialValue = LocalContentColor.current, + targetValue = MaterialTheme.colorScheme.primary, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 1500), + repeatMode = RepeatMode.Reverse, + ), ) - ) - IconButton(onClick = { - onClick() - }) { - Icon( - imageVector = Icons.Outlined.Timer, - contentDescription = "Timer", - tint = if (!timerVisible && timerState.running) activeColor else LocalContentColor.current - ) - } -} \ No newline at end of file + IconButton(onClick = { + onClick() + }) { + Icon( + imageVector = Icons.Outlined.Timer, + contentDescription = "Timer", + tint = if (!timerVisible && timerState.running) activeColor else LocalContentColor.current, + ) + } +} diff --git a/app/src/main/java/com/example/android/january2022/ui/session/components/CompactSetCard.kt b/app/src/main/java/com/example/android/january2022/ui/session/components/CompactSetCard.kt index 2661dea..74869e6 100644 --- a/app/src/main/java/com/example/android/january2022/ui/session/components/CompactSetCard.kt +++ b/app/src/main/java/com/example/android/january2022/ui/session/components/CompactSetCard.kt @@ -1,6 +1,12 @@ package com.example.android.january2022.ui.session.components -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.fillMaxHeight +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredHeight +import androidx.compose.foundation.layout.width import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface @@ -16,45 +22,46 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.example.android.january2022.db.entities.GymSet - @Composable fun CompactSetCard( - set: GymSet + set: GymSet, ) { - val reps = set.reps - val weight = set.weight - val repsText by remember { derivedStateOf { reps?.toString() ?: "0" } } - val weightText by remember { derivedStateOf { weight?.toString() ?: "0" } } + val reps = set.reps + val weight = set.weight + val repsText by remember { derivedStateOf { reps?.toString() ?: "0" } } + val weightText by remember { derivedStateOf { weight?.toString() ?: "0" } } - Row( - Modifier - .padding(horizontal = 8.dp, vertical = 4.dp) - .requiredHeight(48.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center - ) { - Surface( - modifier = Modifier - .fillMaxHeight(0.7f) - .padding(top = 1.dp) - .width(2.dp), - color = setTypeColor(set.setType, MaterialTheme.colorScheme) - ) {} - Column(Modifier.padding(start = 4.dp)) { - Row { - Text(text = repsText, fontWeight = FontWeight.Bold) - Text( - text = "reps", - fontSize = 10.sp, - color = LocalContentColor.current.copy(alpha = 0.85f) - ) - } - Row { - Text(text = weightText, fontWeight = FontWeight.Bold) - Text( - text = "kg", fontSize = 10.sp, color = LocalContentColor.current.copy(alpha = 0.85f) - ) - } + Row( + Modifier + .padding(horizontal = 8.dp, vertical = 4.dp) + .requiredHeight(48.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + ) { + Surface( + modifier = Modifier + .fillMaxHeight(0.7f) + .padding(top = 1.dp) + .width(2.dp), + color = setTypeColor(set.setType, MaterialTheme.colorScheme), + ) {} + Column(Modifier.padding(start = 4.dp)) { + Row { + Text(text = repsText, fontWeight = FontWeight.Bold) + Text( + text = "reps", + fontSize = 10.sp, + color = LocalContentColor.current.copy(alpha = 0.85f), + ) + } + Row { + Text(text = weightText, fontWeight = FontWeight.Bold) + Text( + text = "kg", + fontSize = 10.sp, + color = LocalContentColor.current.copy(alpha = 0.85f), + ) + } + } } - } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/example/android/january2022/ui/session/components/DeletionAlertDialog.kt b/app/src/main/java/com/example/android/january2022/ui/session/components/DeletionAlertDialog.kt index de2bd24..b71bc4a 100644 --- a/app/src/main/java/com/example/android/january2022/ui/session/components/DeletionAlertDialog.kt +++ b/app/src/main/java/com/example/android/january2022/ui/session/components/DeletionAlertDialog.kt @@ -8,24 +8,24 @@ import androidx.compose.runtime.Composable @Composable fun DeletionAlertDialog( - onDismiss: () -> Unit, - onDelete: () -> Unit, - title: @Composable () -> Unit, - text: @Composable () -> Unit + onDismiss: () -> Unit, + onDelete: () -> Unit, + title: @Composable () -> Unit, + text: @Composable () -> Unit, ) { - AlertDialog( - onDismissRequest = onDismiss, - confirmButton = { - Button(onClick = onDelete) { - Text(text = "Delete") - } - }, - dismissButton = { - TextButton(onClick = onDismiss) { - Text(text = "Cancel") - } - }, - title = title, - text = text - ) -} \ No newline at end of file + AlertDialog( + onDismissRequest = onDismiss, + confirmButton = { + Button(onClick = onDelete) { + Text(text = "Delete") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(text = "Cancel") + } + }, + title = title, + text = text, + ) +} diff --git a/app/src/main/java/com/example/android/january2022/ui/session/components/ExerciseCard.kt b/app/src/main/java/com/example/android/january2022/ui/session/components/ExerciseCard.kt index ef9749c..a8df042 100644 --- a/app/src/main/java/com/example/android/january2022/ui/session/components/ExerciseCard.kt +++ b/app/src/main/java/com/example/android/january2022/ui/session/components/ExerciseCard.kt @@ -1,20 +1,39 @@ package com.example.android.january2022.ui.session.components -import androidx.compose.animation.* +import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.interaction.MutableInteractionSource -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.padding +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ChevronRight import androidx.compose.material.ripple.rememberRipple -import androidx.compose.material3.* -import androidx.compose.runtime.* +import androidx.compose.material3.Icon +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +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 @@ -34,130 +53,130 @@ import com.example.android.january2022.utils.Event @OptIn(ExperimentalFoundationApi::class) @Composable fun ExerciseCard( - exerciseWrapper: ExerciseWrapper, - expanded: Boolean = false, - selected: Boolean = false, - onEvent: (Event) -> Unit, - onLongClick: () -> Unit, - onSetDeleted: (GymSet) -> Unit, - onClick: () -> Unit + exerciseWrapper: ExerciseWrapper, + expanded: Boolean = false, + selected: Boolean = false, + onEvent: (Event) -> Unit, + onLongClick: () -> Unit, + onSetDeleted: (GymSet) -> Unit, + onClick: () -> Unit, ) { - val exercise = exerciseWrapper.exercise - val sets = exerciseWrapper.sets - val tonalElevation by animateDpAsState(targetValue = if (selected) 2.dp else 0.dp) - val localHaptic = LocalHapticFeedback.current + val exercise = exerciseWrapper.exercise + val sets = exerciseWrapper.sets + val tonalElevation by animateDpAsState(targetValue = if (selected) 2.dp else 0.dp) + val localHaptic = LocalHapticFeedback.current - Surface( - tonalElevation = tonalElevation, - shape = MaterialTheme.shapes.medium, - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 8.dp, horizontal = 8.dp) - .clip(MaterialTheme.shapes.medium) - .combinedClickable( - interactionSource = remember { MutableInteractionSource() }, - indication = rememberRipple(bounded = true), - onLongClick = { - localHaptic.performHapticFeedback(HapticFeedbackType.LongPress) - onLongClick() - }, - onClick = onClick - ) - ) { - Column( - Modifier - .padding(top = 12.dp, bottom = 6.dp) - .fillMaxWidth() + Surface( + tonalElevation = tonalElevation, + shape = MaterialTheme.shapes.medium, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp, horizontal = 8.dp) + .clip(MaterialTheme.shapes.medium) + .combinedClickable( + interactionSource = remember { MutableInteractionSource() }, + indication = rememberRipple(bounded = true), + onLongClick = { + localHaptic.performHapticFeedback(HapticFeedbackType.LongPress) + onLongClick() + }, + onClick = onClick, + ), ) { - Text( - text = exercise.title, - modifier = Modifier.padding(horizontal = 12.dp), - style = MaterialTheme.typography.titleMedium - ) - Spacer(Modifier.height(4.dp)) - AnimatedVisibility(expanded) { - ExpandedExerciseContent( - sets = sets, - onEvent = onEvent, - onSetCreated = { - onEvent(SessionEvent.SetCreated(exerciseWrapper)) - }, - onSetDeleted = onSetDeleted - ) - } - AnimatedVisibility(!expanded) { - val listState = rememberLazyListState() - val width by remember { - derivedStateOf { listState.layoutInfo.viewportSize.width } - } - val startWidth = 50f - val endWidth by animateFloatAsState( - targetValue = width.toFloat() - if (listState.canScrollForward) 225f else startWidth - ) - Box( - contentAlignment = Alignment.CenterEnd + Column( + Modifier + .padding(top = 12.dp, bottom = 6.dp) + .fillMaxWidth(), ) { - LazyRow( - modifier = Modifier - .fillMaxWidth() - .graphicsLayer { alpha = 0.99f } - .drawWithContent { - val colors = listOf(Color.Black, Color.Transparent) - drawContent() - drawRect( - brush = Brush.horizontalGradient( - colors = colors, - startX = endWidth - ), - blendMode = BlendMode.DstIn + Text( + text = exercise.title, + modifier = Modifier.padding(horizontal = 12.dp), + style = MaterialTheme.typography.titleMedium, + ) + Spacer(Modifier.height(4.dp)) + AnimatedVisibility(expanded) { + ExpandedExerciseContent( + sets = sets, + onEvent = onEvent, + onSetCreated = { + onEvent(SessionEvent.SetCreated(exerciseWrapper)) + }, + onSetDeleted = onSetDeleted, ) - drawRect( - brush = Brush.horizontalGradient( - colors = colors.reversed(), - endX = startWidth - ), - blendMode = BlendMode.DstIn - ) - }, - state = listState - ) { - item { - Spacer(Modifier.width(12.dp)) } - items(sets) { set -> - CompactSetCard(set) + AnimatedVisibility(!expanded) { + val listState = rememberLazyListState() + val width by remember { + derivedStateOf { listState.layoutInfo.viewportSize.width } + } + val startWidth = 50f + val endWidth by animateFloatAsState( + targetValue = width.toFloat() - if (listState.canScrollForward) 225f else startWidth, + ) + Box( + contentAlignment = Alignment.CenterEnd, + ) { + LazyRow( + modifier = Modifier + .fillMaxWidth() + .graphicsLayer { alpha = 0.99f } + .drawWithContent { + val colors = listOf(Color.Black, Color.Transparent) + drawContent() + drawRect( + brush = Brush.horizontalGradient( + colors = colors, + startX = endWidth, + ), + blendMode = BlendMode.DstIn, + ) + drawRect( + brush = Brush.horizontalGradient( + colors = colors.reversed(), + endX = startWidth, + ), + blendMode = BlendMode.DstIn, + ) + }, + state = listState, + ) { + item { + Spacer(Modifier.width(12.dp)) + } + items(sets) { set -> + CompactSetCard(set) + } + } + Column { + AnimatedVisibility( + visible = listState.canScrollForward, + enter = slideInHorizontally(initialOffsetX = { it / 2 }) + fadeIn(), + exit = slideOutHorizontally(targetOffsetX = { it / 2 }) + fadeOut(), + ) { + Icon( + imageVector = Icons.Default.ChevronRight, + contentDescription = "More sets in list.", + modifier = Modifier.padding(end = 8.dp), + tint = LocalContentColor.current.copy(alpha = 0.5f), + ) + } + } + } } - } - Column { - AnimatedVisibility( - visible = listState.canScrollForward, - enter = slideInHorizontally(initialOffsetX = { it / 2 }) + fadeIn(), - exit = slideOutHorizontally(targetOffsetX = { it / 2 }) + fadeOut() + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start, ) { - Icon( - imageVector = Icons.Default.ChevronRight, - contentDescription = "More sets in list.", - modifier = Modifier.padding(end = 8.dp), - tint = LocalContentColor.current.copy(alpha = 0.5f) - ) + exercise.getMuscleGroups().forEach { + SmallPill(text = it, modifier = Modifier.padding(end = 4.dp)) + } + exercise.equipment.forEach { eq -> + SmallPill(text = eq, modifier = Modifier.padding(end = 4.dp)) + } } - } - } - } - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 4.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Start - ) { - exercise.getMuscleGroups().forEach { - SmallPill(text = it, modifier = Modifier.padding(end = 4.dp)) - } - exercise.equipment.forEach { eq -> - SmallPill(text = eq, modifier = Modifier.padding(end = 4.dp)) } - } } - } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/example/android/january2022/ui/session/components/ExpandedExerciseContent.kt b/app/src/main/java/com/example/android/january2022/ui/session/components/ExpandedExerciseContent.kt index 9fb3036..7bb9468 100644 --- a/app/src/main/java/com/example/android/january2022/ui/session/components/ExpandedExerciseContent.kt +++ b/app/src/main/java/com/example/android/january2022/ui/session/components/ExpandedExerciseContent.kt @@ -1,13 +1,24 @@ package com.example.android.january2022.ui.session.components import androidx.compose.foundation.clickable -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.defaultMinSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Close -import androidx.compose.material3.* +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -25,117 +36,117 @@ import com.example.android.january2022.utils.Event @Composable fun ExpandedExerciseContent( - sets: List, - onEvent: (Event) -> Unit, - onSetDeleted: (GymSet) -> Unit, - onSetCreated: () -> Unit + sets: List, + onEvent: (Event) -> Unit, + onSetDeleted: (GymSet) -> Unit, + onSetCreated: () -> Unit, ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(top = 8.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - sets.forEach { set -> - val localFocusManager = LocalFocusManager.current - val reps = set.reps ?: "" - val weight = set.weight ?: "" - - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, + Column( modifier = Modifier - .padding(bottom = 4.dp, start = 6.dp, end = 8.dp) - .fillMaxWidth() - .clickable { } - ) { - IconButton( - onClick = { onSetDeleted(set) }, - modifier = Modifier.padding(end = 8.dp) - ) { - Icon( - imageVector = Icons.Default.Close, - contentDescription = "Delete Set", - tint = LocalContentColor.current.copy(alpha = 0.75f) - ) - } - InputField( - label = "reps", - initialValue = reps.toString(), - onValueChange = { - val tfv = it.text.trim().toIntOrNull() - if (tfv != null) { - onEvent(SessionEvent.SetChanged(set.copy(reps = tfv))) - true - } else { - false - } - }, - keyboardActions = KeyboardActions( - onNext = { localFocusManager.moveFocus(FocusDirection.Next) } - ), - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Number, - imeAction = ImeAction.Next - ), - autoRequestFocus = true - ) - InputField( - label = "kg", - initialValue = weight.toString(), - onValueChange = { - val tfv = it.text.trim().toFloatOrNull() - if (tfv != null) { - onEvent(SessionEvent.SetChanged(set.copy(weight = tfv))) - true - } else { - false - } - }, - keyboardActions = KeyboardActions( - onDone = { - localFocusManager.moveFocus(FocusDirection.Next) - localFocusManager.clearFocus() + .fillMaxWidth() + .padding(top = 8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + sets.forEach { set -> + val localFocusManager = LocalFocusManager.current + val reps = set.reps ?: "" + val weight = set.weight ?: "" + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier + .padding(bottom = 4.dp, start = 6.dp, end = 8.dp) + .fillMaxWidth() + .clickable { }, + ) { + IconButton( + onClick = { onSetDeleted(set) }, + modifier = Modifier.padding(end = 8.dp), + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = "Delete Set", + tint = LocalContentColor.current.copy(alpha = 0.75f), + ) + } + InputField( + label = "reps", + initialValue = reps.toString(), + onValueChange = { + val tfv = it.text.trim().toIntOrNull() + if (tfv != null) { + onEvent(SessionEvent.SetChanged(set.copy(reps = tfv))) + true + } else { + false + } + }, + keyboardActions = KeyboardActions( + onNext = { localFocusManager.moveFocus(FocusDirection.Next) }, + ), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Number, + imeAction = ImeAction.Next, + ), + autoRequestFocus = true, + ) + InputField( + label = "kg", + initialValue = weight.toString(), + onValueChange = { + val tfv = it.text.trim().toFloatOrNull() + if (tfv != null) { + onEvent(SessionEvent.SetChanged(set.copy(weight = tfv))) + true + } else { + false + } + }, + keyboardActions = KeyboardActions( + onDone = { + localFocusManager.moveFocus(FocusDirection.Next) + localFocusManager.clearFocus() + }, + ), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Number, + imeAction = ImeAction.Done, + ), + ) + Surface( + onClick = { + onEvent(SessionEvent.SetChanged(set.copy(setType = SetType.next(set.setType)))) + }, + color = setTypeColor(set.setType, MaterialTheme.colorScheme), + shape = MaterialTheme.shapes.small, + modifier = Modifier + .defaultMinSize(minWidth = 100.dp) + .padding(start = 12.dp), + ) { + Text( + text = set.setType, + modifier = Modifier.padding(6.dp), + textAlign = TextAlign.Center, + ) + } } - ), - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Number, - imeAction = ImeAction.Done - ) - ) - Surface( - onClick = { - onEvent(SessionEvent.SetChanged(set.copy(setType = SetType.next(set.setType)))) - }, - color = setTypeColor(set.setType, MaterialTheme.colorScheme), - shape = MaterialTheme.shapes.small, - modifier = Modifier - .defaultMinSize(minWidth = 100.dp) - .padding(start = 12.dp) + } + IconButton( + onClick = { onSetCreated() }, ) { - Text( - text = set.setType, - modifier = Modifier.padding(6.dp), - textAlign = TextAlign.Center - ) + Icon(imageVector = Icons.Default.Add, contentDescription = "Add new set") } - } } - IconButton( - onClick = { onSetCreated() } - ) { - Icon(imageVector = Icons.Default.Add, contentDescription = "Add new set") - } - } } fun setTypeColor(setType: String, colorScheme: ColorScheme): Color { - return when (setType) { - SetType.WARMUP -> Color(0xFF7A7272) - SetType.EASY -> Color(0xFF6A9E44) - SetType.NORMAL -> colorScheme.primary - SetType.HARD -> Color(0xFFB84733) - SetType.DROP -> Color(0xFFAD49A8) - else -> Color.White - } + return when (setType) { + SetType.WARMUP -> Color(0xFF7A7272) + SetType.EASY -> Color(0xFF6A9E44) + SetType.NORMAL -> colorScheme.primary + SetType.HARD -> Color(0xFFB84733) + SetType.DROP -> Color(0xFFAD49A8) + else -> Color.White + } } diff --git a/app/src/main/java/com/example/android/january2022/ui/session/components/InputField.kt b/app/src/main/java/com/example/android/january2022/ui/session/components/InputField.kt index 33daea5..cbeff59 100644 --- a/app/src/main/java/com/example/android/january2022/ui/session/components/InputField.kt +++ b/app/src/main/java/com/example/android/january2022/ui/session/components/InputField.kt @@ -2,12 +2,22 @@ package com.example.android.january2022.ui.session.components import androidx.compose.animation.animateColorAsState import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +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.focus.FocusRequester @@ -24,67 +34,67 @@ import androidx.compose.ui.unit.sp @Composable fun InputField( - label: String, - initialValue: String, - onValueChange: (TextFieldValue) -> Boolean, - keyboardActions: KeyboardActions, - keyboardOptions: KeyboardOptions, - autoRequestFocus: Boolean = false + label: String, + initialValue: String, + onValueChange: (TextFieldValue) -> Boolean, + keyboardActions: KeyboardActions, + keyboardOptions: KeyboardOptions, + autoRequestFocus: Boolean = false, ) { - val initialText = initialValue.toFloatOrNull().let { - if (it == null || it < 0) "" else initialValue - } - val requester = remember { FocusRequester() } - var textValidation by remember { mutableStateOf(true) } - val selection = remember { mutableStateOf(TextRange(100)) } - var textFieldValue by remember { - val tfv = TextFieldValue(text = initialText, selection = selection.value) - mutableStateOf(tfv) - } - val textColor by animateColorAsState( - targetValue = if (textValidation) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.error - ) - DisposableEffect(Unit) { - if (autoRequestFocus && initialText.isEmpty()) requester.requestFocus() - onDispose { } - } - - Row( - modifier = Modifier - .clickable { - requester.requestFocus() - } - .height(40.dp) - .defaultMinSize(minWidth = 60.dp) - .padding(start = 8.dp, end = 6.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center - ) { - BasicTextField( - value = textFieldValue, - onValueChange = { - textFieldValue = it - textValidation = onValueChange(textFieldValue) - }, - textStyle = TextStyle( - color = textColor, - fontSize = 21.sp, - fontWeight = FontWeight.Bold, - textAlign = TextAlign.End - ), - keyboardOptions = keyboardOptions, - keyboardActions = keyboardActions, - cursorBrush = SolidColor(MaterialTheme.colorScheme.onSurface), - modifier = Modifier - .width(60.dp) - .focusRequester(requester) - .onFocusChanged { - // reset cursor position when receiving focus - if (it.hasFocus || it.isFocused) { - textFieldValue = TextFieldValue(text = textFieldValue.text, selection = selection.value) - } - } + val initialText = initialValue.toFloatOrNull().let { + if (it == null || it < 0) "" else initialValue + } + val requester = remember { FocusRequester() } + var textValidation by remember { mutableStateOf(true) } + val selection = remember { mutableStateOf(TextRange(100)) } + var textFieldValue by remember { + val tfv = TextFieldValue(text = initialText, selection = selection.value) + mutableStateOf(tfv) + } + val textColor by animateColorAsState( + targetValue = if (textValidation) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.error, ) - InputLabel(text = label) - } -} \ No newline at end of file + DisposableEffect(Unit) { + if (autoRequestFocus && initialText.isEmpty()) requester.requestFocus() + onDispose { } + } + + Row( + modifier = Modifier + .clickable { + requester.requestFocus() + } + .height(40.dp) + .defaultMinSize(minWidth = 60.dp) + .padding(start = 8.dp, end = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + ) { + BasicTextField( + value = textFieldValue, + onValueChange = { + textFieldValue = it + textValidation = onValueChange(textFieldValue) + }, + textStyle = TextStyle( + color = textColor, + fontSize = 21.sp, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.End, + ), + keyboardOptions = keyboardOptions, + keyboardActions = keyboardActions, + cursorBrush = SolidColor(MaterialTheme.colorScheme.onSurface), + modifier = Modifier + .width(60.dp) + .focusRequester(requester) + .onFocusChanged { + // reset cursor position when receiving focus + if (it.hasFocus || it.isFocused) { + textFieldValue = TextFieldValue(text = textFieldValue.text, selection = selection.value) + } + }, + ) + InputLabel(text = label) + } +} diff --git a/app/src/main/java/com/example/android/january2022/ui/session/components/InputLabel.kt b/app/src/main/java/com/example/android/january2022/ui/session/components/InputLabel.kt index cef3b4d..940f55f 100644 --- a/app/src/main/java/com/example/android/january2022/ui/session/components/InputLabel.kt +++ b/app/src/main/java/com/example/android/january2022/ui/session/components/InputLabel.kt @@ -11,12 +11,12 @@ import androidx.compose.ui.unit.dp @Composable fun InputLabel(text: String) { - Text( - text = text, - color = LocalContentColor.current.copy(alpha = 0.8f), - style = MaterialTheme.typography.labelSmall, - modifier = Modifier - .padding(start = 4.dp, top = 6.dp) - .fillMaxHeight() - ) -} \ No newline at end of file + Text( + text = text, + color = LocalContentColor.current.copy(alpha = 0.8f), + style = MaterialTheme.typography.labelSmall, + modifier = Modifier + .padding(start = 4.dp, top = 6.dp) + .fillMaxHeight(), + ) +} diff --git a/app/src/main/java/com/example/android/january2022/ui/session/components/SessionAppBar.kt b/app/src/main/java/com/example/android/january2022/ui/session/components/SessionAppBar.kt index 46e7bee..4f19825 100644 --- a/app/src/main/java/com/example/android/january2022/ui/session/components/SessionAppBar.kt +++ b/app/src/main/java/com/example/android/january2022/ui/session/components/SessionAppBar.kt @@ -7,38 +7,36 @@ import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable -import androidx.compose.ui.graphics.Color import com.example.android.january2022.ui.TimerState import com.example.android.january2022.ui.session.actions.ActionSpacer import com.example.android.january2022.ui.session.actions.ActionSpacerStart import com.example.android.january2022.ui.session.actions.MenuAction import com.example.android.january2022.ui.session.actions.TimerAction -import com.example.android.january2022.utils.Event @Composable fun SessionAppBar( - onDeleteSession: () -> Unit, - timerState: TimerState, - timerVisible: Boolean, - onTimerPress: () -> Unit, - onFAB: () -> Unit + onDeleteSession: () -> Unit, + timerState: TimerState, + timerVisible: Boolean, + onTimerPress: () -> Unit, + onFAB: () -> Unit, ) { - BottomAppBar( - actions = { - ActionSpacerStart() - MenuAction( - onDelete = onDeleteSession, - ) - ActionSpacer() - TimerAction(timerState, timerVisible, onTimerPress) - }, - floatingActionButton = { - FloatingActionButton( - onClick = { onFAB() }, - containerColor = MaterialTheme.colorScheme.primary - ) { - Icon(imageVector = Icons.Default.Add, contentDescription = "Add Exercise") - } - } - ) -} \ No newline at end of file + BottomAppBar( + actions = { + ActionSpacerStart() + MenuAction( + onDelete = onDeleteSession, + ) + ActionSpacer() + TimerAction(timerState, timerVisible, onTimerPress) + }, + floatingActionButton = { + FloatingActionButton( + onClick = { onFAB() }, + containerColor = MaterialTheme.colorScheme.primary, + ) { + Icon(imageVector = Icons.Default.Add, contentDescription = "Add Exercise") + } + }, + ) +} diff --git a/app/src/main/java/com/example/android/january2022/ui/session/components/SessionAppBarExpanded.kt b/app/src/main/java/com/example/android/january2022/ui/session/components/SessionAppBarExpanded.kt index d73f9b8..6ab0dd5 100644 --- a/app/src/main/java/com/example/android/january2022/ui/session/components/SessionAppBarExpanded.kt +++ b/app/src/main/java/com/example/android/january2022/ui/session/components/SessionAppBarExpanded.kt @@ -4,29 +4,33 @@ import androidx.compose.material3.BottomAppBar import androidx.compose.runtime.Composable import com.example.android.january2022.ui.TimerState import com.example.android.january2022.ui.session.SessionEvent -import com.example.android.january2022.ui.session.actions.* -import com.example.android.january2022.utils.Event +import com.example.android.january2022.ui.session.actions.ActionSpacer +import com.example.android.january2022.ui.session.actions.ActionSpacerStart +import com.example.android.january2022.ui.session.actions.MenuAction +import com.example.android.january2022.ui.session.actions.OpenInNewAction +import com.example.android.january2022.ui.session.actions.OpenStatsAction +import com.example.android.january2022.ui.session.actions.TimerAction @Composable fun SessionAppBarExpanded( - onEvent: (SessionEvent) -> Unit, - onDeleteSession: () -> Unit, - timerState: TimerState, - timerVisible: Boolean, - onTimerPress: () -> Unit, + onEvent: (SessionEvent) -> Unit, + onDeleteSession: () -> Unit, + timerState: TimerState, + timerVisible: Boolean, + onTimerPress: () -> Unit, ) { - BottomAppBar( - actions = { - ActionSpacerStart() - MenuAction( - onDelete = onDeleteSession, - ) - ActionSpacer() - TimerAction(timerState = timerState, timerVisible = timerVisible) { onTimerPress() } - ActionSpacer() - OpenInNewAction { onEvent(SessionEvent.OpenGuide) } - ActionSpacer() - OpenStatsAction { /* TODO */ } - } - ) -} \ No newline at end of file + BottomAppBar( + actions = { + ActionSpacerStart() + MenuAction( + onDelete = onDeleteSession, + ) + ActionSpacer() + TimerAction(timerState = timerState, timerVisible = timerVisible) { onTimerPress() } + ActionSpacer() + OpenInNewAction { onEvent(SessionEvent.OpenGuide) } + ActionSpacer() + OpenStatsAction { /* TODO */ } + }, + ) +} diff --git a/app/src/main/java/com/example/android/january2022/ui/session/components/SessionAppBarSelected.kt b/app/src/main/java/com/example/android/january2022/ui/session/components/SessionAppBarSelected.kt index 44badcb..a630ee2 100644 --- a/app/src/main/java/com/example/android/january2022/ui/session/components/SessionAppBarSelected.kt +++ b/app/src/main/java/com/example/android/january2022/ui/session/components/SessionAppBarSelected.kt @@ -1,6 +1,5 @@ package com.example.android.january2022.ui.session.components - import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Deselect import androidx.compose.material.icons.outlined.Delete @@ -15,34 +14,33 @@ import com.example.android.january2022.ui.session.actions.ActionSpacer import com.example.android.january2022.ui.session.actions.ActionSpacerStart import com.example.android.january2022.ui.session.actions.MenuAction import com.example.android.january2022.ui.session.actions.TimerAction -import com.example.android.january2022.utils.Event @Composable fun SessionAppBarSelected( - onEvent: (SessionEvent) -> Unit, - onDeleteSession: () -> Unit, - onDeleteExercise: () -> Unit, - timerState: TimerState, - timerVisible: Boolean, - onTimerPress: () -> Unit, + onEvent: (SessionEvent) -> Unit, + onDeleteSession: () -> Unit, + onDeleteExercise: () -> Unit, + timerState: TimerState, + timerVisible: Boolean, + onTimerPress: () -> Unit, ) { - BottomAppBar( - actions = { - ActionSpacerStart() - MenuAction( - onDelete = onDeleteSession, - ) - ActionSpacer() - TimerAction(timerState = timerState, timerVisible = timerVisible) { onTimerPress() } - ActionSpacer() - IconButton(onClick = onDeleteExercise) { - Icon(imageVector = Icons.Outlined.Delete, contentDescription = "Toggle set deletion mode.") - } - }, - floatingActionButton = { - FloatingActionButton(onClick = { onEvent(SessionEvent.DeselectExercises) }) { - Icon(imageVector = Icons.Default.Deselect, contentDescription = "Deselect selected exercises.") - } - } - ) -} \ No newline at end of file + BottomAppBar( + actions = { + ActionSpacerStart() + MenuAction( + onDelete = onDeleteSession, + ) + ActionSpacer() + TimerAction(timerState = timerState, timerVisible = timerVisible) { onTimerPress() } + ActionSpacer() + IconButton(onClick = onDeleteExercise) { + Icon(imageVector = Icons.Outlined.Delete, contentDescription = "Toggle set deletion mode.") + } + }, + floatingActionButton = { + FloatingActionButton(onClick = { onEvent(SessionEvent.DeselectExercises) }) { + Icon(imageVector = Icons.Default.Deselect, contentDescription = "Deselect selected exercises.") + } + }, + ) +} diff --git a/app/src/main/java/com/example/android/january2022/ui/session/components/SessionHeader.kt b/app/src/main/java/com/example/android/january2022/ui/session/components/SessionHeader.kt index e789a8f..0083fd6 100644 --- a/app/src/main/java/com/example/android/january2022/ui/session/components/SessionHeader.kt +++ b/app/src/main/java/com/example/android/january2022/ui/session/components/SessionHeader.kt @@ -1,7 +1,14 @@ package com.example.android.january2022.ui.session.components import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyListState import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -19,92 +26,92 @@ import java.time.format.DateTimeFormatter @Composable fun SessionHeader( - sessionWrapper: SessionWrapper, - muscleGroups: List, - scrollState: LazyListState, - height: Dp, - topPadding: Dp, - onEndTime: () -> Unit, - onStartTime: () -> Unit + sessionWrapper: SessionWrapper, + muscleGroups: List, + scrollState: LazyListState, + height: Dp, + topPadding: Dp, + onEndTime: () -> Unit, + onStartTime: () -> Unit, ) { - val session = sessionWrapper.session - val startTime = DateTimeFormatter.ofPattern("HH:mm").format(session.start) - val endTime = session.end?.let { DateTimeFormatter.ofPattern("HH:mm").format(it) } ?: "ongoing" + val session = sessionWrapper.session + val startTime = DateTimeFormatter.ofPattern("HH:mm").format(session.start) + val endTime = session.end?.let { DateTimeFormatter.ofPattern("HH:mm").format(it) } ?: "ongoing" - Box( - modifier = Modifier - .padding( - start = 12.dp, - top = topPadding, - end = 12.dp - ) - .height(height) - .fillMaxWidth() - .graphicsLayer { - val scroll = if(scrollState.layoutInfo.visibleItemsInfo.firstOrNull()?.index == 0) { - scrollState.firstVisibleItemScrollOffset.toFloat() - } else { - 10000f - } - translationY = scroll / 3f // Parallax effect - alpha = 1 - scroll / 250f // Fade out text - scaleX = 1 - scroll / 3000f - scaleY = 1 - scroll / 3000f - } - ) { - Column( - modifier = Modifier.fillMaxSize(), - verticalArrangement = Arrangement.Bottom - ) { - Text( - text = session.toSessionTitle(), - style = MaterialTheme.typography.headlineLarge - ) - Row( - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, + Box( modifier = Modifier - .fillMaxWidth() - .padding(8.dp) - ) { - Row( - modifier = Modifier.fillMaxWidth(0.5f) - ) { - Text( - text = startTime, - color = MaterialTheme.colorScheme.secondary, - style = MaterialTheme.typography.headlineSmall, - modifier = Modifier - .padding(start = 4.dp) - .clickable { - onStartTime() - } - ) - Text( - text = "-", - color = MaterialTheme.colorScheme.secondary, - style = MaterialTheme.typography.headlineSmall, - modifier = Modifier.padding(start = 4.dp) - ) - Text( - text = endTime, - color = MaterialTheme.colorScheme.secondary, - style = MaterialTheme.typography.headlineSmall, - modifier = Modifier - .padding(start = 4.dp) - .clickable { - onEndTime() - } - ) - } - FlowRow( - mainAxisAlignment = FlowMainAxisAlignment.Center + .padding( + start = 12.dp, + top = topPadding, + end = 12.dp, + ) + .height(height) + .fillMaxWidth() + .graphicsLayer { + val scroll = if (scrollState.layoutInfo.visibleItemsInfo.firstOrNull()?.index == 0) { + scrollState.firstVisibleItemScrollOffset.toFloat() + } else { + 10000f + } + translationY = scroll / 3f // Parallax effect + alpha = 1 - scroll / 250f // Fade out text + scaleX = 1 - scroll / 3000f + scaleY = 1 - scroll / 3000f + }, + ) { + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Bottom, ) { - muscleGroups.forEach { muscle -> - SmallPill(text = muscle, modifier = Modifier.padding(4.dp)) - } + Text( + text = session.toSessionTitle(), + style = MaterialTheme.typography.headlineLarge, + ) + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding(8.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(0.5f), + ) { + Text( + text = startTime, + color = MaterialTheme.colorScheme.secondary, + style = MaterialTheme.typography.headlineSmall, + modifier = Modifier + .padding(start = 4.dp) + .clickable { + onStartTime() + }, + ) + Text( + text = "-", + color = MaterialTheme.colorScheme.secondary, + style = MaterialTheme.typography.headlineSmall, + modifier = Modifier.padding(start = 4.dp), + ) + Text( + text = endTime, + color = MaterialTheme.colorScheme.secondary, + style = MaterialTheme.typography.headlineSmall, + modifier = Modifier + .padding(start = 4.dp) + .clickable { + onEndTime() + }, + ) + } + FlowRow( + mainAxisAlignment = FlowMainAxisAlignment.Center, + ) { + muscleGroups.forEach { muscle -> + SmallPill(text = muscle, modifier = Modifier.padding(4.dp)) + } + } + } } - } } - } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/example/android/january2022/ui/session/components/SmallPill.kt b/app/src/main/java/com/example/android/january2022/ui/session/components/SmallPill.kt index bf9ac90..d0e6147 100644 --- a/app/src/main/java/com/example/android/january2022/ui/session/components/SmallPill.kt +++ b/app/src/main/java/com/example/android/january2022/ui/session/components/SmallPill.kt @@ -12,15 +12,15 @@ import com.example.android.january2022.ui.theme.Shapes @Composable fun SmallPill(text: String, modifier: Modifier = Modifier) { - Surface( - shape = Shapes.small, - tonalElevation = LocalAbsoluteTonalElevation.current + 1.dp, - modifier = modifier - ) { - Text( - text = text.uppercase(), - style = MaterialTheme.typography.labelSmall, - modifier = Modifier.padding(4.dp) - ) - } -} \ No newline at end of file + Surface( + shape = Shapes.small, + tonalElevation = LocalAbsoluteTonalElevation.current + 1.dp, + modifier = modifier, + ) { + Text( + text = text.uppercase(), + style = MaterialTheme.typography.labelSmall, + modifier = Modifier.padding(4.dp), + ) + } +} diff --git a/app/src/main/java/com/example/android/january2022/ui/session/components/TimerBar.kt b/app/src/main/java/com/example/android/january2022/ui/session/components/TimerBar.kt index 29ec346..3da9938 100644 --- a/app/src/main/java/com/example/android/january2022/ui/session/components/TimerBar.kt +++ b/app/src/main/java/com/example/android/january2022/ui/session/components/TimerBar.kt @@ -1,9 +1,20 @@ package com.example.android.january2022.ui.session.components import androidx.compose.animation.core.animateDpAsState -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.* +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Pause +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.Remove import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.Surface @@ -22,65 +33,65 @@ import com.example.android.january2022.utils.Event @Composable fun TimerBar( - timerState: TimerState, - onEvent: (Event) -> Unit + timerState: TimerState, + onEvent: (Event) -> Unit, ) { - val timerTime = timerState.time - val timerRunning = timerState.running - val timerMaxTime = timerState.maxTime - val maxWidth = LocalConfiguration.current.screenWidthDp - val timerWidth = maxWidth.times(timerTime.toFloat() / timerMaxTime).toInt().dp - val timerToggleIcon = if (timerRunning) Icons.Default.Pause else Icons.Default.PlayArrow - val timerTimeText = - if (timerTime > 0L) timerTime.toTimerString() else timerMaxTime.toTimerString() - val timerTonalElevation by animateDpAsState(targetValue = if (timerRunning) 140.dp else 14.dp) + val timerTime = timerState.time + val timerRunning = timerState.running + val timerMaxTime = timerState.maxTime + val maxWidth = LocalConfiguration.current.screenWidthDp + val timerWidth = maxWidth.times(timerTime.toFloat() / timerMaxTime).toInt().dp + val timerToggleIcon = if (timerRunning) Icons.Default.Pause else Icons.Default.PlayArrow + val timerTimeText = + if (timerTime > 0L) timerTime.toTimerString() else timerMaxTime.toTimerString() + val timerTonalElevation by animateDpAsState(targetValue = if (timerRunning) 140.dp else 14.dp) - Surface( - modifier = Modifier - .fillMaxWidth() - .height(50.dp), - tonalElevation = 8.dp - ) { - Box { - Surface( + Surface( modifier = Modifier - .width(timerWidth) - .height(50.dp), - tonalElevation = timerTonalElevation - ) {} - Row( - modifier = Modifier - .fillMaxSize() - .padding(horizontal = 12.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Row( - verticalAlignment = Alignment.CenterVertically - ) { - IconButton(onClick = { onEvent(SessionEvent.TimerDecreased) }) { - Icon(Icons.Default.Remove, "Decrease time") - } - Text( - text = timerTimeText, - textAlign = TextAlign.Center, - modifier = Modifier.width(50.dp) - ) - IconButton(onClick = { onEvent(SessionEvent.TimerIncreased) }) { - Icon(Icons.Default.Add, "Increase time") - } - } - Row( - verticalAlignment = Alignment.CenterVertically - ) { - IconButton(onClick = { onEvent(SessionEvent.TimerReset) }) { - Icon(Icons.Default.Refresh, "Reset Timer") - } - IconButton(onClick = { onEvent(SessionEvent.TimerToggled) }) { - Icon(timerToggleIcon, "Toggle Timer") - } + .fillMaxWidth() + .height(50.dp), + tonalElevation = 8.dp, + ) { + Box { + Surface( + modifier = Modifier + .width(timerWidth) + .height(50.dp), + tonalElevation = timerTonalElevation, + ) {} + Row( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton(onClick = { onEvent(SessionEvent.TimerDecreased) }) { + Icon(Icons.Default.Remove, "Decrease time") + } + Text( + text = timerTimeText, + textAlign = TextAlign.Center, + modifier = Modifier.width(50.dp), + ) + IconButton(onClick = { onEvent(SessionEvent.TimerIncreased) }) { + Icon(Icons.Default.Add, "Increase time") + } + } + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton(onClick = { onEvent(SessionEvent.TimerReset) }) { + Icon(Icons.Default.Refresh, "Reset Timer") + } + IconButton(onClick = { onEvent(SessionEvent.TimerToggled) }) { + Icon(timerToggleIcon, "Toggle Timer") + } + } + } } - } } - } } diff --git a/app/src/main/java/com/example/android/january2022/ui/settings/SettingsEvent.kt b/app/src/main/java/com/example/android/january2022/ui/settings/SettingsEvent.kt index 7de6737..539c3cc 100644 --- a/app/src/main/java/com/example/android/january2022/ui/settings/SettingsEvent.kt +++ b/app/src/main/java/com/example/android/january2022/ui/settings/SettingsEvent.kt @@ -5,8 +5,8 @@ import android.net.Uri import com.example.android.january2022.utils.Event sealed class SettingsEvent : Event { - data class ExportDatabase(val context: Context, val uri: Uri): SettingsEvent() - data class ImportDatabase(val context: Context, val uri: Uri): SettingsEvent() - object CreateFile: SettingsEvent() - object ClearDatabase: SettingsEvent() + data class ExportDatabase(val context: Context, val uri: Uri) : SettingsEvent() + data class ImportDatabase(val context: Context, val uri: Uri) : SettingsEvent() + object CreateFile : SettingsEvent() + object ClearDatabase : SettingsEvent() } diff --git a/app/src/main/java/com/example/android/january2022/ui/settings/SettingsScreen.kt b/app/src/main/java/com/example/android/january2022/ui/settings/SettingsScreen.kt index 67ac58d..4eda2cd 100644 --- a/app/src/main/java/com/example/android/january2022/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/com/example/android/january2022/ui/settings/SettingsScreen.kt @@ -20,61 +20,61 @@ import com.example.android.january2022.utils.UiEvent @Composable fun SettingsScreen( - viewModel: SettingsViewModel = hiltViewModel() + viewModel: SettingsViewModel = hiltViewModel(), ) { - val mContext = LocalContext.current - val exportLauncher = rememberLauncherForActivityResult( - contract = CreateDocument("application/json"), - onResult = { uri -> - uri?.let { - viewModel.onEvent(SettingsEvent.ExportDatabase(mContext, it)) - } - } - ) - val importLauncher = rememberLauncherForActivityResult( - contract = ActivityResultContracts.GetContent(), - onResult = { uri -> - uri?.let { - viewModel.onEvent(SettingsEvent.ImportDatabase(mContext, it)) - } - } - ) + val mContext = LocalContext.current + val exportLauncher = rememberLauncherForActivityResult( + contract = CreateDocument("application/json"), + onResult = { uri -> + uri?.let { + viewModel.onEvent(SettingsEvent.ExportDatabase(mContext, it)) + } + }, + ) + val importLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.GetContent(), + onResult = { uri -> + uri?.let { + viewModel.onEvent(SettingsEvent.ImportDatabase(mContext, it)) + } + }, + ) - LaunchedEffect(key1 = true) { - viewModel.uiEvent.collect { event -> - when (event) { - is UiEvent.FileCreated -> { - exportLauncher.launch(event.fileName) + LaunchedEffect(key1 = true) { + viewModel.uiEvent.collect { event -> + when (event) { + is UiEvent.FileCreated -> { + exportLauncher.launch(event.fileName) + } + else -> Unit + } } - else -> Unit - } } - } - Scaffold { padding -> - Column( - Modifier - .fillMaxSize() - .padding(padding), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text("Settings") - FilledTonalButton(onClick = { - viewModel.onEvent(SettingsEvent.CreateFile) - }) { - Text("Export Database") - } - FilledTonalButton(onClick = { - importLauncher.launch("application/json") - }) { - Text("Import Database") - } - FilledTonalButton(onClick = { - viewModel.onEvent(SettingsEvent.ClearDatabase) - }) { - Text("Delete Database") - } + Scaffold { padding -> + Column( + Modifier + .fillMaxSize() + .padding(padding), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text("Settings") + FilledTonalButton(onClick = { + viewModel.onEvent(SettingsEvent.CreateFile) + }) { + Text("Export Database") + } + FilledTonalButton(onClick = { + importLauncher.launch("application/json") + }) { + Text("Import Database") + } + FilledTonalButton(onClick = { + viewModel.onEvent(SettingsEvent.ClearDatabase) + }) { + Text("Delete Database") + } + } } - } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/example/android/january2022/ui/theme/Color.kt b/app/src/main/java/com/example/android/january2022/ui/theme/Color.kt index ebf7906..b96a278 100644 --- a/app/src/main/java/com/example/android/january2022/ui/theme/Color.kt +++ b/app/src/main/java/com/example/android/january2022/ui/theme/Color.kt @@ -64,7 +64,6 @@ val md_theme_dark_surfaceTint = Color(0xFFFFB957) val md_theme_dark_outlineVariant = Color(0xFF4F4539) val md_theme_dark_scrim = Color(0xFF000000) - val seed4 = Color(0xFFFFC881) val seed3 = Color(0xFFFDCA6C) val seed2 = Color(0xFFF6CF00) diff --git a/app/src/main/java/com/example/android/january2022/ui/theme/Shape.kt b/app/src/main/java/com/example/android/january2022/ui/theme/Shape.kt index 1aece24..10361a1 100644 --- a/app/src/main/java/com/example/android/january2022/ui/theme/Shape.kt +++ b/app/src/main/java/com/example/android/january2022/ui/theme/Shape.kt @@ -9,12 +9,12 @@ import androidx.compose.ui.unit.dp val Shapes = Shapes( small = RoundedCornerShape(5.dp), medium = RoundedCornerShape(10.dp), - large = RoundedCornerShape(20.dp) + large = RoundedCornerShape(20.dp), ) -fun CornerBasedShape.onlyTop() : CornerBasedShape { +fun CornerBasedShape.onlyTop(): CornerBasedShape { return copy( bottomStart = CornerSize(0.dp), - bottomEnd = CornerSize(0.dp) + bottomEnd = CornerSize(0.dp), ) -} \ No newline at end of file +} diff --git a/app/src/main/java/com/example/android/january2022/ui/theme/Theme.kt b/app/src/main/java/com/example/android/january2022/ui/theme/Theme.kt index 65d1074..6e555d4 100644 --- a/app/src/main/java/com/example/android/january2022/ui/theme/Theme.kt +++ b/app/src/main/java/com/example/android/january2022/ui/theme/Theme.kt @@ -1,82 +1,82 @@ package com.example.android.january2022.ui.theme import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.material3.* +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable import androidx.compose.runtime.SideEffect import androidx.compose.ui.graphics.Color import com.google.accompanist.systemuicontroller.rememberSystemUiController - private val LightColors = lightColorScheme( - primary = md_theme_light_primary, - onPrimary = md_theme_light_onPrimary, - primaryContainer = md_theme_light_primaryContainer, - onPrimaryContainer = md_theme_light_onPrimaryContainer, - secondary = md_theme_light_secondary, - onSecondary = md_theme_light_onSecondary, - secondaryContainer = md_theme_light_secondaryContainer, - onSecondaryContainer = md_theme_light_onSecondaryContainer, - tertiary = md_theme_light_tertiary, - onTertiary = md_theme_light_onTertiary, - tertiaryContainer = md_theme_light_tertiaryContainer, - onTertiaryContainer = md_theme_light_onTertiaryContainer, - error = md_theme_light_error, - errorContainer = md_theme_light_errorContainer, - onError = md_theme_light_onError, - onErrorContainer = md_theme_light_onErrorContainer, - background = md_theme_light_background, - onBackground = md_theme_light_onBackground, - surface = md_theme_light_surface, - onSurface = md_theme_light_onSurface, - surfaceVariant = md_theme_light_surfaceVariant, - onSurfaceVariant = md_theme_light_onSurfaceVariant, - outline = md_theme_light_outline, - inverseOnSurface = md_theme_light_inverseOnSurface, - inverseSurface = md_theme_light_inverseSurface, - inversePrimary = md_theme_light_inversePrimary, - surfaceTint = md_theme_light_surfaceTint, - outlineVariant = md_theme_light_outlineVariant, - scrim = md_theme_light_scrim, + primary = md_theme_light_primary, + onPrimary = md_theme_light_onPrimary, + primaryContainer = md_theme_light_primaryContainer, + onPrimaryContainer = md_theme_light_onPrimaryContainer, + secondary = md_theme_light_secondary, + onSecondary = md_theme_light_onSecondary, + secondaryContainer = md_theme_light_secondaryContainer, + onSecondaryContainer = md_theme_light_onSecondaryContainer, + tertiary = md_theme_light_tertiary, + onTertiary = md_theme_light_onTertiary, + tertiaryContainer = md_theme_light_tertiaryContainer, + onTertiaryContainer = md_theme_light_onTertiaryContainer, + error = md_theme_light_error, + errorContainer = md_theme_light_errorContainer, + onError = md_theme_light_onError, + onErrorContainer = md_theme_light_onErrorContainer, + background = md_theme_light_background, + onBackground = md_theme_light_onBackground, + surface = md_theme_light_surface, + onSurface = md_theme_light_onSurface, + surfaceVariant = md_theme_light_surfaceVariant, + onSurfaceVariant = md_theme_light_onSurfaceVariant, + outline = md_theme_light_outline, + inverseOnSurface = md_theme_light_inverseOnSurface, + inverseSurface = md_theme_light_inverseSurface, + inversePrimary = md_theme_light_inversePrimary, + surfaceTint = md_theme_light_surfaceTint, + outlineVariant = md_theme_light_outlineVariant, + scrim = md_theme_light_scrim, ) - private val DarkColors = darkColorScheme( - primary = md_theme_dark_primary, - onPrimary = md_theme_dark_onPrimary, - primaryContainer = md_theme_dark_primaryContainer, - onPrimaryContainer = md_theme_dark_onPrimaryContainer, - secondary = md_theme_dark_secondary, - onSecondary = md_theme_dark_onSecondary, - secondaryContainer = md_theme_dark_secondaryContainer, - onSecondaryContainer = md_theme_dark_onSecondaryContainer, - tertiary = md_theme_dark_tertiary, - onTertiary = md_theme_dark_onTertiary, - tertiaryContainer = md_theme_dark_tertiaryContainer, - onTertiaryContainer = md_theme_dark_onTertiaryContainer, - error = md_theme_dark_error, - errorContainer = md_theme_dark_errorContainer, - onError = md_theme_dark_onError, - onErrorContainer = md_theme_dark_onErrorContainer, - background = md_theme_dark_background, - onBackground = md_theme_dark_onBackground, - surface = md_theme_dark_surface, - onSurface = md_theme_dark_onSurface, - surfaceVariant = md_theme_dark_surfaceVariant, - onSurfaceVariant = md_theme_dark_onSurfaceVariant, - outline = md_theme_dark_outline, - inverseOnSurface = md_theme_dark_inverseOnSurface, - inverseSurface = md_theme_dark_inverseSurface, - inversePrimary = md_theme_dark_inversePrimary, - surfaceTint = md_theme_dark_surfaceTint, - outlineVariant = md_theme_dark_outlineVariant, - scrim = md_theme_dark_scrim, + primary = md_theme_dark_primary, + onPrimary = md_theme_dark_onPrimary, + primaryContainer = md_theme_dark_primaryContainer, + onPrimaryContainer = md_theme_dark_onPrimaryContainer, + secondary = md_theme_dark_secondary, + onSecondary = md_theme_dark_onSecondary, + secondaryContainer = md_theme_dark_secondaryContainer, + onSecondaryContainer = md_theme_dark_onSecondaryContainer, + tertiary = md_theme_dark_tertiary, + onTertiary = md_theme_dark_onTertiary, + tertiaryContainer = md_theme_dark_tertiaryContainer, + onTertiaryContainer = md_theme_dark_onTertiaryContainer, + error = md_theme_dark_error, + errorContainer = md_theme_dark_errorContainer, + onError = md_theme_dark_onError, + onErrorContainer = md_theme_dark_onErrorContainer, + background = md_theme_dark_background, + onBackground = md_theme_dark_onBackground, + surface = md_theme_dark_surface, + onSurface = md_theme_dark_onSurface, + surfaceVariant = md_theme_dark_surfaceVariant, + onSurfaceVariant = md_theme_dark_onSurfaceVariant, + outline = md_theme_dark_outline, + inverseOnSurface = md_theme_dark_inverseOnSurface, + inverseSurface = md_theme_dark_inverseSurface, + inversePrimary = md_theme_dark_inversePrimary, + surfaceTint = md_theme_dark_surfaceTint, + outlineVariant = md_theme_dark_outlineVariant, + scrim = md_theme_dark_scrim, ) @Composable fun WorkoutTheme( useDarkTheme: Boolean = isSystemInDarkTheme(), - content: @Composable() () -> Unit + content: @Composable () -> Unit, ) { val colorScheme = if (!useDarkTheme) { LightColors @@ -84,21 +84,21 @@ fun WorkoutTheme( DarkColors } - // Remember a SystemUiController - val systemUiController = rememberSystemUiController() - val useDarkIcons = !useDarkTheme + // Remember a SystemUiController + val systemUiController = rememberSystemUiController() + val useDarkIcons = !useDarkTheme - SideEffect { - // Update all of the system bar colors to be transparent, and use - // dark icons if we're in light theme - systemUiController.setSystemBarsColor( - color = Color.Transparent, - darkIcons = useDarkIcons + SideEffect { + // Update all of the system bar colors to be transparent, and use + // dark icons if we're in light theme + systemUiController.setSystemBarsColor( + color = Color.Transparent, + darkIcons = useDarkIcons, + ) + // setStatusBarColor() and setNavigationBarColor() also exist + } + MaterialTheme( + colorScheme = colorScheme, + content = content, ) - // setStatusBarColor() and setNavigationBarColor() also exist - } - MaterialTheme( - colorScheme = colorScheme, - content = content - ) -} \ No newline at end of file +} diff --git a/app/src/main/java/com/example/android/january2022/ui/theme/Type.kt b/app/src/main/java/com/example/android/january2022/ui/theme/Type.kt index eb48a9c..b7a5318 100644 --- a/app/src/main/java/com/example/android/january2022/ui/theme/Type.kt +++ b/app/src/main/java/com/example/android/january2022/ui/theme/Type.kt @@ -2,118 +2,116 @@ package com.example.android.january2022.ui.theme import androidx.compose.material3.Typography import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -//Replace with your font locations +// Replace with your font locations val Roboto = FontFamily.Default val AppTypography = Typography( - displayLarge = TextStyle( - fontFamily = Roboto, - fontWeight = FontWeight.W400, - fontSize = 57.sp, - lineHeight = 64.sp, - letterSpacing = -0.25.sp, - ), - displayMedium = TextStyle( - fontFamily = Roboto, - fontWeight = FontWeight.W400, - fontSize = 45.sp, - lineHeight = 52.sp, - letterSpacing = 0.sp, - ), - displaySmall = TextStyle( - fontFamily = Roboto, - fontWeight = FontWeight.W400, - fontSize = 36.sp, - lineHeight = 44.sp, - letterSpacing = 0.sp, - ), - headlineLarge = TextStyle( - fontFamily = Roboto, - fontWeight = FontWeight.W400, - fontSize = 32.sp, - lineHeight = 40.sp, - letterSpacing = 0.sp, - ), - headlineMedium = TextStyle( - fontFamily = Roboto, - fontWeight = FontWeight.W400, - fontSize = 28.sp, - lineHeight = 36.sp, - letterSpacing = 0.sp, - ), - headlineSmall = TextStyle( - fontFamily = Roboto, - fontWeight = FontWeight.W400, - fontSize = 24.sp, - lineHeight = 32.sp, - letterSpacing = 0.sp, - ), - titleLarge = TextStyle( - fontFamily = Roboto, - fontWeight = FontWeight.W400, - fontSize = 22.sp, - lineHeight = 28.sp, - letterSpacing = 0.sp, - ), - titleMedium = TextStyle( - fontFamily = Roboto, - fontWeight = FontWeight.Medium, - fontSize = 16.sp, - lineHeight = 24.sp, - letterSpacing = 0.1.sp, - ), - titleSmall = TextStyle( - fontFamily = Roboto, - fontWeight = FontWeight.Medium, - fontSize = 14.sp, - lineHeight = 20.sp, - letterSpacing = 0.1.sp, - ), - labelLarge = TextStyle( - fontFamily = Roboto, - fontWeight = FontWeight.Medium, - fontSize = 14.sp, - lineHeight = 20.sp, - letterSpacing = 0.1.sp, - ), - bodyLarge = TextStyle( - fontFamily = Roboto, - fontWeight = FontWeight.W400, - fontSize = 16.sp, - lineHeight = 24.sp, - letterSpacing = 0.5.sp, - ), - bodyMedium = TextStyle( - fontFamily = Roboto, - fontWeight = FontWeight.W400, - fontSize = 14.sp, - lineHeight = 20.sp, - letterSpacing = 0.25.sp, - ), - bodySmall = TextStyle( - fontFamily = Roboto, - fontWeight = FontWeight.W400, - fontSize = 12.sp, - lineHeight = 16.sp, - letterSpacing = 0.4.sp, - ), - labelMedium = TextStyle( - fontFamily = Roboto, - fontWeight = FontWeight.Medium, - fontSize = 12.sp, - lineHeight = 16.sp, - letterSpacing = 0.5.sp, - ), - labelSmall = TextStyle( - fontFamily = Roboto, - fontWeight = FontWeight.Medium, - fontSize = 11.sp, - lineHeight = 16.sp, - letterSpacing = 0.5.sp, - ), + displayLarge = TextStyle( + fontFamily = Roboto, + fontWeight = FontWeight.W400, + fontSize = 57.sp, + lineHeight = 64.sp, + letterSpacing = (-0.25).sp, + ), + displayMedium = TextStyle( + fontFamily = Roboto, + fontWeight = FontWeight.W400, + fontSize = 45.sp, + lineHeight = 52.sp, + letterSpacing = 0.sp, + ), + displaySmall = TextStyle( + fontFamily = Roboto, + fontWeight = FontWeight.W400, + fontSize = 36.sp, + lineHeight = 44.sp, + letterSpacing = 0.sp, + ), + headlineLarge = TextStyle( + fontFamily = Roboto, + fontWeight = FontWeight.W400, + fontSize = 32.sp, + lineHeight = 40.sp, + letterSpacing = 0.sp, + ), + headlineMedium = TextStyle( + fontFamily = Roboto, + fontWeight = FontWeight.W400, + fontSize = 28.sp, + lineHeight = 36.sp, + letterSpacing = 0.sp, + ), + headlineSmall = TextStyle( + fontFamily = Roboto, + fontWeight = FontWeight.W400, + fontSize = 24.sp, + lineHeight = 32.sp, + letterSpacing = 0.sp, + ), + titleLarge = TextStyle( + fontFamily = Roboto, + fontWeight = FontWeight.W400, + fontSize = 22.sp, + lineHeight = 28.sp, + letterSpacing = 0.sp, + ), + titleMedium = TextStyle( + fontFamily = Roboto, + fontWeight = FontWeight.Medium, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.1.sp, + ), + titleSmall = TextStyle( + fontFamily = Roboto, + fontWeight = FontWeight.Medium, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.1.sp, + ), + labelLarge = TextStyle( + fontFamily = Roboto, + fontWeight = FontWeight.Medium, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.1.sp, + ), + bodyLarge = TextStyle( + fontFamily = Roboto, + fontWeight = FontWeight.W400, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp, + ), + bodyMedium = TextStyle( + fontFamily = Roboto, + fontWeight = FontWeight.W400, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.25.sp, + ), + bodySmall = TextStyle( + fontFamily = Roboto, + fontWeight = FontWeight.W400, + fontSize = 12.sp, + lineHeight = 16.sp, + letterSpacing = 0.4.sp, + ), + labelMedium = TextStyle( + fontFamily = Roboto, + fontWeight = FontWeight.Medium, + fontSize = 12.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp, + ), + labelSmall = TextStyle( + fontFamily = Roboto, + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp, + ), ) diff --git a/app/src/main/java/com/example/android/january2022/utils/BackPressHandler.kt b/app/src/main/java/com/example/android/january2022/utils/BackPressHandler.kt deleted file mode 100644 index 37e7ba3..0000000 --- a/app/src/main/java/com/example/android/january2022/utils/BackPressHandler.kt +++ /dev/null @@ -1,31 +0,0 @@ -package com.example.android.january2022.utils - -import androidx.activity.OnBackPressedCallback -import androidx.activity.OnBackPressedDispatcher -import androidx.activity.compose.LocalOnBackPressedDispatcherOwner -import androidx.compose.runtime.* - -@Composable -fun BackPressHandler( - backPressedDispatcher: OnBackPressedDispatcher? = - LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher, - onBackPressed: () -> Unit -) { - val currentOnBackPressed by rememberUpdatedState(newValue = onBackPressed) - - val backCallback = remember { - object : OnBackPressedCallback(true) { - override fun handleOnBackPressed() { - currentOnBackPressed() - } - } - } - - DisposableEffect(key1 = backPressedDispatcher) { - backPressedDispatcher?.addCallback(backCallback) - - onDispose { - backCallback.remove() - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/example/android/january2022/utils/Event.kt b/app/src/main/java/com/example/android/january2022/utils/Event.kt index b034aa5..13b6e49 100644 --- a/app/src/main/java/com/example/android/january2022/utils/Event.kt +++ b/app/src/main/java/com/example/android/january2022/utils/Event.kt @@ -1,4 +1,3 @@ package com.example.android.january2022.utils -interface Event { -} \ No newline at end of file +interface Event diff --git a/app/src/main/java/com/example/android/january2022/utils/FuzzySearch.kt b/app/src/main/java/com/example/android/january2022/utils/FuzzySearch.kt index 14534d8..65b4882 100644 --- a/app/src/main/java/com/example/android/january2022/utils/FuzzySearch.kt +++ b/app/src/main/java/com/example/android/january2022/utils/FuzzySearch.kt @@ -2,13 +2,13 @@ package com.example.android.january2022.utils class FuzzySearch { - companion object { - fun regexMatch(query: String, text: String): Boolean { - val q = query.lowercase().filter { it.isLetterOrDigit() || it.isWhitespace() } - val t = text.lowercase().filter { it.isLetterOrDigit() || it.isWhitespace() } - val regexp = - q.split(" ").joinToString(separator = "", postfix = ".*") { "(?=.*$it)" }.toRegex() - return regexp.matches(t) + companion object { + fun regexMatch(query: String, text: String): Boolean { + val q = query.lowercase().filter { it.isLetterOrDigit() || it.isWhitespace() } + val t = text.lowercase().filter { it.isLetterOrDigit() || it.isWhitespace() } + val regexp = + q.split(" ").joinToString(separator = "", postfix = ".*") { "(?=.*$it)" }.toRegex() + return regexp.matches(t) + } } - } } diff --git a/app/src/main/java/com/example/android/january2022/utils/Modifiers.kt b/app/src/main/java/com/example/android/january2022/utils/Modifiers.kt index 3ad8b5e..8ddfe40 100644 --- a/app/src/main/java/com/example/android/january2022/utils/Modifiers.kt +++ b/app/src/main/java/com/example/android/january2022/utils/Modifiers.kt @@ -3,7 +3,11 @@ package com.example.android.january2022.utils import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.isImeVisible -import androidx.compose.runtime.* +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.composed import androidx.compose.ui.focus.onFocusEvent @@ -11,25 +15,25 @@ import androidx.compose.ui.platform.LocalFocusManager @OptIn(ExperimentalLayoutApi::class) fun Modifier.clearFocusOnKeyboardDismiss(): Modifier = composed { - var isFocused by remember { mutableStateOf(false) } - var keyboardAppearedSinceLastFocused by remember { mutableStateOf(false) } - if (isFocused) { - val imeIsVisible = WindowInsets.isImeVisible - val focusManager = LocalFocusManager.current - LaunchedEffect(imeIsVisible) { - if (imeIsVisible) { - keyboardAppearedSinceLastFocused = true - } else if (keyboardAppearedSinceLastFocused) { - focusManager.clearFocus() - } + var isFocused by remember { mutableStateOf(false) } + var keyboardAppearedSinceLastFocused by remember { mutableStateOf(false) } + if (isFocused) { + val imeIsVisible = WindowInsets.isImeVisible + val focusManager = LocalFocusManager.current + LaunchedEffect(imeIsVisible) { + if (imeIsVisible) { + keyboardAppearedSinceLastFocused = true + } else if (keyboardAppearedSinceLastFocused) { + focusManager.clearFocus() + } + } } - } - onFocusEvent { - if (isFocused != it.isFocused) { - isFocused = it.isFocused - if (isFocused) { - keyboardAppearedSinceLastFocused = false - } + onFocusEvent { + if (isFocused != it.isFocused) { + isFocused = it.isFocused + if (isFocused) { + keyboardAppearedSinceLastFocused = false + } + } } - } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/example/android/january2022/utils/MuscleConverters.kt b/app/src/main/java/com/example/android/january2022/utils/MuscleConverters.kt index f8489ed..6c176c5 100644 --- a/app/src/main/java/com/example/android/january2022/utils/MuscleConverters.kt +++ b/app/src/main/java/com/example/android/january2022/utils/MuscleConverters.kt @@ -6,84 +6,84 @@ import com.example.android.january2022.db.entities.SessionExerciseWithExercise import timber.log.Timber fun turnTargetIntoMuscleGroups(targets: List): List { - return turnTargetIntoMuscleGroups(targets.joinToString("|")) + return turnTargetIntoMuscleGroups(targets.joinToString("|")) } fun turnTargetIntoMuscleGroups(targets: String): List { - return targets.split("|").map { target -> - when (target) { - "Adductor Magnus, ischial fibers" -> MuscleGroup.HIPS - "Adductors, Hip" -> MuscleGroup.HIPS - "Back, General" -> MuscleGroup.BACK - "Biceps Brachii" -> MuscleGroup.BICEPS - "Brachialis" -> MuscleGroup.BICEPS - "Brachioradialis" -> MuscleGroup.BICEPS - "Deltoid, Anterior" -> MuscleGroup.SHOULDERS - "Deltoid, Lateral" -> MuscleGroup.SHOULDERS - "Deltoid, Posterior" -> MuscleGroup.SHOULDERS - "Erector Spinae" -> MuscleGroup.BACK - "Extensor Carpi Radialis" -> MuscleGroup.FOREARMS - "Extensor Carpi Ulnaris" -> MuscleGroup.FOREARMS - "External Hip Rotators" -> MuscleGroup.HIPS - "Flexor Carpi Radialis" -> MuscleGroup.FOREARMS - "Flexor Carpi Ulnaris" -> MuscleGroup.FOREARMS - "Gastrocnemius" -> MuscleGroup.CALVES - "Gluteus Maximus" -> MuscleGroup.GLUTES - "Gluteus Medius" -> MuscleGroup.GLUTES - "Gluteus Minimus" -> MuscleGroup.GLUTES - "Gluteus Minimus, Anterior Fibers" -> MuscleGroup.GLUTES - "Hamstrings" -> MuscleGroup.HAMSTRINGS - "Hip Abductors" -> MuscleGroup.HIPS - "Hip External Rotators" -> MuscleGroup.HIPS - "Hip Internal Rotators" -> MuscleGroup.HIPS - "Iliopsoas" -> MuscleGroup.HIPS - "Infraspinatus" -> MuscleGroup.SHOULDERS - "Latissimus Dorsi" -> MuscleGroup.BACK - "Longus colli" -> MuscleGroup.BACK - "Obliques" -> MuscleGroup.CORE - "Pectoralis Major, Clavicular" -> MuscleGroup.CHEST - "Pectoralis Major, Sternal" -> MuscleGroup.CHEST - "Pectoralis Minor" -> MuscleGroup.CHEST - "Piriformis" -> MuscleGroup.HIPS - "Pronators" -> MuscleGroup.FOREARMS - "Quadratus Femoris" -> MuscleGroup.QUADRICEPS - "Quadriceps" -> MuscleGroup.QUADRICEPS - "Rectus Abdominis" -> MuscleGroup.CORE - "Rectus Femoris" -> MuscleGroup.QUADRICEPS - "Rhomboids" -> MuscleGroup.BACK - "Serratus Anterior" -> MuscleGroup.BACK - "Soleus" -> MuscleGroup.CALVES - "Splenius" -> MuscleGroup.BACK - "Sternocleidomastoid" -> MuscleGroup.NECK - "Subscapularis" -> MuscleGroup.SHOULDERS - "Supinator" -> MuscleGroup.FOREARMS - "Supraspinatus" -> MuscleGroup.SHOULDERS - "Tensor Fasciae Latae" -> MuscleGroup.GLUTES - "Teres Major" -> MuscleGroup.SHOULDERS - "Teres Minor" -> MuscleGroup.SHOULDERS - "Tibialis Anterior" -> MuscleGroup.CALVES - "Transverse Abdominis" -> MuscleGroup.CORE - "Trapezius, Lower" -> MuscleGroup.BACK - "Trapezius, Middle" -> MuscleGroup.BACK - "Trapezius, Upper" -> MuscleGroup.BACK - "Triceps Brachii" -> MuscleGroup.TRICEPS - "Triceps Brachii, Long Head" -> MuscleGroup.TRICEPS - "Wrist Extensors" -> MuscleGroup.FOREARMS - "Wrist Flexors" -> MuscleGroup.FOREARMS - else -> "FAILURE".also { Timber.d("Failed with: $targets") } - } - }.distinct().filterNot { it == "FAILURE" } + return targets.split("|").map { target -> + when (target) { + "Adductor Magnus, ischial fibers" -> MuscleGroup.HIPS + "Adductors, Hip" -> MuscleGroup.HIPS + "Back, General" -> MuscleGroup.BACK + "Biceps Brachii" -> MuscleGroup.BICEPS + "Brachialis" -> MuscleGroup.BICEPS + "Brachioradialis" -> MuscleGroup.BICEPS + "Deltoid, Anterior" -> MuscleGroup.SHOULDERS + "Deltoid, Lateral" -> MuscleGroup.SHOULDERS + "Deltoid, Posterior" -> MuscleGroup.SHOULDERS + "Erector Spinae" -> MuscleGroup.BACK + "Extensor Carpi Radialis" -> MuscleGroup.FOREARMS + "Extensor Carpi Ulnaris" -> MuscleGroup.FOREARMS + "External Hip Rotators" -> MuscleGroup.HIPS + "Flexor Carpi Radialis" -> MuscleGroup.FOREARMS + "Flexor Carpi Ulnaris" -> MuscleGroup.FOREARMS + "Gastrocnemius" -> MuscleGroup.CALVES + "Gluteus Maximus" -> MuscleGroup.GLUTES + "Gluteus Medius" -> MuscleGroup.GLUTES + "Gluteus Minimus" -> MuscleGroup.GLUTES + "Gluteus Minimus, Anterior Fibers" -> MuscleGroup.GLUTES + "Hamstrings" -> MuscleGroup.HAMSTRINGS + "Hip Abductors" -> MuscleGroup.HIPS + "Hip External Rotators" -> MuscleGroup.HIPS + "Hip Internal Rotators" -> MuscleGroup.HIPS + "Iliopsoas" -> MuscleGroup.HIPS + "Infraspinatus" -> MuscleGroup.SHOULDERS + "Latissimus Dorsi" -> MuscleGroup.BACK + "Longus colli" -> MuscleGroup.BACK + "Obliques" -> MuscleGroup.CORE + "Pectoralis Major, Clavicular" -> MuscleGroup.CHEST + "Pectoralis Major, Sternal" -> MuscleGroup.CHEST + "Pectoralis Minor" -> MuscleGroup.CHEST + "Piriformis" -> MuscleGroup.HIPS + "Pronators" -> MuscleGroup.FOREARMS + "Quadratus Femoris" -> MuscleGroup.QUADRICEPS + "Quadriceps" -> MuscleGroup.QUADRICEPS + "Rectus Abdominis" -> MuscleGroup.CORE + "Rectus Femoris" -> MuscleGroup.QUADRICEPS + "Rhomboids" -> MuscleGroup.BACK + "Serratus Anterior" -> MuscleGroup.BACK + "Soleus" -> MuscleGroup.CALVES + "Splenius" -> MuscleGroup.BACK + "Sternocleidomastoid" -> MuscleGroup.NECK + "Subscapularis" -> MuscleGroup.SHOULDERS + "Supinator" -> MuscleGroup.FOREARMS + "Supraspinatus" -> MuscleGroup.SHOULDERS + "Tensor Fasciae Latae" -> MuscleGroup.GLUTES + "Teres Major" -> MuscleGroup.SHOULDERS + "Teres Minor" -> MuscleGroup.SHOULDERS + "Tibialis Anterior" -> MuscleGroup.CALVES + "Transverse Abdominis" -> MuscleGroup.CORE + "Trapezius, Lower" -> MuscleGroup.BACK + "Trapezius, Middle" -> MuscleGroup.BACK + "Trapezius, Upper" -> MuscleGroup.BACK + "Triceps Brachii" -> MuscleGroup.TRICEPS + "Triceps Brachii, Long Head" -> MuscleGroup.TRICEPS + "Wrist Extensors" -> MuscleGroup.FOREARMS + "Wrist Flexors" -> MuscleGroup.FOREARMS + else -> "FAILURE".also { Timber.d("Failed with: $targets") } + } + }.distinct().filterNot { it == "FAILURE" } } @JvmName("sortedListOfMuscleGroupsForSessionExercises") fun List.sortedListOfMuscleGroups(): List { - return this.map { it.exercise }.sortedListOfMuscleGroups() + return this.map { it.exercise }.sortedListOfMuscleGroups() } @JvmName("sortedListOfMuscleGroupsForExercises") fun List.sortedListOfMuscleGroups(): List { - return this.map { turnTargetIntoMuscleGroups(it.targets) }.flatten() - .groupingBy { it }.eachCount().toList() - .sortedByDescending { it.second } - .map { it.first } -} \ No newline at end of file + return this.map { turnTargetIntoMuscleGroups(it.targets) }.flatten() + .groupingBy { it }.eachCount().toList() + .sortedByDescending { it.second } + .map { it.first } +} diff --git a/app/src/main/java/com/example/android/january2022/utils/Routes.kt b/app/src/main/java/com/example/android/january2022/utils/Routes.kt index ef21973..daf8180 100644 --- a/app/src/main/java/com/example/android/january2022/utils/Routes.kt +++ b/app/src/main/java/com/example/android/january2022/utils/Routes.kt @@ -5,4 +5,4 @@ object Routes { const val SESSION = "session" const val EXERCISE_PICKER = "exercisePicker" const val SETTINGS = "settings" -} \ No newline at end of file +} diff --git a/app/src/main/java/com/example/android/january2022/utils/TypeConverters.kt b/app/src/main/java/com/example/android/january2022/utils/TypeConverters.kt index 5dee762..4ca35e0 100644 --- a/app/src/main/java/com/example/android/january2022/utils/TypeConverters.kt +++ b/app/src/main/java/com/example/android/january2022/utils/TypeConverters.kt @@ -1,33 +1,31 @@ package com.example.android.january2022.utils import androidx.room.TypeConverter -import timber.log.Timber import java.time.LocalDateTime -import javax.xml.transform.Source class Converters { - @TypeConverter - fun fromString(source: String): List { - return source.split("|") - } + @TypeConverter + fun fromString(source: String): List { + return source.split("|") + } - @TypeConverter - fun fromList(source: List): String { - return source.joinToString("|") - } + @TypeConverter + fun fromList(source: List): String { + return source.joinToString("|") + } - @TypeConverter - fun fromDateTime(source: LocalDateTime?): String { - return source?.toString() ?: "" - } + @TypeConverter + fun fromDateTime(source: LocalDateTime?): String { + return source?.toString() ?: "" + } - @TypeConverter - fun toDateTime(source: String?): LocalDateTime? { - return try { - LocalDateTime.parse(source) - } catch (e: Exception) { - null + @TypeConverter + fun toDateTime(source: String?): LocalDateTime? { + return try { + LocalDateTime.parse(source) + } catch (e: Exception) { + null + } } - } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/example/android/january2022/utils/UiEvent.kt b/app/src/main/java/com/example/android/january2022/utils/UiEvent.kt index c60af23..4e1d065 100644 --- a/app/src/main/java/com/example/android/january2022/utils/UiEvent.kt +++ b/app/src/main/java/com/example/android/january2022/utils/UiEvent.kt @@ -2,11 +2,11 @@ package com.example.android.january2022.utils sealed class UiEvent { data class OpenWebsite(val url: String) : UiEvent() - data class Navigate(val route: String, val popBackStack: Boolean = false): UiEvent() + data class Navigate(val route: String, val popBackStack: Boolean = false) : UiEvent() data class FileCreated(val fileName: String) : UiEvent() - object ToggleTimer: UiEvent() - object ResetTimer: UiEvent() - object IncrementTimer: UiEvent() - object DecrementTimer: UiEvent() + object ToggleTimer : UiEvent() + object ResetTimer : UiEvent() + object IncrementTimer : UiEvent() + object DecrementTimer : UiEvent() } diff --git a/app/src/test/java/com/example/android/january2022/ExampleUnitTest.kt b/app/src/test/java/com/example/android/january2022/ExampleUnitTest.kt index 851becf..444096d 100644 --- a/app/src/test/java/com/example/android/january2022/ExampleUnitTest.kt +++ b/app/src/test/java/com/example/android/january2022/ExampleUnitTest.kt @@ -1,9 +1,8 @@ package com.example.android.january2022 +import junit.framework.TestCase.assertEquals import org.junit.Test -import org.junit.Assert.* - /** * Example local unit test, which will execute on the development machine (host). * @@ -14,4 +13,4 @@ class ExampleUnitTest { fun addition_isCorrect() { assertEquals(4, 2 + 2) } -} \ No newline at end of file +}