diff --git a/.gitignore b/.gitignore index f1bd5ac..a352f1e 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ local.properties /keystore /release app-release.aab +.artifacts diff --git a/app/build.gradle b/app/build.gradle index 99a24c7..c4a991d 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -12,8 +12,8 @@ android { applicationId "de.practicetime.practicetime" minSdk 23 targetSdk 35 - versionCode 15 - versionName "1.2.0" + versionCode 16 + versionName "1.3.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" diff --git a/app/src/main/java/de/practicetime/practicetime/database/daos/SessionDao.kt b/app/src/main/java/de/practicetime/practicetime/database/daos/SessionDao.kt index c2f542d..932c93d 100644 --- a/app/src/main/java/de/practicetime/practicetime/database/daos/SessionDao.kt +++ b/app/src/main/java/de/practicetime/practicetime/database/daos/SessionDao.kt @@ -100,37 +100,68 @@ abstract class SessionDao : BaseDao(tableName = "session") { newRating: Int, newSections: List, newComment: String, + newTimestamp: Long? = null ) { // the difference session will save the difference in the section duration // between the original session and the edited sections val sessionWithSectionsWithCategoriesWithGoals = getWithSectionsWithCategoriesWithGoals(sessionId) - sessionWithSectionsWithCategoriesWithGoals.sections.forEach { (section, _) -> - section.duration = (newSections.find { - it.section.id == section.id - }?.section?.duration ?: 0) - (section.duration ?: 0) - } + if (newTimestamp == null) { + // Existing logic for when timestamp doesn't change + sessionWithSectionsWithCategoriesWithGoals.sections.forEach { (section, _) -> + section.duration = (newSections.find { + it.section.id == section.id + }?.section?.duration ?: 0) - (section.duration ?: 0) + } - val goalProgress = PracticeTime.goalDescriptionDao.computeGoalProgressForSession( - sessionWithSectionsWithCategoriesWithGoals, - checkArchived = true - ) - - // get all active goal instances at the time of the session - PracticeTime.goalInstanceDao.apply { - get( - goalDescriptionIds = goalProgress.keys.toList(), - from = sessionWithSectionsWithCategoriesWithGoals.sections.first().section.timestamp - // add the progress - ).onEach { instance -> - goalProgress[instance.goalDescriptionId].also { progress -> - if (progress != null) { - // progress should not get lower than 0 - instance.progress = maxOf(0 , instance.progress + progress) + val goalProgress = PracticeTime.goalDescriptionDao.computeGoalProgressForSession( + sessionWithSectionsWithCategoriesWithGoals, + checkArchived = true + ) + + // get all active goal instances at the time of the session + PracticeTime.goalInstanceDao.apply { + get( + goalDescriptionIds = goalProgress.keys.toList(), + from = sessionWithSectionsWithCategoriesWithGoals.sections.first().section.timestamp + // add the progress + ).onEach { instance -> + goalProgress[instance.goalDescriptionId].also { progress -> + if (progress != null) { + // progress should not get lower than 0 + instance.progress = maxOf(0, instance.progress + progress) + } + } + update(instance) + } + } + } else { + // Logic for when timestamp changes + // 1. Remove OLD progress + val oldGoalProgress = PracticeTime.goalDescriptionDao.computeGoalProgressForSession( + sessionWithSectionsWithCategoriesWithGoals, + checkArchived = true + ) + PracticeTime.goalInstanceDao.apply { + get( + goalDescriptionIds = oldGoalProgress.keys.toList(), + from = sessionWithSectionsWithCategoriesWithGoals.sections.first().section.timestamp + ).onEach { instance -> + oldGoalProgress[instance.goalDescriptionId].also { progress -> + if (progress != null) { + instance.progress = maxOf(0, instance.progress - progress) + } } + update(instance) } - update(instance) + } + + // 2. Update timestamps in data structures + val diff = newTimestamp - sessionWithSectionsWithCategoriesWithGoals.session.createdAt + sessionWithSectionsWithCategoriesWithGoals.session.createdAt = newTimestamp + newSections.forEach { (section, _) -> + section.timestamp += diff } } @@ -145,5 +176,28 @@ abstract class SessionDao : BaseDao(tableName = "session") { } update(sessionWithSectionsWithCategoriesWithGoals.session) + + if (newTimestamp != null) { + // 3. Add NEW progress at the new timestamp + // Refetch to get the updated relations with goal descriptions at the new time + val updatedSessionWithGoals = getWithSectionsWithCategoriesWithGoals(sessionId) + val newGoalProgress = PracticeTime.goalDescriptionDao.computeGoalProgressForSession( + updatedSessionWithGoals, + checkArchived = true + ) + PracticeTime.goalInstanceDao.apply { + get( + goalDescriptionIds = newGoalProgress.keys.toList(), + from = newTimestamp + ).onEach { instance -> + newGoalProgress[instance.goalDescriptionId].also { progress -> + if (progress != null) { + instance.progress += progress + } + } + update(instance) + } + } + } } } diff --git a/app/src/main/java/de/practicetime/practicetime/database/entities/Section.kt b/app/src/main/java/de/practicetime/practicetime/database/entities/Section.kt index 016fadc..04791d6 100644 --- a/app/src/main/java/de/practicetime/practicetime/database/entities/Section.kt +++ b/app/src/main/java/de/practicetime/practicetime/database/entities/Section.kt @@ -15,5 +15,5 @@ data class Section ( @ColumnInfo(name="session_id", index = true) var sessionId: Long?, @ColumnInfo(name="category_id", index = true) val categoryId: Long, @ColumnInfo(name="duration") var duration: Int?, - @ColumnInfo(name="timestamp") val timestamp: Long, + @ColumnInfo(name="timestamp") var timestamp: Long, ) : BaseModel() diff --git a/app/src/main/java/de/practicetime/practicetime/ui/sessionlist/FullscreenSessionActivity.kt b/app/src/main/java/de/practicetime/practicetime/ui/sessionlist/FullscreenSessionActivity.kt index 9a8e4b8..3236f86 100644 --- a/app/src/main/java/de/practicetime/practicetime/ui/sessionlist/FullscreenSessionActivity.kt +++ b/app/src/main/java/de/practicetime/practicetime/ui/sessionlist/FullscreenSessionActivity.kt @@ -7,6 +7,8 @@ package de.practicetime.practicetime.ui.sessionlist import android.app.AlertDialog +import android.app.DatePickerDialog +import android.app.TimePickerDialog import android.content.Context import android.content.DialogInterface import android.content.Intent @@ -35,14 +37,18 @@ import de.practicetime.practicetime.database.entities.SessionWithSectionsWithCat import de.practicetime.practicetime.shared.EditTimeDialog import de.practicetime.practicetime.ui.MainActivity import de.practicetime.practicetime.utils.TIME_FORMAT_HUMAN_PRETTY +import de.practicetime.practicetime.utils.epochSecondsToDate import de.practicetime.practicetime.utils.getDurationString import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import java.time.ZonedDateTime +import java.time.format.DateTimeFormatter class FullscreenSessionActivity : AppCompatActivity() { private lateinit var ratingBarView: RatingBar + private lateinit var dateTextView: TextView private lateinit var sectionListView: RecyclerView private lateinit var commentFieldView: TextView @@ -55,12 +61,14 @@ class FullscreenSessionActivity : AppCompatActivity() { private var sessionWithSectionsWithCategories: SessionWithSectionsWithCategories? = null private var selectedSection: Section? = null + private var sessionTimestamp: Long = 0 + private var timestampEdited = false private var showCommentPlaceholder = true private var sessionEdited = false override fun onBackPressed() { - if(!sessionEdited) return super.onBackPressed() + if(!sessionEdited && !timestampEdited) return super.onBackPressed() confirmationDialog.apply { setMessage(getString(R.string.discard_changes_dialog_message)) show() @@ -103,7 +111,7 @@ class FullscreenSessionActivity : AppCompatActivity() { if (sessionId != null) { showFullscreenSession(sessionId) findViewById(R.id.fullscreen_session_cancel).setOnClickListener { - if(!sessionEdited) return@setOnClickListener exitActivity() + if(!sessionEdited && !timestampEdited) return@setOnClickListener exitActivity() confirmationDialog.apply { setMessage(getString(R.string.discard_changes_dialog_message)) show() @@ -133,6 +141,7 @@ class FullscreenSessionActivity : AppCompatActivity() { newSections = sectionAdapterData, newComment = if(showCommentPlaceholder) "" else commentFieldView.text.toString(), + newTimestamp = if (timestampEdited) sessionTimestamp else null ) dismiss() exitActivity() @@ -148,6 +157,7 @@ class FullscreenSessionActivity : AppCompatActivity() { } private fun showFullscreenSession(sessionId: Long) { + dateTextView = findViewById(R.id.fullscreen_session_date_text) ratingBarView = findViewById(R.id.fullscreen_session_rating_bar) sectionListView = findViewById(R.id.fullscreen_session_section_list) commentFieldView = findViewById(R.id.fullscreen_session_comment_field) @@ -169,6 +179,13 @@ class FullscreenSessionActivity : AppCompatActivity() { PracticeTime.sessionDao.getWithSectionsWithCategories(sessionId) val (session, sectionsWithCategories) = sessionWithSectionsWithCategories!! + sessionTimestamp = session.createdAt + updateDateUI() + + findViewById(R.id.fullscreen_session_date_layout).setOnClickListener { + showDateTimePicker() + } + ratingBarView.progress = session.rating ratingBarView.setOnRatingBarChangeListener { _, _, _ -> sessionEdited = true @@ -189,6 +206,46 @@ class FullscreenSessionActivity : AppCompatActivity() { } + private fun updateDateUI() { + val zonedDateTime = epochSecondsToDate(sessionTimestamp) + val dateFormatter = DateTimeFormatter.ofPattern("dd.MM.yyyy") + val timeFormatter = DateTimeFormatter.ofPattern("HH:mm") + + dateTextView.text = getString( + R.string.fullscreen_session_date, + zonedDateTime.format(dateFormatter), + zonedDateTime.format(timeFormatter) + ) + } + + private fun showDateTimePicker() { + val currentDateTime = epochSecondsToDate(sessionTimestamp) + + DatePickerDialog( + this, + { _, year, month, dayOfMonth -> + TimePickerDialog( + this, + { _, hourOfDay, minute -> + val newDateTime = ZonedDateTime.of( + year, month + 1, dayOfMonth, hourOfDay, minute, 0, 0, + currentDateTime.zone + ) + sessionTimestamp = newDateTime.toEpochSecond() + timestampEdited = true + updateDateUI() + }, + currentDateTime.hour, + currentDateTime.minute, + true + ).show() + }, + currentDateTime.year, + currentDateTime.monthValue - 1, + currentDateTime.dayOfMonth + ).show() + } + private fun editSectionDurationHandler(section: Section?, newSectionDuration: Int) { section?.duration = newSectionDuration sectionAdapterData.indexOfFirst { diff --git a/app/src/main/java/de/practicetime/practicetime/ui/timer/TimerFragment.kt b/app/src/main/java/de/practicetime/practicetime/ui/timer/TimerFragment.kt new file mode 100644 index 0000000..0077223 --- /dev/null +++ b/app/src/main/java/de/practicetime/practicetime/ui/timer/TimerFragment.kt @@ -0,0 +1,120 @@ +/* + * This software is licensed under the MIT license + * + * Copyright (c) 2022, Javier Carbone, author Matthias Emde + */ + +package de.practicetime.practicetime.ui.timer + +import android.media.AudioManager +import android.media.ToneGenerator +import android.os.Bundle +import android.os.CountDownTimer +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.WindowManager +import android.widget.Button +import android.widget.EditText +import android.widget.TextView +import androidx.fragment.app.Fragment +import com.google.android.material.switchmaterial.SwitchMaterial +import de.practicetime.practicetime.R +import java.util.Locale + +class TimerFragment : Fragment() { + + private lateinit var countdownText: TextView + private lateinit var inputMinutes: EditText + private lateinit var inputSeconds: EditText + private lateinit var startBtn: Button + private lateinit var stopBtn: Button + private lateinit var loopSwitch: SwitchMaterial + private lateinit var beepSwitch: SwitchMaterial + + private var timer: CountDownTimer? = null + private var isRunning = false + private val toneGenerator = ToneGenerator(AudioManager.STREAM_ALARM, 100) + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + val view = inflater.inflate(R.layout.fragment_timer, container, false) + countdownText = view.findViewById(R.id.timerCountdown) + inputMinutes = view.findViewById(R.id.timerInputMinutes) + inputSeconds = view.findViewById(R.id.timerInputSeconds) + startBtn = view.findViewById(R.id.timerStartBtn) + stopBtn = view.findViewById(R.id.timerStopBtn) + loopSwitch = view.findViewById(R.id.timerLoopSwitch) + beepSwitch = view.findViewById(R.id.timerBeepSwitch) + + startBtn.setOnClickListener { startTimer() } + stopBtn.setOnClickListener { stopTimer() } + + return view + } + + private fun startTimer() { + val minutes = inputMinutes.text.toString().toLongOrNull() ?: 0L + val seconds = inputSeconds.text.toString().toLongOrNull() ?: 30L + val totalMillis = (minutes * 60 + seconds) * 1000 + + if (totalMillis <= 0) return + + isRunning = true + startBtn.isEnabled = false + stopBtn.isEnabled = true + inputMinutes.isEnabled = false + inputSeconds.isEnabled = false + loopSwitch.isEnabled = false + beepSwitch.isEnabled = false + + // Keep screen on while timer is running + requireActivity().window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + + timer = object : CountDownTimer(totalMillis, 100) { + override fun onTick(millisUntilFinished: Long) { + val sec = (millisUntilFinished / 1000) % 60 + val min = (millisUntilFinished / 1000) / 60 + countdownText.text = String.format(Locale.getDefault(), "%02d:%02d", min, sec) + } + + override fun onFinish() { + if (beepSwitch.isChecked) { + toneGenerator.startTone(ToneGenerator.TONE_PROP_BEEP, 750) + } + if (loopSwitch.isChecked && isRunning) { + startTimer() // Restart for endless loop + } else { + stopTimer() + } + } + }.start() + } + + private fun stopTimer() { + isRunning = false + timer?.cancel() + timer = null + startBtn.isEnabled = true + stopBtn.isEnabled = false + inputMinutes.isEnabled = true + inputSeconds.isEnabled = true + loopSwitch.isEnabled = true + beepSwitch.isEnabled = true + + val defaultDuration = getString(R.string.goalDialogDefaultDuration) + countdownText.text = String.format(Locale.getDefault(), "%s:%s", defaultDuration, defaultDuration) + + // Allow screen to turn off when timer stops + requireActivity().window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } + + override fun onDestroy() { + super.onDestroy() + timer?.cancel() + toneGenerator.release() + } +} diff --git a/app/src/main/res/drawable/ic_timer.xml b/app/src/main/res/drawable/ic_timer.xml new file mode 100644 index 0000000..a0bba4c --- /dev/null +++ b/app/src/main/res/drawable/ic_timer.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/layout/activity_fullscreen_session.xml b/app/src/main/res/layout/activity_fullscreen_session.xml index 6b31c7a..4bde626 100644 --- a/app/src/main/res/layout/activity_fullscreen_session.xml +++ b/app/src/main/res/layout/activity_fullscreen_session.xml @@ -57,6 +57,49 @@ android:layout_height="wrap_content" android:layout_marginStart="24dp" android:layout_marginTop="16dp" + android:text="@string/fullscreen_session_date_label" + android:textColor="?attr/colorOnSurfaceLowerContrast" + android:textSize="16sp" + android:textStyle="bold" /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +