diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 94d84d2..e9ffe26 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -33,8 +33,8 @@ android { applicationId = "org.nsh07.pomodoro" minSdk = 26 targetSdk = 36 - versionCode = 6 - versionName = "1.2.0" + versionCode = 7 + versionName = "1.3.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } diff --git a/app/schemas/org.nsh07.pomodoro.data.AppDatabase/2.json b/app/schemas/org.nsh07.pomodoro.data.AppDatabase/2.json new file mode 100644 index 0000000..54e93e3 --- /dev/null +++ b/app/schemas/org.nsh07.pomodoro.data.AppDatabase/2.json @@ -0,0 +1,133 @@ +{ + "formatVersion": 1, + "database": { + "version": 2, + "identityHash": "4691d636a1c16c8cd33dc1bf0602190c", + "entities": [ + { + "tableName": "int_preference", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` INTEGER NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + } + }, + { + "tableName": "boolean_preference", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` INTEGER NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + } + }, + { + "tableName": "string_preference", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` TEXT NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + } + }, + { + "tableName": "stat", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`date` TEXT NOT NULL, `focusTimeQ1` INTEGER NOT NULL, `focusTimeQ2` INTEGER NOT NULL, `focusTimeQ3` INTEGER NOT NULL, `focusTimeQ4` INTEGER NOT NULL, `breakTime` INTEGER NOT NULL, PRIMARY KEY(`date`))", + "fields": [ + { + "fieldPath": "date", + "columnName": "date", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "focusTimeQ1", + "columnName": "focusTimeQ1", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "focusTimeQ2", + "columnName": "focusTimeQ2", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "focusTimeQ3", + "columnName": "focusTimeQ3", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "focusTimeQ4", + "columnName": "focusTimeQ4", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "breakTime", + "columnName": "breakTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "date" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '4691d636a1c16c8cd33dc1bf0602190c')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index a5372a3..19ff772 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -6,6 +6,7 @@ + + + @Query("SELECT value FROM string_preference WHERE `key` = :key") + suspend fun getStringPreference(key: String): String? + + @Query("SELECT value FROM string_preference WHERE `key` = :key") + fun getStringPreferenceFlow(key: String): Flow } \ No newline at end of file diff --git a/app/src/main/java/org/nsh07/pomodoro/data/PreferenceRepository.kt b/app/src/main/java/org/nsh07/pomodoro/data/PreferenceRepository.kt index 438af3f..4d53239 100644 --- a/app/src/main/java/org/nsh07/pomodoro/data/PreferenceRepository.kt +++ b/app/src/main/java/org/nsh07/pomodoro/data/PreferenceRepository.kt @@ -9,12 +9,13 @@ package org.nsh07.pomodoro.data import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.withContext /** * Interface for reading/writing app preferences to the app's database. This style of storage aims - * to mimic the Preferences DataStore library, preventing the requirement of migration if the - * database schema changes. + * to mimic the Preferences DataStore library, preventing the requirement of migration if new + * preferences are added */ interface PreferenceRepository { /** @@ -22,11 +23,41 @@ interface PreferenceRepository { */ suspend fun saveIntPreference(key: String, value: Int): Int + /** + * Saves a boolean preference key-value pair to the database. + */ + suspend fun saveBooleanPreference(key: String, value: Boolean): Boolean + + /** + * Saves a string preference key-value pair to the database. + */ + suspend fun saveStringPreference(key: String, value: String): String + /** * Retrieves an integer preference key-value pair from the database. */ suspend fun getIntPreference(key: String): Int? + /** + * Retrieves a boolean preference key-value pair from the database. + */ + suspend fun getBooleanPreference(key: String): Boolean? + + /** + * Retrieves a boolean preference key-value pair as a flow from the database. + */ + fun getBooleanPreferenceFlow(key: String): Flow + + /** + * Retrieves a string preference key-value pair from the database. + */ + suspend fun getStringPreference(key: String): String? + + /** + * Retrieves a string preference key-value pair as a flow from the database. + */ + fun getStringPreferenceFlow(key: String): Flow + /** * Erases all integer preference key-value pairs in the database. Do note that the default values * will need to be rewritten manually @@ -47,11 +78,39 @@ class AppPreferenceRepository( value } + override suspend fun saveBooleanPreference(key: String, value: Boolean): Boolean = + withContext(ioDispatcher) { + preferenceDao.insertBooleanPreference(BooleanPreference(key, value)) + value + } + + override suspend fun saveStringPreference(key: String, value: String): String = + withContext(ioDispatcher) { + preferenceDao.insertStringPreference(StringPreference(key, value)) + value + } + override suspend fun getIntPreference(key: String): Int? = withContext(ioDispatcher) { preferenceDao.getIntPreference(key) } + override suspend fun getBooleanPreference(key: String): Boolean? = withContext(ioDispatcher) { + preferenceDao.getBooleanPreference(key) + } + + override fun getBooleanPreferenceFlow(key: String): Flow = + preferenceDao.getBooleanPreferenceFlow(key) + + override suspend fun getStringPreference(key: String): String? = withContext(ioDispatcher) { + preferenceDao.getStringPreference(key) + } + + override fun getStringPreferenceFlow(key: String): Flow = + preferenceDao.getStringPreferenceFlow(key) + override suspend fun resetSettings() = withContext(ioDispatcher) { preferenceDao.resetIntPreferences() + preferenceDao.resetBooleanPreferences() + preferenceDao.resetStringPreferences() } } \ No newline at end of file diff --git a/app/src/main/java/org/nsh07/pomodoro/data/TimerRepository.kt b/app/src/main/java/org/nsh07/pomodoro/data/TimerRepository.kt index 0283a17..e1e3068 100644 --- a/app/src/main/java/org/nsh07/pomodoro/data/TimerRepository.kt +++ b/app/src/main/java/org/nsh07/pomodoro/data/TimerRepository.kt @@ -7,6 +7,11 @@ package org.nsh07.pomodoro.data +import android.net.Uri +import android.provider.Settings +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.lightColorScheme + /** * Interface that holds the timer durations for each timer type. This repository maintains a single * source of truth for the timer durations for the various ViewModels in the app. @@ -15,7 +20,17 @@ interface TimerRepository { var focusTime: Long var shortBreakTime: Long var longBreakTime: Long + var sessionLength: Int + + var timerFrequency: Float + + var alarmEnabled: Boolean + var vibrateEnabled: Boolean + + var colorScheme: ColorScheme + + var alarmSoundUri: Uri? } /** @@ -26,4 +41,10 @@ class AppTimerRepository : TimerRepository { override var shortBreakTime = 5 * 60 * 1000L override var longBreakTime = 15 * 60 * 1000L override var sessionLength = 4 + override var timerFrequency: Float = 10f + override var alarmEnabled = true + override var vibrateEnabled = true + override var colorScheme = lightColorScheme() + override var alarmSoundUri: Uri? = + Settings.System.DEFAULT_ALARM_ALERT_URI ?: Settings.System.DEFAULT_RINGTONE_URI } \ No newline at end of file diff --git a/app/src/main/java/org/nsh07/pomodoro/service/TimerService.kt b/app/src/main/java/org/nsh07/pomodoro/service/TimerService.kt index 82c2ff4..a3c348c 100644 --- a/app/src/main/java/org/nsh07/pomodoro/service/TimerService.kt +++ b/app/src/main/java/org/nsh07/pomodoro/service/TimerService.kt @@ -7,9 +7,9 @@ import android.media.MediaPlayer import android.os.Build import android.os.IBinder import android.os.SystemClock -import android.provider.Settings -import androidx.compose.material3.ColorScheme -import androidx.compose.material3.lightColorScheme +import android.os.VibrationEffect +import android.os.Vibrator +import android.os.VibratorManager import androidx.compose.ui.graphics.toArgb import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat @@ -17,41 +17,35 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import org.nsh07.pomodoro.R import org.nsh07.pomodoro.TomatoApplication -import org.nsh07.pomodoro.data.AppContainer -import org.nsh07.pomodoro.data.StatRepository -import org.nsh07.pomodoro.data.TimerRepository import org.nsh07.pomodoro.ui.timerScreen.viewModel.TimerMode -import org.nsh07.pomodoro.ui.timerScreen.viewModel.TimerState import org.nsh07.pomodoro.utils.millisecondsToStr import kotlin.text.Typography.middleDot class TimerService : Service() { - private lateinit var appContainer: AppContainer - - private lateinit var timerRepository: TimerRepository - private lateinit var statRepository: StatRepository - private lateinit var notificationManager: NotificationManagerCompat - private lateinit var notificationBuilder: NotificationCompat.Builder - private lateinit var _timerState: MutableStateFlow - private lateinit var _time: MutableStateFlow - - val timeStateFlow by lazy { - _time.asStateFlow() + private val appContainer by lazy { + (application as TomatoApplication).container } - var time: Long + private val timerRepository by lazy { appContainer.appTimerRepository } + private val statRepository by lazy { appContainer.appStatRepository } + private val notificationManager by lazy { NotificationManagerCompat.from(this) } + private val notificationBuilder by lazy { appContainer.notificationBuilder } + private val _timerState by lazy { appContainer.timerState } + private val _time by lazy { appContainer.time } + + private val timeStateFlow by lazy { _time.asStateFlow() } + + private var time: Long get() = timeStateFlow.value set(value) = _time.update { value } - lateinit var timerState: StateFlow + private val timerState by lazy { _timerState.asStateFlow() } private var cycles = 0 private var startTime = 0L @@ -62,29 +56,26 @@ class TimerService : Service() { private val scope = CoroutineScope(Dispatchers.IO + job) private val skipScope = CoroutineScope(Dispatchers.IO + job) - private lateinit var alarm: MediaPlayer + private var alarm: MediaPlayer? = null - private var cs: ColorScheme = lightColorScheme() + private val vibrator by lazy { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + val vibratorManager = getSystemService(VIBRATOR_MANAGER_SERVICE) as VibratorManager + vibratorManager.defaultVibrator + } else { + @Suppress("DEPRECATION") getSystemService(VIBRATOR_SERVICE) as Vibrator + } + } + + private val cs by lazy { timerRepository.colorScheme } override fun onBind(intent: Intent?): IBinder? { return null } override fun onCreate() { - appContainer = (application as TomatoApplication).container - timerRepository = appContainer.appTimerRepository - statRepository = appContainer.appStatRepository - notificationManager = NotificationManagerCompat.from(this) - notificationBuilder = appContainer.notificationBuilder - _timerState = appContainer.timerState - _time = appContainer.time - - timerState = _timerState.asStateFlow() - - alarm = MediaPlayer.create( - this, - Settings.System.DEFAULT_ALARM_ALERT_URI ?: Settings.System.DEFAULT_RINGTONE_URI - ) + super.onCreate() + alarm = MediaPlayer.create(this, timerRepository.alarmSoundUri) } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { @@ -103,32 +94,26 @@ class TimerService : Service() { Actions.SKIP.toString() -> skipTimer(true) Actions.STOP_ALARM.toString() -> stopAlarm() + + Actions.UPDATE_ALARM_TONE.toString() -> updateAlarmTone() } return super.onStartCommand(intent, flags, startId) } private fun toggleTimer() { if (timerState.value.timerRunning) { - notificationBuilder - .clearActions() - .addTimerActions( - this, - R.drawable.play, - "Start" - ) + notificationBuilder.clearActions().addTimerActions( + this, R.drawable.play, "Start" + ) showTimerNotification(time.toInt(), paused = true) _timerState.update { currentState -> currentState.copy(timerRunning = false) } pauseTime = SystemClock.elapsedRealtime() } else { - notificationBuilder - .clearActions() - .addTimerActions( - this, - R.drawable.pause, - "Stop" - ) + notificationBuilder.clearActions().addTimerActions( + this, R.drawable.pause, "Stop" + ) _timerState.update { it.copy(timerRunning = true) } if (pauseTime != 0L) pauseDuration += SystemClock.elapsedRealtime() - pauseTime @@ -147,7 +132,8 @@ class TimerService : Service() { else -> timerRepository.longBreakTime - (SystemClock.elapsedRealtime() - startTime - pauseDuration).toInt() } - iterations = (iterations + 1) % 10 + iterations = + (iterations + 1) % timerRepository.timerFrequency.toInt().coerceAtLeast(1) if (iterations == 0) showTimerNotification(time.toInt()) @@ -165,7 +151,7 @@ class TimerService : Service() { } } - delay(100) + delay((1000f / timerRepository.timerFrequency).toLong()) } } } @@ -208,40 +194,42 @@ class TimerService : Service() { ) .setContentText("Up next: $nextTimer (${timerState.value.nextTimeStr})") .setStyle( - NotificationCompat.ProgressStyle().also { - // Add all the Focus, Short break and long break intervals in order - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.BAKLAVA) { - // Android 16 and later supports live updates - // Set progress bar sections if on Baklava or later - for (i in 0..= Build.VERSION_CODES.BAKLAVA) { + // Android 16 and later supports live updates + // Set progress bar sections if on Baklava or later + for (i in 0.. timerRepository.focusTime.toInt() + TimerMode.SHORT_BREAK -> timerRepository.shortBreakTime.toInt() + else -> timerRepository.longBreakTime.toInt() + } + ) ) } - } else { - it.addProgressSegment( - NotificationCompat.ProgressStyle.Segment( - when (timerState.value.timerMode) { - TimerMode.FOCUS -> timerRepository.focusTime.toInt() - TimerMode.SHORT_BREAK -> timerRepository.shortBreakTime.toInt() - else -> timerRepository.longBreakTime.toInt() - } - ) - ) } - } .setProgress( // Set the current progress by filling the previous intervals and part of the current interval if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.BAKLAVA) { (totalTime - remainingTime) + ((cycles + 1) / 2) * timerRepository.focusTime.toInt() + (cycles / 2) * timerRepository.shortBreakTime.toInt() @@ -254,7 +242,7 @@ class TimerService : Service() { ) if (complete) { - alarm.start() + startAlarm() _timerState.update { currentState -> currentState.copy(alarmRinging = true) } @@ -328,9 +316,30 @@ class TimerService : Service() { } } + fun startAlarm() { + if (timerRepository.alarmEnabled) alarm?.start() + + if (timerRepository.vibrateEnabled) { + if (!vibrator.hasVibrator()) { + return + } + val vibrationPattern = longArrayOf(0, 1000, 1000, 1000) + val repeat = 2 + val effect = VibrationEffect.createWaveform(vibrationPattern, repeat) + vibrator.vibrate(effect) + } + } + fun stopAlarm() { - alarm.pause() - alarm.seekTo(0) + if (timerRepository.alarmEnabled) { + alarm?.pause() + alarm?.seekTo(0) + } + + if (timerRepository.vibrateEnabled) { + vibrator.cancel() + } + _timerState.update { currentState -> currentState.copy(alarmRinging = false) } @@ -340,12 +349,15 @@ class TimerService : Service() { TimerMode.FOCUS -> timerRepository.focusTime.toInt() TimerMode.SHORT_BREAK -> timerRepository.shortBreakTime.toInt() else -> timerRepository.longBreakTime.toInt() - }, - paused = true, - complete = false + }, paused = true, complete = false ) } + fun updateAlarmTone() { + alarm?.release() + alarm = MediaPlayer.create(this, timerRepository.alarmSoundUri) + } + suspend fun saveTimeToDb() { when (timerState.value.timerMode) { TimerMode.FOCUS -> statRepository.addFocusTime( @@ -374,10 +386,11 @@ class TimerService : Service() { job.cancel() saveTimeToDb() notificationManager.cancel(1) + alarm?.release() } } enum class Actions { - TOGGLE, SKIP, RESET, STOP_ALARM + TOGGLE, SKIP, RESET, STOP_ALARM, UPDATE_ALARM_TONE } } \ No newline at end of file diff --git a/app/src/main/java/org/nsh07/pomodoro/ui/AppScreen.kt b/app/src/main/java/org/nsh07/pomodoro/ui/AppScreen.kt index b2437f6..0a1e0d9 100644 --- a/app/src/main/java/org/nsh07/pomodoro/ui/AppScreen.kt +++ b/app/src/main/java/org/nsh07/pomodoro/ui/AppScreen.kt @@ -28,14 +28,11 @@ import androidx.compose.material3.ShortNavigationBarItem import androidx.compose.material3.Text import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Modifier -import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.res.painterResource import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -50,6 +47,7 @@ import org.nsh07.pomodoro.service.TimerService import org.nsh07.pomodoro.ui.settingsScreen.SettingsScreenRoot import org.nsh07.pomodoro.ui.statsScreen.StatsScreenRoot import org.nsh07.pomodoro.ui.statsScreen.viewModel.StatsViewModel +import org.nsh07.pomodoro.ui.timerScreen.AlarmDialog import org.nsh07.pomodoro.ui.timerScreen.TimerScreen import org.nsh07.pomodoro.ui.timerScreen.viewModel.TimerAction import org.nsh07.pomodoro.ui.timerScreen.viewModel.TimerViewModel @@ -69,16 +67,19 @@ fun AppScreen( val progress by rememberUpdatedState((uiState.totalTime.toFloat() - remainingTime) / uiState.totalTime) val layoutDirection = LocalLayoutDirection.current - val haptic = LocalHapticFeedback.current val motionScheme = motionScheme val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass - LaunchedEffect(uiState.timerMode) { - haptic.performHapticFeedback(HapticFeedbackType.LongPress) - } - val backStack = rememberNavBackStack(Screen.Timer) + if (uiState.alarmRinging) + AlarmDialog { + Intent(context, TimerService::class.java).also { + it.action = TimerService.Actions.STOP_ALARM.toString() + context.startService(it) + } + } + Scaffold( bottomBar = { val wide = remember { diff --git a/app/src/main/java/org/nsh07/pomodoro/ui/settingsScreen/SettingsScreen.kt b/app/src/main/java/org/nsh07/pomodoro/ui/settingsScreen/SettingsScreen.kt index 5355f9c..6420566 100644 --- a/app/src/main/java/org/nsh07/pomodoro/ui/settingsScreen/SettingsScreen.kt +++ b/app/src/main/java/org/nsh07/pomodoro/ui/settingsScreen/SettingsScreen.kt @@ -7,8 +7,17 @@ package org.nsh07.pomodoro.ui.settingsScreen +import android.app.Activity +import android.content.Intent +import android.media.RingtoneManager +import android.net.Uri +import android.os.Build +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.annotation.DrawableRes import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -18,8 +27,10 @@ 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.itemsIndexed import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.input.TextFieldState @@ -32,10 +43,11 @@ import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.ListItem import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme.colorScheme -import androidx.compose.material3.MaterialTheme.shapes import androidx.compose.material3.MaterialTheme.typography import androidx.compose.material3.Slider import androidx.compose.material3.SliderState +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults @@ -50,18 +62,25 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.tooling.preview.Devices import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.core.net.toUri +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import org.nsh07.pomodoro.R +import org.nsh07.pomodoro.service.TimerService import org.nsh07.pomodoro.ui.settingsScreen.viewModel.SettingsViewModel import org.nsh07.pomodoro.ui.theme.AppFonts.robotoFlexTopBar import org.nsh07.pomodoro.ui.theme.CustomColors.listItemColors import org.nsh07.pomodoro.ui.theme.CustomColors.topBarColors +import org.nsh07.pomodoro.ui.theme.TomatoShapeDefaults.bottomListItemShape +import org.nsh07.pomodoro.ui.theme.TomatoShapeDefaults.middleListItemShape +import org.nsh07.pomodoro.ui.theme.TomatoShapeDefaults.topListItemShape import org.nsh07.pomodoro.ui.theme.TomatoTheme @OptIn(ExperimentalMaterial3Api::class) @@ -70,6 +89,7 @@ fun SettingsScreenRoot( modifier: Modifier = Modifier, viewModel: SettingsViewModel = viewModel(factory = SettingsViewModel.Factory) ) { + val context = LocalContext.current val focusTimeInputFieldState = rememberSaveable(saver = TextFieldState.Saver) { viewModel.focusTimeTextFieldState } @@ -80,6 +100,10 @@ fun SettingsScreenRoot( viewModel.longBreakTimeTextFieldState } + val alarmEnabled by viewModel.alarmEnabled.collectAsStateWithLifecycle(true) + val vibrateEnabled by viewModel.vibrateEnabled.collectAsStateWithLifecycle(true) + val alarmSound by viewModel.alarmSound.collectAsStateWithLifecycle(viewModel.currentAlarmSound) + val sessionsSliderState = rememberSaveable( saver = SliderState.Saver( viewModel.sessionsSliderState.onValueChangeFinished, @@ -94,6 +118,18 @@ fun SettingsScreenRoot( shortBreakTimeInputFieldState = shortBreakTimeInputFieldState, longBreakTimeInputFieldState = longBreakTimeInputFieldState, sessionsSliderState = sessionsSliderState, + alarmEnabled = alarmEnabled, + vibrateEnabled = vibrateEnabled, + alarmSound = alarmSound, + onAlarmEnabledChange = viewModel::saveAlarmEnabled, + onVibrateEnabledChange = viewModel::saveVibrateEnabled, + onAlarmSoundChanged = { + viewModel.saveAlarmSound(it) + Intent(context, TimerService::class.java).apply { + action = TimerService.Actions.RESET.toString() + context.startService(this) + } + }, modifier = modifier ) } @@ -105,9 +141,63 @@ private fun SettingsScreen( shortBreakTimeInputFieldState: TextFieldState, longBreakTimeInputFieldState: TextFieldState, sessionsSliderState: SliderState, + alarmEnabled: Boolean, + vibrateEnabled: Boolean, + alarmSound: String, + onAlarmEnabledChange: (Boolean) -> Unit, + onVibrateEnabledChange: (Boolean) -> Unit, + onAlarmSoundChanged: (Uri?) -> Unit, modifier: Modifier = Modifier ) { val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() + val switchColors = SwitchDefaults.colors( + checkedIconColor = colorScheme.primary, + ) + + val context = LocalContext.current + + val ringtonePickerLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.StartActivityForResult() + ) { result -> + if (result.resultCode == Activity.RESULT_OK) { + val uri = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + result.data?.getParcelableExtra( + RingtoneManager.EXTRA_RINGTONE_PICKED_URI, + Uri::class.java + ) + } else { + @Suppress("DEPRECATION") + result.data?.getParcelableExtra(RingtoneManager.EXTRA_RINGTONE_PICKED_URI) + } + onAlarmSoundChanged(uri) + } + } + + val intent = Intent(RingtoneManager.ACTION_RINGTONE_PICKER).apply { + putExtra(RingtoneManager.EXTRA_RINGTONE_TYPE, RingtoneManager.TYPE_ALARM) + putExtra(RingtoneManager.EXTRA_RINGTONE_TITLE, "Alarm sound") + putExtra(RingtoneManager.EXTRA_RINGTONE_EXISTING_URI, alarmSound.toUri()) + } + + val switchItems = remember(alarmEnabled, vibrateEnabled) { + listOf( + SettingsSwitchItem( + checked = alarmEnabled, + icon = R.drawable.alarm_on, + label = "Alarm", + description = "Ring alarm when a timer completes", + onClick = onAlarmEnabledChange + ), + SettingsSwitchItem( + checked = vibrateEnabled, + icon = R.drawable.mobile_vibrate, + label = "Vibrate", + description = "Vibrate when a timer completes", + onClick = onVibrateEnabledChange + ) + ) + } Column(modifier.nestedScroll(scrollBehavior.nestedScrollConnection)) { TopAppBar( @@ -155,10 +245,10 @@ private fun SettingsScreen( MinuteInputField( state = focusTimeInputFieldState, shape = RoundedCornerShape( - topStart = 16.dp, - bottomStart = 16.dp, - topEnd = 4.dp, - bottomEnd = 4.dp + topStart = topListItemShape.topStart, + bottomStart = topListItemShape.topStart, + topEnd = topListItemShape.bottomStart, + bottomEnd = topListItemShape.bottomStart ), imeAction = ImeAction.Next ) @@ -174,7 +264,7 @@ private fun SettingsScreen( ) MinuteInputField( state = shortBreakTimeInputFieldState, - shape = RoundedCornerShape(4.dp), + shape = RoundedCornerShape(middleListItemShape.topStart), imeAction = ImeAction.Next ) } @@ -190,10 +280,10 @@ private fun SettingsScreen( MinuteInputField( state = longBreakTimeInputFieldState, shape = RoundedCornerShape( - topStart = 4.dp, - bottomStart = 4.dp, - topEnd = 16.dp, - bottomEnd = 16.dp + topStart = bottomListItemShape.topStart, + bottomStart = bottomListItemShape.topStart, + topEnd = bottomListItemShape.bottomStart, + bottomEnd = bottomListItemShape.bottomStart ), imeAction = ImeAction.Done ) @@ -224,7 +314,68 @@ private fun SettingsScreen( } }, colors = listItemColors, - modifier = Modifier.clip(shapes.large) + modifier = Modifier.clip(topListItemShape) + ) + } + item { + ListItem( + leadingContent = { + Icon(painterResource(R.drawable.alarm), null) + }, + headlineContent = { Text("Alarm sound") }, + supportingContent = { + Text( + remember(alarmSound) { + RingtoneManager.getRingtone(context, alarmSound.toUri()) + .getTitle(context) + } + ) + }, + colors = listItemColors, + modifier = Modifier + .clip(bottomListItemShape) + .clickable(onClick = { ringtonePickerLauncher.launch(intent) }) + ) + } + item { Spacer(Modifier.height(12.dp)) } + itemsIndexed(switchItems) { index, item -> + ListItem( + leadingContent = { + Icon(painterResource(item.icon), contentDescription = null) + }, + headlineContent = { Text(item.label) }, + supportingContent = { Text(item.description) }, + trailingContent = { + Switch( + checked = item.checked, + onCheckedChange = { item.onClick(it) }, + thumbContent = { + if (item.checked) { + Icon( + painter = painterResource(R.drawable.check), + contentDescription = null, + modifier = Modifier.size(SwitchDefaults.IconSize), + ) + } else { + Icon( + painter = painterResource(R.drawable.clear), + contentDescription = null, + modifier = Modifier.size(SwitchDefaults.IconSize), + ) + } + }, + colors = switchColors + ) + }, + colors = listItemColors, + modifier = Modifier + .clip( + when (index) { + 0 -> topListItemShape + switchItems.lastIndex -> bottomListItemShape + else -> middleListItemShape + } + ) ) } item { @@ -275,7 +426,21 @@ fun SettingsScreenPreview() { shortBreakTimeInputFieldState = rememberTextFieldState((5 * 60 * 1000).toString()), longBreakTimeInputFieldState = rememberTextFieldState((15 * 60 * 1000).toString()), sessionsSliderState = rememberSliderState(value = 3f, steps = 3, valueRange = 1f..5f), + alarmEnabled = true, + vibrateEnabled = true, + alarmSound = "null", + onAlarmEnabledChange = {}, + onVibrateEnabledChange = {}, + onAlarmSoundChanged = {}, modifier = Modifier.fillMaxSize() ) } } + +data class SettingsSwitchItem( + val checked: Boolean, + @DrawableRes val icon: Int, + val label: String, + val description: String, + val onClick: (Boolean) -> Unit +) diff --git a/app/src/main/java/org/nsh07/pomodoro/ui/settingsScreen/viewModel/SettingsViewModel.kt b/app/src/main/java/org/nsh07/pomodoro/ui/settingsScreen/viewModel/SettingsViewModel.kt index c3454e5..dc2439d 100644 --- a/app/src/main/java/org/nsh07/pomodoro/ui/settingsScreen/viewModel/SettingsViewModel.kt +++ b/app/src/main/java/org/nsh07/pomodoro/ui/settingsScreen/viewModel/SettingsViewModel.kt @@ -7,6 +7,7 @@ package org.nsh07.pomodoro.ui.settingsScreen.viewModel +import android.net.Uri import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.SliderState @@ -20,6 +21,7 @@ import androidx.lifecycle.viewmodel.viewModelFactory import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.launch import org.nsh07.pomodoro.TomatoApplication import org.nsh07.pomodoro.data.AppPreferenceRepository @@ -44,6 +46,15 @@ class SettingsViewModel( onValueChangeFinished = ::updateSessionLength ) + val currentAlarmSound = timerRepository.alarmSoundUri.toString() + + val alarmSound = + preferenceRepository.getStringPreferenceFlow("alarm_sound").distinctUntilChanged() + val alarmEnabled = + preferenceRepository.getBooleanPreferenceFlow("alarm_enabled").distinctUntilChanged() + val vibrateEnabled = + preferenceRepository.getBooleanPreferenceFlow("vibrate_enabled").distinctUntilChanged() + init { viewModelScope.launch(Dispatchers.IO) { snapshotFlow { focusTimeTextFieldState.text } @@ -92,6 +103,27 @@ class SettingsViewModel( } } + fun saveAlarmEnabled(enabled: Boolean) { + viewModelScope.launch { + preferenceRepository.saveBooleanPreference("alarm_enabled", enabled) + timerRepository.alarmEnabled = enabled + } + } + + fun saveVibrateEnabled(enabled: Boolean) { + viewModelScope.launch { + preferenceRepository.saveBooleanPreference("vibrate_enabled", enabled) + timerRepository.vibrateEnabled = enabled + } + } + + fun saveAlarmSound(uri: Uri?) { + viewModelScope.launch { + preferenceRepository.saveStringPreference("alarm_sound", uri.toString()) + } + timerRepository.alarmSoundUri = uri + } + companion object { val Factory: ViewModelProvider.Factory = viewModelFactory { initializer { diff --git a/app/src/main/java/org/nsh07/pomodoro/ui/theme/Shape.kt b/app/src/main/java/org/nsh07/pomodoro/ui/theme/Shape.kt new file mode 100644 index 0000000..b7dea55 --- /dev/null +++ b/app/src/main/java/org/nsh07/pomodoro/ui/theme/Shape.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2025 Nishant Mishra + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.nsh07.pomodoro.ui.theme + +import androidx.compose.foundation.shape.CornerBasedShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.MaterialTheme.shapes +import androidx.compose.runtime.Composable + +object TomatoShapeDefaults { + @OptIn(ExperimentalMaterial3ExpressiveApi::class) + val topListItemShape: RoundedCornerShape + @Composable get() = + RoundedCornerShape( + topStart = shapes.largeIncreased.topStart, + topEnd = shapes.largeIncreased.topEnd, + bottomStart = shapes.extraSmall.bottomStart, + bottomEnd = shapes.extraSmall.bottomStart + ) + + val middleListItemShape: RoundedCornerShape + @Composable get() = RoundedCornerShape(shapes.extraSmall.topStart) + + @OptIn(ExperimentalMaterial3ExpressiveApi::class) + val bottomListItemShape: RoundedCornerShape + @Composable get() = + RoundedCornerShape( + topStart = shapes.extraSmall.topStart, + topEnd = shapes.extraSmall.topEnd, + bottomStart = shapes.largeIncreased.bottomStart, + bottomEnd = shapes.largeIncreased.bottomEnd + ) + + @OptIn(ExperimentalMaterial3ExpressiveApi::class) + val cardShape: CornerBasedShape + @Composable get() = shapes.largeIncreased +} \ No newline at end of file diff --git a/app/src/main/java/org/nsh07/pomodoro/ui/timerScreen/AlarmDialog.kt b/app/src/main/java/org/nsh07/pomodoro/ui/timerScreen/AlarmDialog.kt index 556e88d..6d67506 100644 --- a/app/src/main/java/org/nsh07/pomodoro/ui/timerScreen/AlarmDialog.kt +++ b/app/src/main/java/org/nsh07/pomodoro/ui/timerScreen/AlarmDialog.kt @@ -69,7 +69,7 @@ fun AlarmDialog( onClick = stopAlarm, modifier = Modifier.align(Alignment.End), ) { - Text("Ok") + Text("Dismiss") } } } diff --git a/app/src/main/java/org/nsh07/pomodoro/ui/timerScreen/TimerScreen.kt b/app/src/main/java/org/nsh07/pomodoro/ui/timerScreen/TimerScreen.kt index ba3a1b2..daed55a 100644 --- a/app/src/main/java/org/nsh07/pomodoro/ui/timerScreen/TimerScreen.kt +++ b/app/src/main/java/org/nsh07/pomodoro/ui/timerScreen/TimerScreen.kt @@ -63,7 +63,9 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight @@ -89,6 +91,7 @@ fun TimerScreen( modifier: Modifier = Modifier ) { val motionScheme = motionScheme + val haptic = LocalHapticFeedback.current val color by animateColorAsState( if (timerState.timerMode == TimerMode.FOCUS) colorScheme.primary @@ -111,9 +114,6 @@ fun TimerScreen( onResult = {} ) - if (timerState.alarmRinging) - AlarmDialog { onAction(TimerAction.StopAlarm) } - Column(modifier = modifier) { TopAppBar( title = { @@ -301,6 +301,10 @@ fun TimerScreen( FilledIconToggleButton( onCheckedChange = { checked -> onAction(TimerAction.ToggleTimer) + + if (checked) haptic.performHapticFeedback(HapticFeedbackType.ToggleOn) + else haptic.performHapticFeedback(HapticFeedbackType.ToggleOff) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && checked) { permissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) } @@ -358,7 +362,10 @@ fun TimerScreen( customItem( { FilledTonalIconButton( - onClick = { onAction(TimerAction.ResetTimer) }, + onClick = { + onAction(TimerAction.ResetTimer) + haptic.performHapticFeedback(HapticFeedbackType.VirtualKey) + }, colors = IconButtonDefaults.filledTonalIconButtonColors( containerColor = colorContainer ), @@ -395,7 +402,10 @@ fun TimerScreen( customItem( { FilledTonalIconButton( - onClick = { onAction(TimerAction.SkipTimer(fromButton = true)) }, + onClick = { + onAction(TimerAction.SkipTimer(fromButton = true)) + haptic.performHapticFeedback(HapticFeedbackType.VirtualKey) + }, colors = IconButtonDefaults.filledTonalIconButtonColors( containerColor = colorContainer ), diff --git a/app/src/main/java/org/nsh07/pomodoro/ui/timerScreen/viewModel/TimerViewModel.kt b/app/src/main/java/org/nsh07/pomodoro/ui/timerScreen/viewModel/TimerViewModel.kt index a173dd4..71cb79f 100644 --- a/app/src/main/java/org/nsh07/pomodoro/ui/timerScreen/viewModel/TimerViewModel.kt +++ b/app/src/main/java/org/nsh07/pomodoro/ui/timerScreen/viewModel/TimerViewModel.kt @@ -8,7 +8,9 @@ package org.nsh07.pomodoro.ui.timerScreen.viewModel import android.app.Application +import android.provider.Settings import androidx.compose.material3.ColorScheme +import androidx.core.net.toUri import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY @@ -30,6 +32,7 @@ import org.nsh07.pomodoro.data.StatRepository import org.nsh07.pomodoro.data.TimerRepository import org.nsh07.pomodoro.utils.millisecondsToStr import java.time.LocalDate +import java.time.temporal.ChronoUnit @OptIn(FlowPreview::class) class TimerViewModel( @@ -77,16 +80,33 @@ class TimerViewModel( timerRepository.sessionLength ) + timerRepository.alarmEnabled = + preferenceRepository.getBooleanPreference("alarm_enabled") + ?: preferenceRepository.saveBooleanPreference("alarm_enabled", true) + timerRepository.vibrateEnabled = + preferenceRepository.getBooleanPreference("vibrate_enabled") + ?: preferenceRepository.saveBooleanPreference("vibrate_enabled", true) + + timerRepository.alarmSoundUri = ( + preferenceRepository.getStringPreference("alarm_sound") + ?: preferenceRepository.saveStringPreference( + "alarm_sound", + (Settings.System.DEFAULT_ALARM_ALERT_URI + ?: Settings.System.DEFAULT_RINGTONE_URI).toString() + ) + ).toUri() + resetTimer() var lastDate = statRepository.getLastDate() val today = LocalDate.now() // Fills dates between today and lastDate with 0s to ensure continuous history - while ((lastDate?.until(today)?.days ?: -1) > 0) { - lastDate = lastDate?.plusDays(1) - statRepository.insertStat(Stat(lastDate!!, 0, 0, 0, 0, 0)) - } + if (lastDate != null) + while (ChronoUnit.DAYS.between(lastDate, today) > 0) { + lastDate = lastDate?.plusDays(1) + statRepository.insertStat(Stat(lastDate!!, 0, 0, 0, 0, 0)) + } delay(1500) diff --git a/app/src/main/res/drawable/alarm_on.xml b/app/src/main/res/drawable/alarm_on.xml new file mode 100644 index 0000000..da06d7f --- /dev/null +++ b/app/src/main/res/drawable/alarm_on.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/app/src/main/res/drawable/check.xml b/app/src/main/res/drawable/check.xml new file mode 100644 index 0000000..954c1ed --- /dev/null +++ b/app/src/main/res/drawable/check.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/app/src/main/res/drawable/clear.xml b/app/src/main/res/drawable/clear.xml new file mode 100644 index 0000000..970bfaa --- /dev/null +++ b/app/src/main/res/drawable/clear.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/app/src/main/res/drawable/hourglass.xml b/app/src/main/res/drawable/hourglass.xml deleted file mode 100644 index a9fdbda..0000000 --- a/app/src/main/res/drawable/hourglass.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/hourglass_filled.xml b/app/src/main/res/drawable/hourglass_filled.xml deleted file mode 100644 index 7e80e61..0000000 --- a/app/src/main/res/drawable/hourglass_filled.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/mobile_vibrate.xml b/app/src/main/res/drawable/mobile_vibrate.xml new file mode 100644 index 0000000..7435b8a --- /dev/null +++ b/app/src/main/res/drawable/mobile_vibrate.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/app/src/main/res/drawable/timer_filled.xml b/app/src/main/res/drawable/timer_filled.xml new file mode 100644 index 0000000..9cfe048 --- /dev/null +++ b/app/src/main/res/drawable/timer_filled.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/timer_outlined.xml b/app/src/main/res/drawable/timer_outlined.xml new file mode 100644 index 0000000..17b99fb --- /dev/null +++ b/app/src/main/res/drawable/timer_outlined.xml @@ -0,0 +1,9 @@ + + + diff --git a/fastlane/metadata/android/en-US/changelogs/7.txt b/fastlane/metadata/android/en-US/changelogs/7.txt new file mode 100644 index 0000000..d57dc08 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/7.txt @@ -0,0 +1,9 @@ +New features: +- New custom alarm ringtone option +- Vibration when a timer completes +- New options in settings to disable alarm and vibration + +Fixes: +- Alarm dialog not visible when on the stats or settings screen +- Improved timer stability +- System M3 colors not being used in Live Update/Now Bar progress bar \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png index a296647..65c74b9 100644 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png and b/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png index cd76715..e41b904 100644 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png and b/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/3.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/3.png index 0d34bd3..3785c3d 100644 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/3.png and b/fastlane/metadata/android/en-US/images/phoneScreenshots/3.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/4.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/4.png index 83c6065..9ce6efb 100644 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/4.png and b/fastlane/metadata/android/en-US/images/phoneScreenshots/4.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/5.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/5.png index 3ffb9e5..c5a3722 100644 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/5.png and b/fastlane/metadata/android/en-US/images/phoneScreenshots/5.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/6.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/6.png index 2d18d2a..77e28cc 100644 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/6.png and b/fastlane/metadata/android/en-US/images/phoneScreenshots/6.png differ