diff --git a/app/build.gradle b/app/build.gradle index f91b766ad..29e877ff2 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -86,6 +86,12 @@ android { } dependencies { + // Room components + implementation "androidx.room:room-runtime:$rootProject.roomVersion" + annotationProcessor "androidx.room:room-compiler:$rootProject.roomVersion" + + // Gson + implementation "com.google.code.gson:gson:$rootProject.gsonVersion" implementation 'androidx.media3:media3-exoplayer:1.7.1' implementation 'androidx.media3:media3-common:1.7.1' implementation 'androidx.preference:preference:1.2.1' diff --git a/app/src/main/java/com/best/deskclock/AlarmInitReceiver.java b/app/src/main/java/com/best/deskclock/AlarmInitReceiver.java index 9626e0bc3..5cbbc11a0 100644 --- a/app/src/main/java/com/best/deskclock/AlarmInitReceiver.java +++ b/app/src/main/java/com/best/deskclock/AlarmInitReceiver.java @@ -16,6 +16,7 @@ import com.best.deskclock.alarms.AlarmNotifications; import com.best.deskclock.alarms.AlarmStateManager; +import com.best.deskclock.holiday.HolidayRepository; import com.best.deskclock.controller.Controller; import com.best.deskclock.data.DataModel; import com.best.deskclock.data.SettingsDAO; @@ -98,6 +99,12 @@ public void onReceive(final Context context, Intent intent) { Controller.getController().updateShortcuts(); } + // Update holiday data on boot or app update + if (Intent.ACTION_BOOT_COMPLETED.equals(action) + || Intent.ACTION_MY_PACKAGE_REPLACED.equals(action)) { + HolidayRepository.getInstance(context).updateWorkdayData(); + } + // Update alarm status once receive the status update broadcast if (ACTION_UPDATE_ALARM_STATUS.equals(action)) { long alarmTime = intent.getLongExtra(TIME, 0L); diff --git a/app/src/main/java/com/best/deskclock/AutoSilenceDurationDialogFragment.java b/app/src/main/java/com/best/deskclock/AutoSilenceDurationDialogFragment.java index 81e85e994..9ac402f60 100644 --- a/app/src/main/java/com/best/deskclock/AutoSilenceDurationDialogFragment.java +++ b/app/src/main/java/com/best/deskclock/AutoSilenceDurationDialogFragment.java @@ -5,8 +5,8 @@ import static android.view.WindowManager.LayoutParams.SOFT_INPUT_ADJUST_PAN; import static android.view.WindowManager.LayoutParams.SOFT_INPUT_STATE_VISIBLE; -import static com.best.deskclock.settings.PreferencesDefaultValues.ALARM_TIMEOUT_END_OF_RINGTONE; -import static com.best.deskclock.settings.PreferencesDefaultValues.ALARM_TIMEOUT_NEVER; +import static com.best.deskclock.settings.PreferencesDefaultValues.TIMEOUT_END_OF_RINGTONE; +import static com.best.deskclock.settings.PreferencesDefaultValues.TIMEOUT_NEVER; import android.app.Dialog; import android.content.Context; @@ -185,9 +185,9 @@ public Dialog onCreateDialog(Bundle savedInstanceState) { mEndOfRingtoneCheckbox = view.findViewById(R.id.end_of_ringtone); mEditMinutes.setText(String.valueOf(editMinutes)); - if (editMinutes == ALARM_TIMEOUT_END_OF_RINGTONE) { + if (editMinutes == TIMEOUT_END_OF_RINGTONE) { mEditMinutes.setText(""); - } else if (editMinutes == ALARM_TIMEOUT_NEVER) { + } else if (editMinutes == TIMEOUT_NEVER) { mEditMinutes.setText(String.valueOf(0)); } mEditMinutes.setEnabled(!isEndOfRingtone); @@ -260,7 +260,7 @@ private void setAutoSilenceDuration() { int minutes = 0; if (mEndOfRingtoneCheckbox.isChecked()) { - minutes = ALARM_TIMEOUT_END_OF_RINGTONE; + minutes = TIMEOUT_END_OF_RINGTONE; } else { String minutesText = mEditMinutes.getText() != null ? mEditMinutes.getText().toString() : ""; @@ -269,7 +269,7 @@ private void setAutoSilenceDuration() { } if (minutes == 0) { - minutes = ALARM_TIMEOUT_NEVER; + minutes = TIMEOUT_NEVER; } } diff --git a/app/src/main/java/com/best/deskclock/BaseActivity.java b/app/src/main/java/com/best/deskclock/BaseActivity.java new file mode 100644 index 000000000..593a92723 --- /dev/null +++ b/app/src/main/java/com/best/deskclock/BaseActivity.java @@ -0,0 +1,337 @@ +// SPDX-License-Identifier: GPL-3.0-only + +package com.best.deskclock; + +import static com.best.deskclock.DeskClockApplication.getDefaultSharedPreferences; +import static com.best.deskclock.settings.PreferencesDefaultValues.AMOLED_DARK_MODE; +import static com.best.deskclock.settings.PreferencesDefaultValues.BLACK_ACCENT_COLOR; +import static com.best.deskclock.settings.PreferencesDefaultValues.BLUE_ACCENT_COLOR; +import static com.best.deskclock.settings.PreferencesDefaultValues.BLUE_GRAY_ACCENT_COLOR; +import static com.best.deskclock.settings.PreferencesDefaultValues.BROWN_ACCENT_COLOR; +import static com.best.deskclock.settings.PreferencesDefaultValues.DARK_THEME; +import static com.best.deskclock.settings.PreferencesDefaultValues.DEBUG_LANGUAGE_CODE; +import static com.best.deskclock.settings.PreferencesDefaultValues.DEFAULT_DARK_MODE; +import static com.best.deskclock.settings.PreferencesDefaultValues.GREEN_ACCENT_COLOR; +import static com.best.deskclock.settings.PreferencesDefaultValues.INDIGO_ACCENT_COLOR; +import static com.best.deskclock.settings.PreferencesDefaultValues.LIGHT_THEME; +import static com.best.deskclock.settings.PreferencesDefaultValues.ORANGE_ACCENT_COLOR; +import static com.best.deskclock.settings.PreferencesDefaultValues.PINK_ACCENT_COLOR; +import static com.best.deskclock.settings.PreferencesDefaultValues.PURPLE_ACCENT_COLOR; +import static com.best.deskclock.settings.PreferencesDefaultValues.RED_ACCENT_COLOR; +import static com.best.deskclock.settings.PreferencesDefaultValues.SYSTEM_THEME; +import static com.best.deskclock.settings.PreferencesDefaultValues.YELLOW_ACCENT_COLOR; +import static com.best.deskclock.settings.PreferencesKeys.KEY_ACCENT_COLOR; +import static com.best.deskclock.settings.PreferencesKeys.KEY_AUTO_NIGHT_ACCENT_COLOR; +import static com.best.deskclock.settings.PreferencesKeys.KEY_CARD_BACKGROUND; +import static com.best.deskclock.settings.PreferencesKeys.KEY_CARD_BORDER; +import static com.best.deskclock.settings.PreferencesKeys.KEY_CUSTOM_LANGUAGE_CODE; +import static com.best.deskclock.settings.PreferencesKeys.KEY_DARK_MODE; +import static com.best.deskclock.settings.PreferencesKeys.KEY_FADE_TRANSITIONS; +import static com.best.deskclock.settings.PreferencesKeys.KEY_GENERAL_FONT; +import static com.best.deskclock.settings.PreferencesKeys.KEY_NIGHT_ACCENT_COLOR; +import static com.best.deskclock.settings.PreferencesKeys.KEY_THEME; + +import android.app.Activity; +import android.content.Context; +import android.content.SharedPreferences; +import android.graphics.Color; +import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; + +import androidx.activity.EdgeToEdge; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AppCompatActivity; +import androidx.appcompat.app.AppCompatDelegate; + +import com.best.deskclock.data.SettingsDAO; +import com.best.deskclock.utils.SdkUtils; +import com.best.deskclock.utils.ThemeUtils; +import com.best.deskclock.utils.Utils; +import com.google.android.material.color.MaterialColors; + +import java.util.List; +import java.util.Map; +import java.util.WeakHashMap; + +/** + * Base activity that ensures consistent theme, accent color, and locale settings + * across all activities in the app. + *
+ * This class handles: + *
+ */ +public class BaseActivity extends AppCompatActivity { + + /** + * List of supported preference keys for theme and UI settings management. + *
+ * This list is used to monitor only relevant keys within + * {@link #registerThemeListener()} to optimize change handling.
+ */ + private static final List+ * Chooses between the regular and night accent color depending on the current theme + * and whether auto-night accent color is enabled.
+ * + * @param isAutoNightAccentColorEnabled True if automatic night accent color is enabled. + * @param accentColor The regular accent color value. + * @param nightAccentColor The night accent color value. + */ + private void applyAccentColor(boolean isAutoNightAccentColorEnabled, String accentColor, + String nightAccentColor, String darkMode) { + + String color = isAutoNightAccentColorEnabled + ? accentColor + : (ThemeUtils.isNight(getResources()) ? nightAccentColor : accentColor); + + switch (color) { + case BLACK_ACCENT_COLOR -> setTheme(R.style.BlackAccentColor); + case BLUE_ACCENT_COLOR -> setTheme(R.style.BlueAccentColor); + case BLUE_GRAY_ACCENT_COLOR -> setTheme(R.style.BlueGrayAccentColor); + case BROWN_ACCENT_COLOR -> setTheme(R.style.BrownAccentColor); + case GREEN_ACCENT_COLOR -> setTheme(R.style.GreenAccentColor); + case INDIGO_ACCENT_COLOR -> setTheme(R.style.IndigoAccentColor); + case ORANGE_ACCENT_COLOR -> setTheme(R.style.OrangeAccentColor); + case PINK_ACCENT_COLOR -> setTheme(R.style.PinkAccentColor); + case PURPLE_ACCENT_COLOR -> setTheme(R.style.PurpleAccentColor); + case RED_ACCENT_COLOR -> setTheme(R.style.RedAccentColor); + case YELLOW_ACCENT_COLOR -> setTheme(R.style.YellowAccentColor); + } + + if (ThemeUtils.isNight(getResources()) && darkMode.equals(AMOLED_DARK_MODE)) { + getWindow().getDecorView().setBackgroundColor(Color.BLACK); + } + } + + /** + * Applies a color to the navigation bar for activities. + */ + private void applyNavigationBarColor(String darkMode) { + if (SdkUtils.isAtLeastAndroid10()) { + if (this instanceof DeskClock) { + EdgeToEdge.enable(this); + getWindow().setNavigationBarContrastEnforced(false); + } + } else { + boolean isPhoneInLandscapeMode = !ThemeUtils.isTablet() && ThemeUtils.isLandscape(); + boolean isCardBackgroundDisplayed = SettingsDAO.isCardBackgroundDisplayed(mPrefs); + + if (ThemeUtils.isNight(getResources()) && darkMode.equals(AMOLED_DARK_MODE)) { + getWindow().setNavigationBarColor(Color.BLACK); + } else if (this instanceof DeskClock) { + getWindow().setNavigationBarColor(MaterialColors.getColor(this, + isPhoneInLandscapeMode || !isCardBackgroundDisplayed + ? android.R.attr.colorBackground + : com.google.android.material.R.attr.colorSurface, Color.BLACK)); + } else { + getWindow().setNavigationBarColor(MaterialColors.getColor(this, + android.R.attr.colorBackground, Color.BLACK)); + } + } + } + + /** + * Sets the night mode of AppCompatDelegate according to the selected theme. + *+ * This method builds a {@link SpannableStringBuilder} containing the names of the days + * on which the alarm is set to repeat. The day corresponding to the next scheduled alarm + * (if provided) will be colored and displayed in bold. + *
+ * + * @param context the context used to access resources + * @param order the preferred order of weekdays (e.g., starting on Monday or Sunday) + * @param forceLongNames whether to force the use of full weekday names + * @param nextAlarmDay the calendar day (e.g., {@link Calendar#MONDAY}) of the next alarm; + * if matched, that day will be styled in bold + * @return a {@link CharSequence} with the formatted and styled weekday names + */ + public CharSequence toStyledString(Context context, Order order, boolean forceLongNames, int nextAlarmDay) { + if (!isRepeating()) { + return ""; + } + + final boolean longNames = forceLongNames || getCount() <= 1; + final DateFormatSymbols dfs = new DateFormatSymbols(); + final String[] weekdays = longNames ? dfs.getWeekdays() : dfs.getShortWeekdays(); + final String separator = context.getString(R.string.day_concat); + + final SpannableStringBuilder builder = new SpannableStringBuilder(); + for (int calendarDay : order.getCalendarDays()) { + if (isBitOn(calendarDay)) { + if (!TextUtils.isEmpty(builder)) { + builder.append(separator); + } + + String dayName = weekdays[calendarDay]; + int start = builder.length(); + builder.append(dayName); + int end = builder.length(); + + if (calendarDay == nextAlarmDay) { + int primaryColor = MaterialColors.getColor(context, androidx.appcompat.R.attr.colorPrimary, Color.BLACK); + builder.setSpan(new ForegroundColorSpan(primaryColor), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + builder.setSpan(new StyleSpan(Typeface.BOLD), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + } + } + } + return builder; + } + /** * The preferred starting day of the week can differ by locale. This enumerated value is used to * describe the preferred ordering. diff --git a/app/src/main/java/com/best/deskclock/holiday/HolidayDao.java b/app/src/main/java/com/best/deskclock/holiday/HolidayDao.java index 946df2522..e671eee6b 100644 --- a/app/src/main/java/com/best/deskclock/holiday/HolidayDao.java +++ b/app/src/main/java/com/best/deskclock/holiday/HolidayDao.java @@ -36,4 +36,10 @@ public interface HolidayDao { @Query("SELECT * FROM holiday WHERE compDays LIKE '%' || :date || '%'") Holiday getCompDayByDate(String date); + + @Query("SELECT * FROM holiday") + List+ * The behavior depends on user settings and the current alarm state:
+ *This method determines when the alarm should trigger again based on its + * configuration. It handles both repeating alarms (with specific days of the week) + * and one-time alarms (with a fixed date). Daylight Savings Time (DST) adjustments + * are also taken into account by resetting the hour and minute after shifting days. + * + * @return a {@link Calendar} instance representing the next valid alarm time. + *
- For repeating alarms: the next valid day of the week at the configured hour/minute.
+ *- For one-time alarms: the configured date and time, or the following day if the + * specified time has already passed relative to {@code currentTime}.
+ */ public Calendar getNextAlarmTime(Calendar currentTime) { final Calendar nextInstanceTime = Calendar.getInstance(currentTime.getTimeZone()); nextInstanceTime.set(Calendar.SECOND, 0); @@ -565,6 +745,88 @@ public Calendar getNextAlarmTime(Calendar currentTime) { return nextInstanceTime; } + /** + * Returns the day of the week (as Calendar.DAY_OF_WEEK) when the alarm will next trigger. + *+ * If a valid AlarmInstance is provided and its scheduled time is in the future, + * that time is used to determine the next alarm day. + * Otherwise, the method calculates the next scheduled alarm time based on the current time + * and the alarm's repeat settings.
+ * + * @param alarmInstance the current AlarmInstance, or null if not yet created + * @return the day of the week (e.g., Calendar.MONDAY, Calendar.TUESDAY, ...) + */ + public int getNextAlarmDayOfWeek(AlarmInstance alarmInstance) { + Calendar referenceTime = Calendar.getInstance(); + Calendar nextAlarmTime; + + if (alarmInstance != null && alarmInstance.getAlarmTime().after(referenceTime)) { + nextAlarmTime = alarmInstance.getAlarmTime(); + } else { + nextAlarmTime = getNextAlarmTime(referenceTime); + } + + return nextAlarmTime.get(Calendar.DAY_OF_WEEK); + } + + /** + * Returns the next alarm time for sorting purposes. + */ + public Calendar getSortableNextAlarmTime(AlarmInstance instance, Calendar now) { + Calendar result = Calendar.getInstance(now.getTimeZone()); + result.set(Calendar.SECOND, 0); + result.set(Calendar.MILLISECOND, 0); + + if (daysOfWeek.isRepeating()) { + // If a future instance exists (e.g. after Dismiss), use it. + // Otherwise compute the next valid occurrence from "now". + if (instance != null && instance.getAlarmTime().getTimeInMillis() > now.getTimeInMillis()) { + return instance.getAlarmTime(); + } + + return getNextAlarmTime(now); + } else { + if (isSpecifiedDate()) { + if (isDateInThePast()) { + // Expired specific date → anchor to today at the alarm's time + result.set(Calendar.YEAR, now.get(Calendar.YEAR)); + result.set(Calendar.MONTH, now.get(Calendar.MONTH)); + result.set(Calendar.DAY_OF_MONTH, now.get(Calendar.DAY_OF_MONTH)); + result.set(Calendar.HOUR_OF_DAY, hour); + result.set(Calendar.MINUTE, minutes); + + // If the time has already passed today, shift to tomorrow + if (result.getTimeInMillis() < now.getTimeInMillis()) { + result.add(Calendar.DAY_OF_YEAR, 1); + } + } else { + // Future or today’s specified date → respect the defined date/time + result.set(Calendar.YEAR, year); + result.set(Calendar.MONTH, month); + result.set(Calendar.DAY_OF_MONTH, day); + result.set(Calendar.HOUR_OF_DAY, hour); + result.set(Calendar.MINUTE, minutes); + } + + return result; + } + } + + // Alarms with no date and no repetition → today at the alarm time, + // and if the time has passed, shift to tomorrow + result.set(Calendar.YEAR, now.get(Calendar.YEAR)); + result.set(Calendar.MONTH, now.get(Calendar.MONTH)); + result.set(Calendar.DAY_OF_MONTH, now.get(Calendar.DAY_OF_MONTH)); + result.set(Calendar.HOUR_OF_DAY, hour); + result.set(Calendar.MINUTE, minutes); + + if (result.getTimeInMillis() < now.getTimeInMillis()) { + result.add(Calendar.DAY_OF_YEAR, 1); + } + + return result; + } + @Override public boolean equals(Object o) { if (!(o instanceof final Alarm other)) return false; @@ -590,11 +852,13 @@ public String toString() { ", minutes=" + minutes + ", daysOfWeek=" + daysOfWeek + ", vibrate=" + vibrate + + ", vibrationPattern=" + vibrationPattern + ", flash=" + flash + ", label='" + label + '\'' + ", deleteAfterUse=" + deleteAfterUse + ", autoSilenceDuration=" + autoSilenceDuration + ", snoozeDuration=" + snoozeDuration + + ", missedAlarmRepeatLimit=" + missedAlarmRepeatLimit + ", crescendoDuration=" + crescendoDuration + ", alarmVolume=" + alarmVolume + '}'; diff --git a/app/src/main/java/com/best/deskclock/provider/AlarmInstance.java b/app/src/main/java/com/best/deskclock/provider/AlarmInstance.java index b1a612131..1c04461ef 100644 --- a/app/src/main/java/com/best/deskclock/provider/AlarmInstance.java +++ b/app/src/main/java/com/best/deskclock/provider/AlarmInstance.java @@ -7,8 +7,14 @@ package com.best.deskclock.provider; import static com.best.deskclock.DeskClockApplication.getDefaultSharedPreferences; -import static com.best.deskclock.settings.PreferencesDefaultValues.ALARM_TIMEOUT_END_OF_RINGTONE; -import static com.best.deskclock.settings.PreferencesDefaultValues.ALARM_TIMEOUT_NEVER; +import static com.best.deskclock.settings.PreferencesDefaultValues.DEFAULT_ALARM_SNOOZE_DURATION; +import static com.best.deskclock.settings.PreferencesDefaultValues.DEFAULT_ALARM_VOLUME; +import static com.best.deskclock.settings.PreferencesDefaultValues.DEFAULT_AUTO_SILENCE_DURATION; +import static com.best.deskclock.settings.PreferencesDefaultValues.DEFAULT_MISSED_ALARM_REPEAT_LIMIT; +import static com.best.deskclock.settings.PreferencesDefaultValues.DEFAULT_VIBRATION_PATTERN; +import static com.best.deskclock.settings.PreferencesDefaultValues.DEFAULT_VOLUME_CRESCENDO_DURATION; +import static com.best.deskclock.settings.PreferencesDefaultValues.TIMEOUT_END_OF_RINGTONE; +import static com.best.deskclock.settings.PreferencesDefaultValues.TIMEOUT_NEVER; import android.content.ContentResolver; import android.content.ContentUris; @@ -41,7 +47,8 @@ public final class AlarmInstance implements ClockContract.InstancesColumns { /** * Offset from alarm time to stop showing missed notification. */ - private static final int MISSED_TIME_TO_LIVE_HOUR_OFFSET = 12; + public static final int MISSED_TIME_TO_LIVE_HOUR_OFFSET = 12; + private static final String[] QUERY_COLUMNS = { _ID, YEAR, @@ -51,12 +58,15 @@ public final class AlarmInstance implements ClockContract.InstancesColumns { MINUTES, LABEL, VIBRATE, + VIBRATION_PATTERN, FLASH, RINGTONE, ALARM_ID, ALARM_STATE, AUTO_SILENCE_DURATION, SNOOZE_DURATION, + MISSED_ALARM_REPEAT_COUNT, + MISSED_ALARM_REPEAT_LIMIT, CRESCENDO_DURATION, ALARM_VOLUME }; @@ -73,14 +83,17 @@ public final class AlarmInstance implements ClockContract.InstancesColumns { private static final int MINUTES_INDEX = 5; private static final int LABEL_INDEX = 6; private static final int VIBRATE_INDEX = 7; - private static final int FLASH_INDEX = 8; - private static final int RINGTONE_INDEX = 9; - private static final int ALARM_ID_INDEX = 10; - private static final int ALARM_STATE_INDEX = 11; - private static final int AUTO_SILENCE_DURATION_INDEX = 12; - private static final int SNOOZE_DURATION_INDEX = 13; - private static final int CRESCENDO_DURATION_INDEX = 14; - private static final int ALARM_VOLUME_INDEX = 15; + private static final int VIBRATION_PATTERN_INDEX = 8; + private static final int FLASH_INDEX = 9; + private static final int RINGTONE_INDEX = 10; + private static final int ALARM_ID_INDEX = 11; + private static final int ALARM_STATE_INDEX = 12; + private static final int AUTO_SILENCE_DURATION_INDEX = 13; + private static final int SNOOZE_DURATION_INDEX = 14; + private static final int MISSED_ALARM_REPEAT_COUNT_INDEX = 15; + private static final int MISSED_ALARM_MAX_COUNT_INDEX = 16; + private static final int CRESCENDO_DURATION_INDEX = 17; + private static final int ALARM_VOLUME_INDEX = 18; private static final int COLUMN_COUNT = ALARM_VOLUME_INDEX + 1; // Public fields @@ -92,12 +105,15 @@ public final class AlarmInstance implements ClockContract.InstancesColumns { public int mMinute; public String mLabel; public boolean mVibrate; + public String mVibrationPattern; public boolean mFlash; public Uri mRingtone; public Long mAlarmId; public int mAlarmState; public int mAutoSilenceDuration; public int mSnoozeDuration; + public int mMissedAlarmCurrentCount; + public int mMissedAlarmRepeatLimit; public int mCrescendoDuration; // Alarm volume level in steps; not a percentage public int mAlarmVolume; @@ -112,13 +128,16 @@ public AlarmInstance(Calendar calendar) { setAlarmTime(calendar); mLabel = ""; mVibrate = false; + mVibrationPattern = DEFAULT_VIBRATION_PATTERN; mFlash = false; mRingtone = null; mAlarmState = SILENT_STATE; - mAutoSilenceDuration = 10; - mSnoozeDuration = 10; - mCrescendoDuration = 0; - mAlarmVolume = 11; + mAutoSilenceDuration = DEFAULT_AUTO_SILENCE_DURATION; + mSnoozeDuration = DEFAULT_ALARM_SNOOZE_DURATION; + mMissedAlarmCurrentCount = 0; + mMissedAlarmRepeatLimit = Integer.parseInt(DEFAULT_MISSED_ALARM_REPEAT_LIMIT); + mCrescendoDuration = DEFAULT_VOLUME_CRESCENDO_DURATION; + mAlarmVolume = DEFAULT_ALARM_VOLUME; } public AlarmInstance(AlarmInstance instance) { @@ -130,12 +149,15 @@ public AlarmInstance(AlarmInstance instance) { this.mMinute = instance.mMinute; this.mLabel = instance.mLabel; this.mVibrate = instance.mVibrate; + this.mVibrationPattern = instance.mVibrationPattern; this.mFlash = instance.mFlash; this.mRingtone = instance.mRingtone; this.mAlarmId = instance.mAlarmId; this.mAlarmState = instance.mAlarmState; this.mAutoSilenceDuration = instance.mAutoSilenceDuration; this.mSnoozeDuration = instance.mSnoozeDuration; + this.mMissedAlarmCurrentCount = instance.mMissedAlarmCurrentCount; + this.mMissedAlarmRepeatLimit = instance.mMissedAlarmRepeatLimit; this.mCrescendoDuration = instance.mCrescendoDuration; this.mAlarmVolume = instance.mAlarmVolume; } @@ -150,9 +172,12 @@ public AlarmInstance(Cursor c, boolean joinedTable) { mMinute = c.getInt(Alarm.INSTANCE_MINUTE_INDEX); mLabel = c.getString(Alarm.INSTANCE_LABEL_INDEX); mVibrate = c.getInt(Alarm.INSTANCE_VIBRATE_INDEX) == 1; + mVibrationPattern = c.getString(Alarm.INSTANCE_VIBRATION_PATTERN_INDEX); mFlash = c.getInt(Alarm.INSTANCE_FLASH_INDEX) == 1; mAutoSilenceDuration = c.getInt(Alarm.INSTANCE_AUTO_SILENCE_DURATION_INDEX); mSnoozeDuration = c.getInt(Alarm.INSTANCE_SNOOZE_DURATION_INDEX); + mMissedAlarmCurrentCount = c.getInt(Alarm.INSTANCE_MISSED_ALARM_REPEAT_COUNT_INDEX); + mMissedAlarmRepeatLimit = c.getInt(Alarm.INSTANCE_MISSED_ALARM_REPEAT_LIMIT_INDEX); mCrescendoDuration = c.getInt(Alarm.INSTANCE_CRESCENDO_DURATION_INDEX); mAlarmVolume = c.getInt(Alarm.INSTANCE_ALARM_VOLUME_INDEX); } else { @@ -164,9 +189,12 @@ public AlarmInstance(Cursor c, boolean joinedTable) { mMinute = c.getInt(MINUTES_INDEX); mLabel = c.getString(LABEL_INDEX); mVibrate = c.getInt(VIBRATE_INDEX) == 1; + mVibrationPattern = c.getString(VIBRATION_PATTERN_INDEX); mFlash = c.getInt(FLASH_INDEX) == 1; mAutoSilenceDuration = c.getInt(AUTO_SILENCE_DURATION_INDEX); mSnoozeDuration = c.getInt(SNOOZE_DURATION_INDEX); + mMissedAlarmCurrentCount = c.getInt(MISSED_ALARM_REPEAT_COUNT_INDEX); + mMissedAlarmRepeatLimit = c.getInt(MISSED_ALARM_MAX_COUNT_INDEX); mCrescendoDuration = c.getInt(CRESCENDO_DURATION_INDEX); mAlarmVolume = c.getInt(ALARM_VOLUME_INDEX); } @@ -184,33 +212,36 @@ public AlarmInstance(Cursor c, boolean joinedTable) { mAlarmState = c.getInt(ALARM_STATE_INDEX); } - public static ContentValues createContentValues(AlarmInstance instance) { + public ContentValues createContentValues() { ContentValues values = new ContentValues(COLUMN_COUNT); - if (instance.mId != INVALID_ID) { - values.put(_ID, instance.mId); + if (mId != INVALID_ID) { + values.put(_ID, mId); } - values.put(YEAR, instance.mYear); - values.put(MONTH, instance.mMonth); - values.put(DAY, instance.mDay); - values.put(HOUR, instance.mHour); - values.put(MINUTES, instance.mMinute); - values.put(LABEL, instance.mLabel); - values.put(VIBRATE, instance.mVibrate ? 1 : 0); - values.put(FLASH, instance.mFlash ? 1 : 0); - if (instance.mRingtone == null) { + values.put(YEAR, mYear); + values.put(MONTH, mMonth); + values.put(DAY, mDay); + values.put(HOUR, mHour); + values.put(MINUTES, mMinute); + values.put(LABEL, mLabel); + values.put(VIBRATE, mVibrate ? 1 : 0); + values.put(VIBRATION_PATTERN, mVibrationPattern); + values.put(FLASH, mFlash ? 1 : 0); + if (mRingtone == null) { // We want to put null in the database, so we'll be able // to pick up on changes to the default alarm values.putNull(RINGTONE); } else { - values.put(RINGTONE, instance.mRingtone.toString()); + values.put(RINGTONE, mRingtone.toString()); } - values.put(ALARM_ID, instance.mAlarmId); - values.put(ALARM_STATE, instance.mAlarmState); - values.put(AUTO_SILENCE_DURATION, instance.mAutoSilenceDuration); - values.put(SNOOZE_DURATION, instance.mSnoozeDuration); - values.put(CRESCENDO_DURATION, instance.mCrescendoDuration); - values.put(ALARM_VOLUME, instance.mAlarmVolume); + values.put(ALARM_ID, mAlarmId); + values.put(ALARM_STATE, mAlarmState); + values.put(AUTO_SILENCE_DURATION, mAutoSilenceDuration); + values.put(SNOOZE_DURATION, mSnoozeDuration); + values.put(MISSED_ALARM_REPEAT_COUNT, mMissedAlarmCurrentCount); + values.put(MISSED_ALARM_REPEAT_LIMIT, mMissedAlarmRepeatLimit); + values.put(CRESCENDO_DURATION, mCrescendoDuration); + values.put(ALARM_VOLUME, mAlarmVolume); return values; } @@ -315,31 +346,30 @@ public static ListType: STRING
+ */ + String VIBRATION_PATTERN = "vibrationPattern"; + /** * True if flash should turn on *Type: BOOLEAN
@@ -87,6 +94,12 @@ private interface AlarmSettingColumns extends BaseColumns { */ String SNOOZE_DURATION = "snoozeDuration"; + /** + * Missed alarm repeat limit + *Type: INTEGER
+ */ + String MISSED_ALARM_REPEAT_LIMIT = "missed_alarm_repeat_limit"; + /** * Alarm crescendo duration. *Type: INTEGER
@@ -278,5 +291,11 @@ protected interface InstancesColumns extends AlarmSettingColumns, BaseColumns { *Type: INTEGER
*/ String ALARM_STATE = "alarm_state"; + + /** + * Missed alarm repeat count + *Type: INTEGER
+ */ + String MISSED_ALARM_REPEAT_COUNT = "missed_alarm_repeat_count"; } } diff --git a/app/src/main/java/com/best/deskclock/provider/ClockDatabaseHelper.java b/app/src/main/java/com/best/deskclock/provider/ClockDatabaseHelper.java index 169c048cf..0046a8df5 100644 --- a/app/src/main/java/com/best/deskclock/provider/ClockDatabaseHelper.java +++ b/app/src/main/java/com/best/deskclock/provider/ClockDatabaseHelper.java @@ -180,7 +180,7 @@ public void onUpgrade(SQLiteDatabase db, int oldVersion, int currentVersion) { // Save new version of alarm and create alarm instance for it db.insert(TEMP_ALARMS_TABLE_NAME, null, Alarm.createContentValues(alarm)); if (alarm.enabled) { - AlarmInstance newInstance = alarm.createInstanceAfter(currentTime); + AlarmInstance newInstance = alarm.createInstanceAfter(context, currentTime); db.insert(TEMP_INSTANCES_TABLE_NAME, null, AlarmInstance.createContentValues(newInstance)); } diff --git a/app/src/main/java/com/best/deskclock/settings/AlarmSettingsFragment.java b/app/src/main/java/com/best/deskclock/settings/AlarmSettingsFragment.java index 73dc120f3..cfdc02327 100644 --- a/app/src/main/java/com/best/deskclock/settings/AlarmSettingsFragment.java +++ b/app/src/main/java/com/best/deskclock/settings/AlarmSettingsFragment.java @@ -1,62 +1,99 @@ // SPDX-License-Identifier: GPL-3.0-only package com.best.deskclock.settings; +import com.best.deskclock.holiday.HolidayRepository; -import static com.best.deskclock.DeskClock.REQUEST_CHANGE_SETTINGS; -import static com.best.deskclock.settings.PreferencesDefaultValues.ALARM_TIMEOUT_END_OF_RINGTONE; +import static android.app.Activity.RESULT_OK; +import static com.best.deskclock.settings.PreferencesDefaultValues.ALARM_SNOOZE_DURATION_DISABLED; +import static com.best.deskclock.settings.PreferencesDefaultValues.DEFAULT_ALARM_VOLUME; +import static com.best.deskclock.settings.PreferencesDefaultValues.DEFAULT_VIBRATION_START_DELAY; +import static com.best.deskclock.settings.PreferencesDefaultValues.DEFAULT_VOLUME_CRESCENDO_DURATION; +import static com.best.deskclock.settings.PreferencesDefaultValues.TIMEOUT_END_OF_RINGTONE; +import static com.best.deskclock.settings.PreferencesDefaultValues.TIMEOUT_NEVER; +import static com.best.deskclock.settings.PreferencesKeys.KEY_ADVANCED_AUDIO_PLAYBACK; import static com.best.deskclock.settings.PreferencesKeys.KEY_ALARM_DISPLAY_CUSTOMIZATION; +import static com.best.deskclock.settings.PreferencesKeys.KEY_ALARM_FONT; import static com.best.deskclock.settings.PreferencesKeys.KEY_ALARM_NOTIFICATION_REMINDER_TIME; +import static com.best.deskclock.settings.PreferencesKeys.KEY_ALARM_SNOOZE_DURATION; +import static com.best.deskclock.settings.PreferencesKeys.KEY_ALARM_VIBRATION_CATEGORY; +import static com.best.deskclock.settings.PreferencesKeys.KEY_ALARM_VOLUME_CRESCENDO_DURATION; import static com.best.deskclock.settings.PreferencesKeys.KEY_ALARM_VOLUME_SETTING; -import static com.best.deskclock.settings.PreferencesKeys.KEY_AUTO_ROUTING_TO_BLUETOOTH_DEVICE; -import static com.best.deskclock.settings.PreferencesKeys.KEY_BLUETOOTH_VOLUME; +import static com.best.deskclock.settings.PreferencesKeys.KEY_AUTO_ROUTING_TO_EXTERNAL_AUDIO_DEVICE; +import static com.best.deskclock.settings.PreferencesKeys.KEY_AUTO_SILENCE_DURATION; import static com.best.deskclock.settings.PreferencesKeys.KEY_DEFAULT_ALARM_RINGTONE; +import static com.best.deskclock.settings.PreferencesKeys.KEY_DISPLAY_DISMISS_BUTTON; +import static com.best.deskclock.settings.PreferencesKeys.KEY_DISPLAY_ENABLED_ALARMS_FIRST; +import static com.best.deskclock.settings.PreferencesKeys.KEY_ENABLE_ALARM_FAB_LONG_PRESS; import static com.best.deskclock.settings.PreferencesKeys.KEY_ENABLE_ALARM_VIBRATIONS_BY_DEFAULT; import static com.best.deskclock.settings.PreferencesKeys.KEY_ENABLE_DELETE_OCCASIONAL_ALARM_BY_DEFAULT; +import static com.best.deskclock.settings.PreferencesKeys.KEY_ENABLE_PER_ALARM_AUTO_SILENCE; +import static com.best.deskclock.settings.PreferencesKeys.KEY_ENABLE_PER_ALARM_MISSED_REPEAT_LIMIT; +import static com.best.deskclock.settings.PreferencesKeys.KEY_ENABLE_PER_ALARM_SNOOZE_DURATION; +import static com.best.deskclock.settings.PreferencesKeys.KEY_ENABLE_PER_ALARM_VIBRATION_PATTERN; import static com.best.deskclock.settings.PreferencesKeys.KEY_ENABLE_PER_ALARM_VOLUME; +import static com.best.deskclock.settings.PreferencesKeys.KEY_ENABLE_PER_ALARM_VOLUME_CRESCENDO_DURATION; import static com.best.deskclock.settings.PreferencesKeys.KEY_ENABLE_SNOOZED_OR_DISMISSED_ALARM_VIBRATIONS; +import static com.best.deskclock.settings.PreferencesKeys.KEY_EXTERNAL_AUDIO_DEVICE_VOLUME; import static com.best.deskclock.settings.PreferencesKeys.KEY_FLIP_ACTION; -import static com.best.deskclock.settings.PreferencesKeys.KEY_HOLIDAY_DATA_URL; import static com.best.deskclock.settings.PreferencesKeys.KEY_MATERIAL_DATE_PICKER_STYLE; import static com.best.deskclock.settings.PreferencesKeys.KEY_MATERIAL_TIME_PICKER_STYLE; import static com.best.deskclock.settings.PreferencesKeys.KEY_POWER_BUTTON; -import static com.best.deskclock.settings.PreferencesKeys.KEY_ADVANCED_AUDIO_PLAYBACK; +import static com.best.deskclock.settings.PreferencesKeys.KEY_REPEAT_MISSED_ALARM; import static com.best.deskclock.settings.PreferencesKeys.KEY_SHAKE_ACTION; import static com.best.deskclock.settings.PreferencesKeys.KEY_SHAKE_INTENSITY; +import static com.best.deskclock.settings.PreferencesKeys.KEY_SORT_ALARM; import static com.best.deskclock.settings.PreferencesKeys.KEY_SYSTEM_MEDIA_VOLUME; import static com.best.deskclock.settings.PreferencesKeys.KEY_TURN_ON_BACK_FLASH_FOR_TRIGGERED_ALARM; -import static com.best.deskclock.settings.PreferencesKeys.KEY_UPDATE_HOLIDAY_DATA; +import static com.best.deskclock.settings.PreferencesKeys.KEY_VIBRATION_PATTERN; import static com.best.deskclock.settings.PreferencesKeys.KEY_VOLUME_BUTTONS; import static com.best.deskclock.settings.PreferencesKeys.KEY_WEEK_START; -import android.app.Application; import android.content.ContentResolver; import android.content.Context; +import android.content.Intent; import android.hardware.Sensor; import android.hardware.SensorManager; import android.media.AudioDeviceCallback; import android.media.AudioDeviceInfo; import android.media.AudioManager; +import android.net.Uri; import android.os.Bundle; import android.os.Handler; import android.os.Looper; +import androidx.activity.result.ActivityResultLauncher; +import androidx.activity.result.contract.ActivityResultContracts; import androidx.annotation.NonNull; +import androidx.annotation.StringRes; +import androidx.appcompat.app.AlertDialog; import androidx.preference.ListPreference; import androidx.preference.Preference; +import androidx.preference.PreferenceCategory; import androidx.preference.SwitchPreferenceCompat; import com.best.deskclock.AlarmSnoozeDurationDialogFragment; import com.best.deskclock.AutoSilenceDurationDialogFragment; import com.best.deskclock.R; +import com.best.deskclock.VibrationPatternDialogFragment; +import com.best.deskclock.VibrationStartDelayDialogFragment; import com.best.deskclock.VolumeCrescendoDurationDialogFragment; import com.best.deskclock.alarms.AlarmUpdateHandler; import com.best.deskclock.data.DataModel; import com.best.deskclock.data.SettingsDAO; import com.best.deskclock.data.Weekdays; -import com.best.deskclock.holiday.HolidayRepository; import com.best.deskclock.provider.Alarm; import com.best.deskclock.ringtone.RingtonePickerActivity; +import com.best.deskclock.settings.custompreference.AlarmSnoozeDurationPreference; +import com.best.deskclock.settings.custompreference.AlarmVolumePreference; +import com.best.deskclock.settings.custompreference.AutoSilenceDurationPreference; +import com.best.deskclock.settings.custompreference.CustomSliderPreference; +import com.best.deskclock.settings.custompreference.VibrationPatternPreference; +import com.best.deskclock.settings.custompreference.VibrationStartDelayPreference; +import com.best.deskclock.settings.custompreference.VolumeCrescendoDurationPreference; +import com.best.deskclock.uicomponents.CustomDialog; +import com.best.deskclock.uicomponents.toast.CustomToast; import com.best.deskclock.utils.AlarmUtils; +import com.best.deskclock.utils.DeviceUtils; import com.best.deskclock.utils.RingtoneUtils; import com.best.deskclock.utils.Utils; @@ -67,21 +104,39 @@ public class AlarmSettingsFragment extends ScreenFragment private AudioManager mAudioManager; private AudioDeviceCallback mAudioDeviceCallback; + private AlarmUpdateHandler mAlarmUpdateHandler; + private List+ * This preference stores the snooze duration in minutes using Android's shared preferences system. + * When shown in the preferences UI, it opens a custom dialog where the user can input hours and minutes. + *
+ */ +public class AlarmSnoozeDurationPreference extends DialogPreference { + + /** + * Constructs a new AlarmSnoozeDurationPreference instance, used to manage user preferences + * related to alarm snooze duration. + * + * @param context The application context in which this preference is used. + * @param attrs The attribute set from XML that may include custom parameters. + */ + public AlarmSnoozeDurationPreference(Context context, AttributeSet attrs) { + super(context, attrs); + setPersistent(true); + } + + @Override + public CharSequence getSummary() { + int minutes = getSnoozeDuration(); + + if (minutes == ALARM_SNOOZE_DURATION_DISABLED) { + return getContext().getString(R.string.snooze_duration_none); + } + + int h = minutes / 60; + int m = minutes % 60; + + if (h > 0 && m > 0) { + String hoursString = getContext().getResources().getQuantityString(R.plurals.hours, h, h); + String minutesString = getContext().getResources().getQuantityString(R.plurals.minutes, m, m); + return String.format("%s %s", hoursString, minutesString); + } else if (h > 0) { + return getContext().getResources().getQuantityString(R.plurals.hours, h, h); + } else { + return getContext().getResources().getQuantityString(R.plurals.minutes, m, m); + } + } + + /** + * Returns the currently persisted snooze delay duration in minutes. + * + * @return The snooze delay in minutes, or 10 if no value has been previously persisted. + */ + public int getSnoozeDuration() { + return getPersistedInt(DEFAULT_ALARM_SNOOZE_DURATION); + } + + /** + * Persists the snooze delay duration in minutes. + * + * @param minutes The snooze duration to be stored, in minutes. + */ + public void setSnoozeDuration(int minutes) { + persistInt(minutes); + } + +} diff --git a/app/src/main/java/com/best/deskclock/settings/custompreference/AlarmVolumePreference.java b/app/src/main/java/com/best/deskclock/settings/custompreference/AlarmVolumePreference.java new file mode 100644 index 000000000..16ee6d028 --- /dev/null +++ b/app/src/main/java/com/best/deskclock/settings/custompreference/AlarmVolumePreference.java @@ -0,0 +1,237 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * modified + * SPDX-License-Identifier: Apache-2.0 AND GPL-3.0-only + */ + +package com.best.deskclock.settings.custompreference; + +import static android.content.Context.AUDIO_SERVICE; +import static android.media.AudioManager.STREAM_ALARM; +import static android.view.View.GONE; + +import static com.best.deskclock.DeskClockApplication.getDefaultSharedPreferences; +import static com.best.deskclock.utils.RingtoneUtils.ALARM_PREVIEW_DURATION_MS; + +import android.content.Context; +import android.content.SharedPreferences; +import android.database.ContentObserver; +import android.media.AudioManager; +import android.net.Uri; +import android.os.Handler; +import android.os.Looper; +import android.provider.Settings; +import android.util.AttributeSet; +import android.view.View; +import android.widget.ImageView; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.appcompat.content.res.AppCompatResources; +import androidx.preference.Preference; +import androidx.preference.PreferenceViewHolder; + +import com.best.deskclock.R; +import com.best.deskclock.data.DataModel; +import com.best.deskclock.ringtone.RingtonePreviewKlaxon; +import com.best.deskclock.utils.RingtoneUtils; +import com.best.deskclock.utils.ThemeUtils; +import com.google.android.material.slider.Slider; + +import java.util.Locale; + +public class AlarmVolumePreference extends Preference { + + private Context mContext; + private SharedPreferences mPrefs; + private Slider mSlider; + private ImageView mSliderMinus; + private ImageView mSliderPlus; + private AudioManager mAudioManager; + private int mMinVolume; + private final Handler mRingtoneHandler = new Handler(Looper.getMainLooper()); + private Runnable mRingtoneStopRunnable; + private boolean mIsPreviewPlaying = false; + + public AlarmVolumePreference(Context context, AttributeSet attrs) { + super(context, attrs); + } + + @Override + public void onBindViewHolder(@NonNull PreferenceViewHolder holder) { + if (holder.itemView.isInEditMode()) { + // Skip logic during Android Studio preview + return; + } + + mContext = getContext(); + mPrefs = getDefaultSharedPreferences(mContext); + + super.onBindViewHolder(holder); + + mAudioManager = (AudioManager) mContext.getSystemService(AUDIO_SERVICE); + + // Disable click feedback for this preference. + holder.itemView.setClickable(false); + + // Minimum volume for alarm is not 0, calculate it. + mMinVolume = RingtoneUtils.getAlarmMinVolume(mAudioManager); + int maxVolume = mAudioManager.getStreamMaxVolume(STREAM_ALARM) - mMinVolume; + mSlider = (Slider) holder.findViewById(R.id.slider); + mSlider.setValueTo(maxVolume); + mSlider.setValueFrom(0f); + mSlider.setStepSize(1f); + mSlider.setValue((float) mAudioManager.getStreamVolume(STREAM_ALARM) - mMinVolume); + + final TextView sliderSummary = (TextView) holder.findViewById(android.R.id.summary); + updateSliderSummary(sliderSummary); + + mSliderMinus = (ImageView) holder.findViewById(R.id.slider_minus_icon); + mSliderMinus.setImageDrawable(AppCompatResources.getDrawable(mContext, R.drawable.ic_volume_down)); + + mSliderPlus = (ImageView) holder.findViewById(R.id.slider_plus_icon); + mSliderPlus.setImageDrawable(AppCompatResources.getDrawable(mContext, R.drawable.ic_volume_up)); + + setupVolumeSliderButton(mSliderMinus, -1); + setupVolumeSliderButton(mSliderPlus, +1); + updateSliderButtonStates(); + + final TextView resetSlider = (TextView) holder.findViewById(R.id.reset_slider_value); + resetSlider.setVisibility(GONE); + + final ContentObserver volumeObserver = new ContentObserver(mSlider.getHandler()) { + @Override + public void onChange(boolean selfChange) { + // Volume was changed elsewhere, update our slider. + float currentVolume = (float) (mAudioManager.getStreamVolume(STREAM_ALARM) - mMinVolume); + mSlider.setValue(currentVolume); + updateSliderSummary(sliderSummary); + } + }; + + mSlider.addOnAttachStateChangeListener(new View.OnAttachStateChangeListener() { + @Override + public void onViewAttachedToWindow(@NonNull View v) { + mContext.getContentResolver().registerContentObserver(Settings.System.CONTENT_URI, + true, volumeObserver); + } + + @Override + public void onViewDetachedFromWindow(@NonNull View v) { + mContext.getContentResolver().unregisterContentObserver(volumeObserver); + } + }); + + mSlider.addOnChangeListener((slider, progress, fromUser) -> { + if (fromUser) { + int newVolume = (int) progress + mMinVolume; + mAudioManager.setStreamVolume(STREAM_ALARM, newVolume, 0); + updateSliderSummary(sliderSummary); + updateSliderButtonStates(); + } + }); + + mSlider.addOnSliderTouchListener(new Slider.OnSliderTouchListener() { + @Override + public void onStartTrackingTouch(@NonNull Slider slider) { + startRingtonePreview(); + } + + @Override + public void onStopTrackingTouch(@NonNull Slider slider) { + } + }); + } + + /** + * Updates the summary text view to show the current alarm volume as a percentage. + */ + private void updateSliderSummary(TextView sliderSummary) { + int currentVolume = mAudioManager.getStreamVolume(STREAM_ALARM); + int maxVolume = mAudioManager.getStreamMaxVolume(STREAM_ALARM); + int volumePercentage = (int) (((float) currentVolume / maxVolume) * 100); + + String formattedText = String.format(Locale.getDefault(), "%d%%", volumePercentage); + sliderSummary.post(() -> sliderSummary.setText(formattedText)); + } + + /** + * Configures the minus or plus button to adjust the alarm volume when clicked. + * + * @param button the ImageView button (minus or plus) + * @param delta +1 to increase volume, -1 to decrease + */ + private void setupVolumeSliderButton(@NonNull ImageView button, int delta) { + button.setOnClickListener(v -> { + int current = (int) mSlider.getValue(); + int max = (int) mSlider.getValueTo(); + + int newValue = Math.min(Math.max(current + delta, 0), max); + if (newValue != current) { + mSlider.setValue(newValue); + updateVolume(mAudioManager); + startRingtonePreview(); + updateSliderButtonStates(); + } + }); + } + + /** + * Enables or disables the minus and plus buttons based on the current slider value + * and the enabled state of the preference. + */ + private void updateSliderButtonStates() { + boolean isPrefEnabled = isEnabled(); + int progress = (int) mSlider.getValue(); + int max = (int) mSlider.getValueTo(); + + ThemeUtils.updateSliderButtonEnabledState(mContext, mSliderMinus, isPrefEnabled + && progress > 0 + && !RingtoneUtils.hasExternalAudioDeviceConnected(mContext, mPrefs)); + ThemeUtils.updateSliderButtonEnabledState(mContext, mSliderPlus, isPrefEnabled + && progress < max + && !RingtoneUtils.hasExternalAudioDeviceConnected(mContext, mPrefs)); + } + + private void updateVolume(AudioManager audioManager) { + int newVolume = (int) mSlider.getValue() + mMinVolume; + audioManager.setStreamVolume(STREAM_ALARM, newVolume, 0); + } + + private void startRingtonePreview() { + if (mRingtoneStopRunnable != null) { + mRingtoneHandler.removeCallbacks(mRingtoneStopRunnable); + } + + // If we are not currently playing, start. + Uri ringtoneUri = DataModel.getDataModel().getAlarmRingtoneUriFromSettings(); + if (RingtoneUtils.isRandomRingtone(ringtoneUri)) { + ringtoneUri = RingtoneUtils.getRandomRingtoneUri(); + } else if (RingtoneUtils.isRandomCustomRingtone(ringtoneUri)) { + ringtoneUri = RingtoneUtils.getRandomCustomRingtoneUri(); + } + + RingtonePreviewKlaxon.start(mContext, mPrefs, ringtoneUri); + mIsPreviewPlaying = true; + + mRingtoneStopRunnable = this::stopRingtonePreview; + // Stop the preview after 5 seconds + mRingtoneHandler.postDelayed(mRingtoneStopRunnable, ALARM_PREVIEW_DURATION_MS); + } + + public void stopRingtonePreview() { + if (!mIsPreviewPlaying) { + return; + } + + if (mRingtoneStopRunnable != null) { + mRingtoneHandler.removeCallbacks(mRingtoneStopRunnable); + } + + RingtonePreviewKlaxon.stop(mContext, mPrefs); + RingtonePreviewKlaxon.releaseResources(); + + mIsPreviewPlaying = false; + } + +} diff --git a/app/src/main/java/com/best/deskclock/settings/custompreference/AutoSilenceDurationPreference.java b/app/src/main/java/com/best/deskclock/settings/custompreference/AutoSilenceDurationPreference.java new file mode 100644 index 000000000..9c6cf7c94 --- /dev/null +++ b/app/src/main/java/com/best/deskclock/settings/custompreference/AutoSilenceDurationPreference.java @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: GPL-3.0-only + +package com.best.deskclock.settings.custompreference; + +import static com.best.deskclock.settings.PreferencesDefaultValues.DEFAULT_AUTO_SILENCE_DURATION; +import static com.best.deskclock.settings.PreferencesDefaultValues.DEFAULT_TIMER_AUTO_SILENCE_DURATION; +import static com.best.deskclock.settings.PreferencesDefaultValues.TIMEOUT_END_OF_RINGTONE; +import static com.best.deskclock.settings.PreferencesDefaultValues.TIMEOUT_NEVER; +import static com.best.deskclock.settings.PreferencesKeys.KEY_TIMER_AUTO_SILENCE_DURATION; + +import android.content.Context; +import android.util.AttributeSet; + +import androidx.preference.DialogPreference; + +import com.best.deskclock.R; + +public class AutoSilenceDurationPreference extends DialogPreference { + + /** + * Constructs a new AutoSilenceDurationPreference instance, used to manage user preferences + * related to auto silence duration. + * + * @param context The application context in which this preference is used. + * @param attrs The attribute set from XML that may include custom parameters. + */ + public AutoSilenceDurationPreference(Context context, AttributeSet attrs) { + super(context, attrs); + setPersistent(true); + } + + @Override + public CharSequence getSummary() { + int duration = getAutoSilenceDuration(); + + if (duration == TIMEOUT_END_OF_RINGTONE) { + return getContext().getString(R.string.auto_silence_end_of_ringtone); + } else if (duration == TIMEOUT_NEVER) { + return getContext().getString(R.string.label_never); + } + + int m = duration / 60; + int s = duration % 60; + + if (m > 0 && s > 0) { + String minutesString = getContext().getResources().getQuantityString(R.plurals.minutes, m, m); + String secondsString = getContext().getResources().getQuantityString(R.plurals.seconds, s, s); + return String.format("%s %s", minutesString, secondsString); + } else if (m > 0) { + return getContext().getResources().getQuantityString(R.plurals.minutes, m, m); + } else { + return getContext().getResources().getQuantityString(R.plurals.seconds, s, s); + } + } + + /** + * Returns the currently persisted auto silence duration + * For alarms : in minutes. + * For timers : in seconds. + * + * @return The auto silence duration, or none if no value has been previously persisted. + */ + public int getAutoSilenceDuration() { + return getPersistedInt(isForTimer() + ? DEFAULT_TIMER_AUTO_SILENCE_DURATION + : DEFAULT_AUTO_SILENCE_DURATION); + } + + /** + * @return {@code true} if this preference is for timers; {@code false} if it is for alarms. + */ + public boolean isForTimer() { + return KEY_TIMER_AUTO_SILENCE_DURATION.equals(getKey()); + } + + /** + * Persists the auto silence duration. + * + * @param duration Duration in seconds (for timers) or minutes (for alarms). + */ + public void setAutoSilenceDuration(int duration) { + persistInt(duration); + } + +} diff --git a/app/src/main/java/com/best/deskclock/settings/custompreference/ColorPickerPreference.java b/app/src/main/java/com/best/deskclock/settings/custompreference/ColorPickerPreference.java new file mode 100644 index 000000000..52d360542 --- /dev/null +++ b/app/src/main/java/com/best/deskclock/settings/custompreference/ColorPickerPreference.java @@ -0,0 +1,81 @@ +// SPDX-License-Identifier: GPL-3.0-only + +package com.best.deskclock.settings.custompreference; + +import static androidx.core.util.TypedValueCompat.dpToPx; + +import android.content.Context; +import android.graphics.Color; +import android.graphics.drawable.GradientDrawable; +import android.util.AttributeSet; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.LinearLayout; + +import androidx.annotation.NonNull; +import androidx.core.content.ContextCompat; +import androidx.preference.PreferenceViewHolder; + +import com.best.deskclock.R; +import com.best.deskclock.utils.ThemeUtils; + +import com.rarepebble.colorpicker.ColorPreference; + +/** + * This class extends {@link ColorPreference} and overrides the view binding to show a circular thumbnail + * with the currently selected color. + */ +public class ColorPickerPreference extends ColorPreference { + + public ColorPickerPreference(Context context, AttributeSet attrs) { + super(context, attrs); + } + + @Override + public void onBindViewHolder(@NonNull PreferenceViewHolder holder) { + if (holder.itemView.isInEditMode()) { + // Skip logic during Android Studio preview + return; + } + + super.onBindViewHolder(holder); + + View thumbnail = addThumbnail(holder.itemView); + if (thumbnail != null) { + View colorPreview = thumbnail.findViewById(R.id.colorPreview); + if (colorPreview != null) { + int color = getColor(); + GradientDrawable circle = (GradientDrawable) ThemeUtils.circleDrawable(); + circle.setColor(color); + colorPreview.setBackground(circle); + } + + View border = thumbnail.findViewById(R.id.border); + if (border != null) { + GradientDrawable borderCircle = new GradientDrawable(); + borderCircle.setShape(GradientDrawable.OVAL); + borderCircle.setColor(Color.TRANSPARENT); + borderCircle.setStroke( + (int) dpToPx(2, getContext().getResources().getDisplayMetrics()), + ContextCompat.getColor(getContext(), R.color.md_theme_outline) + ); + border.setBackground(borderCircle); + } + } + } + + private View addThumbnail(View view) { + LinearLayout widgetFrameView = view.findViewById(android.R.id.widget_frame); + if (widgetFrameView == null) { + return null; + } + + widgetFrameView.setVisibility(View.VISIBLE); + widgetFrameView.removeAllViews(); + + LayoutInflater.from(getContext()).inflate(R.layout.settings_preference_color_thumbnail, + widgetFrameView); + + return widgetFrameView.findViewById(R.id.thumbnail); + } +} diff --git a/app/src/main/java/com/best/deskclock/settings/custompreference/CustomAboutTitlePreference.java b/app/src/main/java/com/best/deskclock/settings/custompreference/CustomAboutTitlePreference.java new file mode 100644 index 000000000..b70ec7314 --- /dev/null +++ b/app/src/main/java/com/best/deskclock/settings/custompreference/CustomAboutTitlePreference.java @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: GPL-3.0-only + +package com.best.deskclock.settings.custompreference; + +import static com.best.deskclock.DeskClockApplication.getDefaultSharedPreferences; + +import android.content.Context; +import android.content.SharedPreferences; +import android.util.AttributeSet; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.preference.Preference; +import androidx.preference.PreferenceViewHolder; + +import com.best.deskclock.R; +import com.best.deskclock.data.SettingsDAO; +import com.best.deskclock.utils.ThemeUtils; + +/** + * A {@link Preference} with a custom layout applied to the title in About. + */ +public class CustomAboutTitlePreference extends Preference { + + public CustomAboutTitlePreference(@NonNull Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + } + + @Override + public void onBindViewHolder(@NonNull PreferenceViewHolder holder) { + if (holder.itemView.isInEditMode()) { + return; + } + + super.onBindViewHolder(holder); + + Context context = holder.itemView.getContext(); + SharedPreferences prefs = getDefaultSharedPreferences(context); + String fontPath = SettingsDAO.getGeneralFont(prefs); + + TextView slogan = (TextView) holder.findViewById(R.id.slogan); + if (slogan != null) { + slogan.setTypeface(ThemeUtils.loadFont(fontPath)); + } + } + +} diff --git a/app/src/main/java/com/best/deskclock/settings/custompreference/CustomListPreference.java b/app/src/main/java/com/best/deskclock/settings/custompreference/CustomListPreference.java new file mode 100644 index 000000000..e69de29bb diff --git a/app/src/main/java/com/best/deskclock/settings/custompreference/CustomPreference.java b/app/src/main/java/com/best/deskclock/settings/custompreference/CustomPreference.java new file mode 100644 index 000000000..e69de29bb diff --git a/app/src/main/java/com/best/deskclock/settings/custompreference/CustomPreferenceCategory.java b/app/src/main/java/com/best/deskclock/settings/custompreference/CustomPreferenceCategory.java new file mode 100644 index 000000000..e69de29bb diff --git a/app/src/main/java/com/best/deskclock/settings/custompreference/CustomSeekbarPreference.java b/app/src/main/java/com/best/deskclock/settings/custompreference/CustomSeekbarPreference.java new file mode 100644 index 000000000..e69de29bb diff --git a/app/src/main/java/com/best/deskclock/settings/custompreference/CustomSliderPreference.java b/app/src/main/java/com/best/deskclock/settings/custompreference/CustomSliderPreference.java new file mode 100644 index 000000000..df6b1ec38 --- /dev/null +++ b/app/src/main/java/com/best/deskclock/settings/custompreference/CustomSliderPreference.java @@ -0,0 +1,670 @@ +// SPDX-License-Identifier: GPL-3.0-only + +package com.best.deskclock.settings.custompreference; + +import static com.best.deskclock.DeskClockApplication.getDefaultSharedPreferences; +import static com.best.deskclock.settings.PreferencesDefaultValues.DEFAULT_ANALOG_CLOCK_SIZE; +import static com.best.deskclock.settings.PreferencesDefaultValues.DEFAULT_DIGITAL_CLOCK_FONT_SIZE; +import static com.best.deskclock.settings.PreferencesDefaultValues.DEFAULT_SHADOW_OFFSET; +import static com.best.deskclock.settings.PreferencesDefaultValues.DEFAULT_BLUR_INTENSITY; +import static com.best.deskclock.settings.PreferencesDefaultValues.DEFAULT_ALARM_TITLE_FONT_SIZE_PREF; +import static com.best.deskclock.settings.PreferencesDefaultValues.DEFAULT_EXTERNAL_AUDIO_DEVICE_VOLUME; +import static com.best.deskclock.settings.PreferencesDefaultValues.DEFAULT_MATERIAL_YOU_WIDGET_BACKGROUND_CORNER_RADIUS; +import static com.best.deskclock.settings.PreferencesDefaultValues.DEFAULT_SCREENSAVER_BRIGHTNESS; +import static com.best.deskclock.settings.PreferencesDefaultValues.DEFAULT_SHAKE_INTENSITY; +import static com.best.deskclock.settings.PreferencesDefaultValues.DEFAULT_TIMER_SHAKE_INTENSITY; +import static com.best.deskclock.settings.PreferencesDefaultValues.DEFAULT_WIDGETS_FONT_SIZE; +import static com.best.deskclock.settings.PreferencesDefaultValues.DEFAULT_WIDGET_BACKGROUND_CORNER_RADIUS; +import static com.best.deskclock.settings.PreferencesKeys.KEY_ALARM_ANALOG_CLOCK_SIZE; +import static com.best.deskclock.settings.PreferencesKeys.KEY_ALARM_BLUR_INTENSITY; +import static com.best.deskclock.settings.PreferencesKeys.KEY_ALARM_DIGITAL_CLOCK_FONT_SIZE; +import static com.best.deskclock.settings.PreferencesKeys.KEY_ALARM_SHADOW_OFFSET; +import static com.best.deskclock.settings.PreferencesKeys.KEY_ALARM_TITLE_FONT_SIZE_PREF; +import static com.best.deskclock.settings.PreferencesKeys.KEY_ANALOG_CLOCK_SIZE; +import static com.best.deskclock.settings.PreferencesKeys.KEY_DIGITAL_CLOCK_FONT_SIZE; +import static com.best.deskclock.settings.PreferencesKeys.KEY_DIGITAL_WIDGET_BACKGROUND_CORNER_RADIUS; +import static com.best.deskclock.settings.PreferencesKeys.KEY_EXTERNAL_AUDIO_DEVICE_VOLUME; +import static com.best.deskclock.settings.PreferencesKeys.KEY_MATERIAL_YOU_DIGITAL_WIDGET_BACKGROUND_CORNER_RADIUS; +import static com.best.deskclock.settings.PreferencesKeys.KEY_MATERIAL_YOU_NEXT_ALARM_WIDGET_BACKGROUND_CORNER_RADIUS; +import static com.best.deskclock.settings.PreferencesKeys.KEY_MATERIAL_YOU_VERTICAL_DIGITAL_WIDGET_BACKGROUND_CORNER_RADIUS; +import static com.best.deskclock.settings.PreferencesKeys.KEY_NEXT_ALARM_WIDGET_BACKGROUND_CORNER_RADIUS; +import static com.best.deskclock.settings.PreferencesKeys.KEY_SCREENSAVER_ANALOG_CLOCK_SIZE; +import static com.best.deskclock.settings.PreferencesKeys.KEY_SCREENSAVER_BLUR_INTENSITY; +import static com.best.deskclock.settings.PreferencesKeys.KEY_SCREENSAVER_BRIGHTNESS; +import static com.best.deskclock.settings.PreferencesKeys.KEY_SCREENSAVER_DIGITAL_CLOCK_FONT_SIZE; +import static com.best.deskclock.settings.PreferencesKeys.KEY_SHAKE_INTENSITY; +import static com.best.deskclock.settings.PreferencesKeys.KEY_TIMER_BLUR_INTENSITY; +import static com.best.deskclock.settings.PreferencesKeys.KEY_TIMER_SHADOW_OFFSET; +import static com.best.deskclock.settings.PreferencesKeys.KEY_TIMER_SHAKE_INTENSITY; +import static com.best.deskclock.settings.PreferencesKeys.KEY_VERTICAL_WIDGET_BACKGROUND_CORNER_RADIUS; +import static com.best.deskclock.utils.RingtoneUtils.ALARM_PREVIEW_DURATION_MS; + +import android.content.Context; +import android.content.SharedPreferences; +import android.content.res.ColorStateList; +import android.graphics.Color; +import android.net.Uri; +import android.os.Handler; +import android.os.Looper; +import android.util.AttributeSet; +import android.widget.ImageView; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.content.res.AppCompatResources; +import androidx.core.widget.TextViewCompat; +import androidx.preference.Preference; +import androidx.preference.PreferenceViewHolder; + +import com.best.deskclock.R; +import com.best.deskclock.data.DataModel; +import com.best.deskclock.data.SettingsDAO; +import com.best.deskclock.ringtone.RingtonePreviewKlaxon; +import com.best.deskclock.utils.RingtoneUtils; +import com.best.deskclock.utils.ThemeUtils; +import com.best.deskclock.utils.WidgetUtils; +import com.google.android.material.color.MaterialColors; +import com.google.android.material.slider.Slider; + +import java.util.Locale; + +public class CustomSliderPreference extends Preference { + + private static final int MIN_FONT_SIZE_VALUE = 20; + private static final int MIN_CORNER_RADIUS_VALUE = 0; + private static final int MIN_SHAKE_INTENSITY_VALUE = DEFAULT_SHAKE_INTENSITY; + private static final int MIN_TIMER_SHAKE_INTENSITY_VALUE = DEFAULT_TIMER_SHAKE_INTENSITY; + private static final int MIN_BRIGHTNESS_VALUE = 0; + private static final int MIN_EXTERNAL_AUDIO_DEVICE_VOLUME = 10; + private static final int MIN_SHADOW_OFFSET_VALUE = 1; + private static final int MIN_BLUR_INTENSITY_VALUE = 1; + private static final int MIN_ANALOG_CLOCK_SIZE_VALUE = 1; + + // The max values below correspond to the max values defined in the preferences XML files. + private static final int MAX_BRIGHTNESS_VALUE = 100; + private static final int MAX_CORNER_RADIUS_VALUE = 100; + private static final int MAX_SHAKE_INTENSITY_VALUE = 55; + private static final int MAX_TIMER_SHAKE_INTENSITY_VALUE = 55; + private static final int MAX_SHADOW_OFFSET_VALUE = 20; + private static final int MAX_BLUR_INTENSITY_VALUE = 100; + private static final int MAX_EXTERNAL_AUDIO_DEVICE_VOLUME = 100; + private static final int MAX_ANALOG_CLOCK_SIZE_VALUE = 100; + private static final int MAX_FONT_SIZE_VALUE = 200; + + private Context mContext; + private SharedPreferences mPrefs; + private Slider mSlider; + private ImageView mSliderMinus; + private ImageView mSliderPlus; + private TextView mResetSlider; + private final Handler mRingtoneHandler = new Handler(Looper.getMainLooper()); + private Runnable mRingtoneStopRunnable; + private boolean mIsPreviewPlaying = false; + + public CustomSliderPreference(@NonNull Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + } + + /** + * Binds the preference view, initializes the slider and associated UI elements, + * and sets up listeners for user interactions. + */ + @Override + public void onBindViewHolder(@NonNull PreferenceViewHolder holder) { + if (holder.itemView.isInEditMode()) { + // Skip logic during Android Studio preview + return; + } + + super.onBindViewHolder(holder); + + mContext = getContext(); + mPrefs = getDefaultSharedPreferences(mContext); + String fontPath = SettingsDAO.getGeneralFont(mPrefs); + + holder.itemView.setClickable(false); + + mSlider = (Slider) holder.findViewById(R.id.slider); + configureSliderBounds(); + mSlider.setStepSize(1f); + + final TextView sliderSummary = (TextView) holder.findViewById(android.R.id.summary); + setSliderProgress(sliderSummary); + + mSliderMinus = (ImageView) holder.findViewById(R.id.slider_minus_icon); + mSliderPlus = (ImageView) holder.findViewById(R.id.slider_plus_icon); + mResetSlider = (TextView) holder.findViewById(R.id.reset_slider_value); + + configureSliderButtonDrawables(); + setupSliderButton(mSliderMinus, isExternalAudioDeviceVolumePreference() ? -10 : -5, sliderSummary); + setupSliderButton(mSliderPlus, isExternalAudioDeviceVolumePreference() ? 10 : 5, sliderSummary); + updateSliderButtonStates(); + updateResetButtonStates(); + + mResetSlider.setTypeface(ThemeUtils.boldTypeface(fontPath)); + mResetSlider.setOnClickListener(v -> { + resetPreference(); + setSliderProgress(sliderSummary); + startRingtonePreviewForExternalAudioDevices(); + updateDigitalWidgets(); + updateSliderButtonStates(); + updateResetButtonStates(); + }); + + mSlider.addOnChangeListener((slider, progress, fromUser) -> { + if (fromUser) { + updateSliderSummary(sliderSummary, (int) progress); + updateSliderButtonStates(); + updateResetButtonStates(); + } + }); + + mSlider.addOnSliderTouchListener(new Slider.OnSliderTouchListener() { + @Override + public void onStartTrackingTouch(@NonNull Slider slider) { + startRingtonePreviewForExternalAudioDevices(); + } + + @Override + public void onStopTrackingTouch(@NonNull Slider slider) { + int finalProgress = (int) slider.getValue(); + saveSliderValue(finalProgress); + updateDigitalWidgets(); + } + }); + } + + /** + * Sets the minimum and maximum value of the slider based on the preference type. + */ + private void configureSliderBounds() { + if (isScreensaverBrightnessPreference()) { + mSlider.setValueTo(MAX_BRIGHTNESS_VALUE); + mSlider.setValueFrom(MIN_BRIGHTNESS_VALUE); + } else if (isDigitalWidgetBackgroundCornerRadius() + || isNextAlarmWidgetBackgroundCornerRadius() + || isVerticalWidgetBackgroundCornerRadius() + || isMaterialYouDigitalWidgetBackgroundCornerRadius() + || isMaterialYouNextAlarmWidgetBackgroundCornerRadius() + || isMaterialYouVerticalWidgetBackgroundCornerRadius()) { + mSlider.setValueTo(MAX_CORNER_RADIUS_VALUE); + mSlider.setValueFrom(MIN_CORNER_RADIUS_VALUE); + } else if (isShakeIntensityPreference()) { + mSlider.setValueTo(MAX_SHAKE_INTENSITY_VALUE); + mSlider.setValueFrom(MIN_SHAKE_INTENSITY_VALUE); + } else if (isTimerShakeIntensityPreference()) { + mSlider.setValueTo(MAX_TIMER_SHAKE_INTENSITY_VALUE); + mSlider.setValueFrom(MIN_TIMER_SHAKE_INTENSITY_VALUE); + } else if (isTimerShadowOffsetPreference() || isAlarmShadowOffsetPreference()) { + mSlider.setValueTo(MAX_SHADOW_OFFSET_VALUE); + mSlider.setValueFrom(MIN_SHADOW_OFFSET_VALUE); + } else if (isScreensaverBlurIntensityPreference() + || isTimerBlurIntensityPreference() + || isAlarmBlurIntensityPreference()) { + mSlider.setValueTo(MAX_BLUR_INTENSITY_VALUE); + mSlider.setValueFrom(MIN_BLUR_INTENSITY_VALUE); + } else if (isExternalAudioDeviceVolumePreference()) { + mSlider.setValueTo(MAX_EXTERNAL_AUDIO_DEVICE_VOLUME); + mSlider.setValueFrom(MIN_EXTERNAL_AUDIO_DEVICE_VOLUME); + } else if (isAnalogClockSizePreference() + || isScreensaverAnalogClockSizePreference() + || isAlarmAnalogClockSizePreference()) { + mSlider.setValueTo(MAX_ANALOG_CLOCK_SIZE_VALUE); + mSlider.setValueFrom(MIN_ANALOG_CLOCK_SIZE_VALUE); + } else { + mSlider.setValueTo(MAX_FONT_SIZE_VALUE); + mSlider.setValueFrom(MIN_FONT_SIZE_VALUE); + } + } + + /** + * Sets the slider value base on the current preference (screensaver brightness, widget font size, etc.) + * and updates the slider summary to reflect this value. + */ + private void setSliderProgress(TextView sliderSummary) { + int currentProgress = mPrefs.getInt(getKey(), getDefaultSliderValue()); + + updateSliderSummary(sliderSummary, currentProgress); + mSlider.setValue(currentProgress); + } + + /** + * Updates the slider summary. + */ + private void updateSliderSummary(TextView sliderSummary, int progress) { + if (progress == getDefaultSliderValue()) { + sliderSummary.setText(R.string.label_default); + } else if (isScreensaverBrightnessPreference() + || isScreensaverAnalogClockSizePreference() + || isExternalAudioDeviceVolumePreference() + || isAnalogClockSizePreference() + || isAlarmAnalogClockSizePreference()) { + String formattedText = String.format(Locale.getDefault(), "%d%%", progress); + sliderSummary.setText(formattedText); + } else { + sliderSummary.setText(String.valueOf(progress)); + } + } + + /** + * Sets the icons for the minus and plus buttons of the slider based on the current preference type. + */ + private void configureSliderButtonDrawables() { + if (isScreensaverBrightnessPreference()) { + mSliderMinus.setImageDrawable(AppCompatResources.getDrawable(mContext, R.drawable.ic_brightness_decrease)); + mSliderPlus.setImageDrawable(AppCompatResources.getDrawable(mContext, R.drawable.ic_brightness_increase)); + } else if (isDigitalWidgetBackgroundCornerRadius() + || isNextAlarmWidgetBackgroundCornerRadius() + || isVerticalWidgetBackgroundCornerRadius() + || isMaterialYouDigitalWidgetBackgroundCornerRadius() + || isMaterialYouNextAlarmWidgetBackgroundCornerRadius() + || isMaterialYouVerticalWidgetBackgroundCornerRadius()) { + mSliderMinus.setImageDrawable(AppCompatResources.getDrawable(mContext, R.drawable.ic_rounded_corner_decrease)); + mSliderPlus.setImageDrawable(AppCompatResources.getDrawable(mContext, R.drawable.ic_rounded_corner_increase)); + } else if (isShakeIntensityPreference() || isTimerShakeIntensityPreference()) { + mSliderMinus.setImageDrawable(AppCompatResources.getDrawable(mContext, R.drawable.ic_sensor_low)); + mSliderPlus.setImageDrawable(AppCompatResources.getDrawable(mContext, R.drawable.ic_sensor_high)); + } else if (isTimerShadowOffsetPreference() || isAlarmShadowOffsetPreference()) { + mSliderMinus.setImageDrawable(AppCompatResources.getDrawable(mContext, R.drawable.ic_shadow_decrease)); + mSliderPlus.setImageDrawable(AppCompatResources.getDrawable(mContext, R.drawable.ic_shadow_increase)); + } else if (isScreensaverBlurIntensityPreference() + || isTimerBlurIntensityPreference() + || isAlarmBlurIntensityPreference()) { + mSliderMinus.setImageDrawable(AppCompatResources.getDrawable(mContext, R.drawable.ic_blur_decrease)); + mSliderPlus.setImageDrawable(AppCompatResources.getDrawable(mContext, R.drawable.ic_blur_increase)); + } else if (isExternalAudioDeviceVolumePreference()) { + mSliderMinus.setImageDrawable(AppCompatResources.getDrawable(mContext, R.drawable.ic_volume_down)); + mSliderPlus.setImageDrawable(AppCompatResources.getDrawable(mContext, R.drawable.ic_volume_up)); + } else if (isAnalogClockSizePreference() + || isScreensaverAnalogClockSizePreference() + || isAlarmAnalogClockSizePreference()) { + mSliderMinus.setImageDrawable(AppCompatResources.getDrawable(mContext, R.drawable.ic_zoom_in)); + mSliderPlus.setImageDrawable(AppCompatResources.getDrawable(mContext, R.drawable.ic_zoom_out)); + } else { + mSliderMinus.setImageDrawable(AppCompatResources.getDrawable(mContext, R.drawable.ic_text_decrease)); + mSliderPlus.setImageDrawable(AppCompatResources.getDrawable(mContext, R.drawable.ic_text_increase)); + } + } + + /** + * Configures the buttons to increase or decrease the value of the slider. + * The interface is updated with the new value and saved in the preferences. + * Sends a broadcast if it concerns buttons related to widget settings (widget font size). + */ + private void setupSliderButton(ImageView button, final int delta, final TextView sliderSummary) { + button.setOnClickListener(v -> { + int newSliderValue = getNewSliderValue(delta); + mSlider.setValue(newSliderValue); + updateSliderSummary(sliderSummary, newSliderValue); + saveSliderValue(newSliderValue); + startRingtonePreviewForExternalAudioDevices(); + updateDigitalWidgets(); + updateSliderButtonStates(); + updateResetButtonStates(); + }); + } + + /** + * Updates the enabled state of the minus and plus buttons based on the current slider value + * and the enabled state of the preference. + * + *Disables the minus button if the current value is at the minimum allowed, + * and disables the plus button if the value is at the maximum allowed.
+ */ + private void updateSliderButtonStates() { + boolean isPrefEnabled = isEnabled(); + int current = (int) mSlider.getValue(); + int min = (int) mSlider.getValueFrom(); + int max = (int) mSlider.getValueTo(); + + ThemeUtils.updateSliderButtonEnabledState(mContext, mSliderMinus, isPrefEnabled && current > min); + ThemeUtils.updateSliderButtonEnabledState(mContext, mSliderPlus, isPrefEnabled && current < max); + } + + /** + * Updates the enabled state of the reset button based on the current slider value + * and the enabled state of the preference. + * + *The reset button is enabled only when the slider value differs from the default + * value for the current preference type.
+ */ + private void updateResetButtonStates() { + boolean isEnabled = isEnabled() && !isSliderAtDefault((int) mSlider.getValue()); + mResetSlider.setEnabled(isEnabled); + + if (isEnabled) { + int enabledColor = MaterialColors.getColor(mContext, androidx.appcompat.R.attr.colorPrimary, Color.BLACK); + + mResetSlider.setTextColor(enabledColor); + TextViewCompat.setCompoundDrawableTintList(mResetSlider, ColorStateList.valueOf(enabledColor)); + } else { + int disabledColor = mContext.getColor(R.color.colorDisabled); + mResetSlider.setTextColor(disabledColor); + TextViewCompat.setCompoundDrawableTintList(mResetSlider, ColorStateList.valueOf(disabledColor)); + } + } + + /** + * @return true if the slider is currently set to its default value, depending on the preference type. + */ + private boolean isSliderAtDefault(int currentValue) { + return currentValue == getDefaultSliderValue(); + } + + /** + * @return a new value for the slider by applying a delta to the current value while respecting + * the minimum and maximum value of the slider. + */ + private int getNewSliderValue(int delta) { + int currentSliderValue = (int) mSlider.getValue(); + + return (int) Math.min(Math.max(currentSliderValue + delta, mSlider.getValueFrom()), mSlider.getValueTo()); + } + + /** + * @return the default value for the slider depending on the preference type. + */ + private int getDefaultSliderValue() { + if (isScreensaverBrightnessPreference()) { + return DEFAULT_SCREENSAVER_BRIGHTNESS; + } else if (isDigitalWidgetBackgroundCornerRadius() + || isNextAlarmWidgetBackgroundCornerRadius() + || isVerticalWidgetBackgroundCornerRadius()) { + return DEFAULT_WIDGET_BACKGROUND_CORNER_RADIUS; + } else if (isMaterialYouDigitalWidgetBackgroundCornerRadius() + || isMaterialYouNextAlarmWidgetBackgroundCornerRadius() + || isMaterialYouVerticalWidgetBackgroundCornerRadius()) { + return DEFAULT_MATERIAL_YOU_WIDGET_BACKGROUND_CORNER_RADIUS; + } else if (isShakeIntensityPreference()) { + return DEFAULT_SHAKE_INTENSITY; + } else if (isTimerShakeIntensityPreference()) { + return DEFAULT_TIMER_SHAKE_INTENSITY; + } else if (isScreensaverDigitalClockFontSizePreference() + || isDigitalClockFontSizePreference() + || isAlarmDigitalClockFontSizePreference()) { + return DEFAULT_DIGITAL_CLOCK_FONT_SIZE; + } else if (isAlarmTitleFontSizePreference()) { + return DEFAULT_ALARM_TITLE_FONT_SIZE_PREF; + } else if (isTimerShadowOffsetPreference() || isAlarmShadowOffsetPreference()) { + return DEFAULT_SHADOW_OFFSET; + } else if (isScreensaverBlurIntensityPreference() + || isTimerBlurIntensityPreference() + || isAlarmBlurIntensityPreference()) { + return DEFAULT_BLUR_INTENSITY; + } else if (isExternalAudioDeviceVolumePreference()) { + return DEFAULT_EXTERNAL_AUDIO_DEVICE_VOLUME; + } else if (isAnalogClockSizePreference() + || isScreensaverAnalogClockSizePreference() + || isAlarmAnalogClockSizePreference()) { + return DEFAULT_ANALOG_CLOCK_SIZE; + } else { + return DEFAULT_WIDGETS_FONT_SIZE; + } + } + + /** + * Saves the current value of the slider in SharedPreferences using the appropriate preference key. + */ + private void saveSliderValue(int value) { + mPrefs.edit().putInt(getKey(), value).apply(); + } + + /** + * Resets the slider value. + */ + private void resetPreference() { + mPrefs.edit().remove(getKey()).apply(); + } + + /** + * Update digital widgets if the Preference is linked to the widgets one. + */ + private void updateDigitalWidgets() { + if (!isScreensaverBrightnessPreference() + && !isScreensaverDigitalClockFontSizePreference() + && !isScreensaverAnalogClockSizePreference() + && !isScreensaverBlurIntensityPreference() + && !isShakeIntensityPreference() + && !isTimerShakeIntensityPreference() + && !isTimerShadowOffsetPreference() + && !isTimerBlurIntensityPreference() + && !isAlarmDigitalClockFontSizePreference() + && !isAlarmTitleFontSizePreference() + && !isAlarmShadowOffsetPreference() + && !isAlarmBlurIntensityPreference() + && !isExternalAudioDeviceVolumePreference() + && !isAnalogClockSizePreference() + && !isAlarmAnalogClockSizePreference() + && !isDigitalClockFontSizePreference()) { + + WidgetUtils.updateAllDigitalWidgets(mContext); + } + } + + /** + * @return {@code true} if the current preference is related to screensaver brightness. + * {@code false} otherwise. + */ + private boolean isScreensaverBrightnessPreference() { + return getKey().equals(KEY_SCREENSAVER_BRIGHTNESS); + } + + /** + * @return {@code true} if the current preference is related to the screensaver analog clock size. + * {@code false} otherwise. + */ + private boolean isScreensaverAnalogClockSizePreference() { + return getKey().equals(KEY_SCREENSAVER_ANALOG_CLOCK_SIZE); + } + + /** + * @return {@code true} if the current preference is related to the font size of the screensaver + * clock. {@code false} otherwise. + */ + private boolean isScreensaverDigitalClockFontSizePreference() { + return getKey().equals(KEY_SCREENSAVER_DIGITAL_CLOCK_FONT_SIZE); + } + + /** + * @return {@code true} if the current preference is related to blur intensity for screensaver. + * {@code false} otherwise. + */ + private boolean isScreensaverBlurIntensityPreference() { + return getKey().equals(KEY_SCREENSAVER_BLUR_INTENSITY); + } + + /** + * @return {@code true} if the current preference is related to corner radius of + * the digital widget background. {@code false} otherwise. + */ + private boolean isDigitalWidgetBackgroundCornerRadius() { + return getKey().equals(KEY_DIGITAL_WIDGET_BACKGROUND_CORNER_RADIUS); + } + + /** + * @return {@code true} if the current preference is related to corner radius of + * the Next alarm widget background. {@code false} otherwise. + */ + private boolean isNextAlarmWidgetBackgroundCornerRadius() { + return getKey().equals(KEY_NEXT_ALARM_WIDGET_BACKGROUND_CORNER_RADIUS); + } + + /** + * @return {@code true} if the current preference is related to corner radius of + * the vertical widget background. {@code false} otherwise. + */ + private boolean isVerticalWidgetBackgroundCornerRadius() { + return getKey().equals(KEY_VERTICAL_WIDGET_BACKGROUND_CORNER_RADIUS); + } + + /** + * @return {@code true} if the current preference is related to corner radius of + * the Material You digital widget background. {@code false} otherwise. + */ + private boolean isMaterialYouDigitalWidgetBackgroundCornerRadius() { + return getKey().equals(KEY_MATERIAL_YOU_DIGITAL_WIDGET_BACKGROUND_CORNER_RADIUS); + } + + /** + * @return {@code true} if the current preference is related to corner radius of + * the Material You Next alarm widget background. {@code false} otherwise. + */ + private boolean isMaterialYouNextAlarmWidgetBackgroundCornerRadius() { + return getKey().equals(KEY_MATERIAL_YOU_NEXT_ALARM_WIDGET_BACKGROUND_CORNER_RADIUS); + } + + /** + * @return {@code true} if the current preference is related to corner radius of + * the Material You vertical digital widget background. {@code false} otherwise. + */ + private boolean isMaterialYouVerticalWidgetBackgroundCornerRadius() { + return getKey().equals(KEY_MATERIAL_YOU_VERTICAL_DIGITAL_WIDGET_BACKGROUND_CORNER_RADIUS); + } + + /** + * @return {@code true} if the current preference is related to shake intensity. + * {@code false} otherwise. + */ + private boolean isShakeIntensityPreference() { + return getKey().equals(KEY_SHAKE_INTENSITY); + } + + /** + * @return {@code true} if the current preference is related to timer shake intensity. + * {@code false} otherwise. + */ + private boolean isTimerShakeIntensityPreference() { + return getKey().equals(KEY_TIMER_SHAKE_INTENSITY); + } + + /** + * @return {@code true} if the current preference is related to shadow offset for timers. + * {@code false} otherwise. + */ + private boolean isTimerShadowOffsetPreference() { + return getKey().equals(KEY_TIMER_SHADOW_OFFSET); + } + + /** + * @return {@code true} if the current preference is related to blur intensity for timers. + * {@code false} otherwise. + */ + private boolean isTimerBlurIntensityPreference() { + return getKey().equals(KEY_TIMER_BLUR_INTENSITY); + } + + /** + * @return {@code true} if the current preference is related to the analog clock size. + * {@code false} otherwise. + */ + private boolean isAnalogClockSizePreference() { + return getKey().equals(KEY_ANALOG_CLOCK_SIZE); + } + + /** + * @return {@code true} if the current preference is related to the font size of the clock. + * {@code false} otherwise. + */ + private boolean isDigitalClockFontSizePreference() { + return getKey().equals(KEY_DIGITAL_CLOCK_FONT_SIZE); + } + + /** + * @return {@code true} if the current preference is related to the alarm analog clock size. + * {@code false} otherwise. + */ + private boolean isAlarmAnalogClockSizePreference() { + return getKey().equals(KEY_ALARM_ANALOG_CLOCK_SIZE); + } + + /** + * @return {@code true} if the current preference is related to the font size of the alarm clock. + * {@code false} otherwise. + */ + private boolean isAlarmDigitalClockFontSizePreference() { + return getKey().equals(KEY_ALARM_DIGITAL_CLOCK_FONT_SIZE); + } + + /** + * @return {@code true} if the current preference is related to the alarm title font size. + * {@code false} otherwise. + */ + private boolean isAlarmTitleFontSizePreference() { + return getKey().equals(KEY_ALARM_TITLE_FONT_SIZE_PREF); + } + + /** + * @return {@code true} if the current preference is related to shadow offset for alarms. + * {@code false} otherwise. + */ + private boolean isAlarmShadowOffsetPreference() { + return getKey().equals(KEY_ALARM_SHADOW_OFFSET); + } + + /** + * @return {@code true} if the current preference is related to blur intensity for alarms. + * {@code false} otherwise. + */ + private boolean isAlarmBlurIntensityPreference() { + return getKey().equals(KEY_ALARM_BLUR_INTENSITY); + } + + /** + * @return {@code true} if the current preference is related to volume when + * an external audio device is connected. {@code false} otherwise. + */ + private boolean isExternalAudioDeviceVolumePreference() { + return getKey().equals(KEY_EXTERNAL_AUDIO_DEVICE_VOLUME); + } + + /** + * Plays ringtone preview if preference is "External audio device volume" or if there is an + * external audio device connected. + */ + private void startRingtonePreviewForExternalAudioDevices() { + if (!isExternalAudioDeviceVolumePreference() + || !RingtoneUtils.hasExternalAudioDeviceConnected(mContext, mPrefs)) { + return; + } + + if (mRingtoneStopRunnable != null) { + mRingtoneHandler.removeCallbacks(mRingtoneStopRunnable); + } + + // If we are not currently playing, start. + Uri ringtoneUri = DataModel.getDataModel().getAlarmRingtoneUriFromSettings(); + if (RingtoneUtils.isRandomRingtone(ringtoneUri)) { + ringtoneUri = RingtoneUtils.getRandomRingtoneUri(); + } else if (RingtoneUtils.isRandomCustomRingtone(ringtoneUri)) { + ringtoneUri = RingtoneUtils.getRandomCustomRingtoneUri(); + } + + RingtonePreviewKlaxon.start(mContext, mPrefs, ringtoneUri); + mIsPreviewPlaying = true; + + mRingtoneStopRunnable = this::stopRingtonePreviewForExternalAudioDevices; + + // Stop the preview after 5 seconds + mRingtoneHandler.postDelayed(mRingtoneStopRunnable, ALARM_PREVIEW_DURATION_MS); + } + + /** + * Stops playing the ringtone preview if it is currently playing. + */ + public void stopRingtonePreviewForExternalAudioDevices() { + if (!mIsPreviewPlaying) { + return; + } + + if (mRingtoneStopRunnable != null) { + mRingtoneHandler.removeCallbacks(mRingtoneStopRunnable); + } + + RingtonePreviewKlaxon.stop(mContext, mPrefs); + RingtonePreviewKlaxon.stopListeningToPreferences(); + + mIsPreviewPlaying = false; + } + +} diff --git a/app/src/main/java/com/best/deskclock/settings/custompreference/CustomSwitchPreference.java b/app/src/main/java/com/best/deskclock/settings/custompreference/CustomSwitchPreference.java new file mode 100644 index 000000000..e69de29bb diff --git a/app/src/main/java/com/best/deskclock/settings/custompreference/PermissionsManagementPreference.java b/app/src/main/java/com/best/deskclock/settings/custompreference/PermissionsManagementPreference.java new file mode 100644 index 000000000..5e9309a1c --- /dev/null +++ b/app/src/main/java/com/best/deskclock/settings/custompreference/PermissionsManagementPreference.java @@ -0,0 +1,181 @@ +// SPDX-License-Identifier: GPL-3.0-only + +package com.best.deskclock.settings.custompreference; + +import static android.view.View.GONE; +import static android.view.View.VISIBLE; +import static com.best.deskclock.DeskClockApplication.getDefaultSharedPreferences; +import static com.best.deskclock.settings.PreferencesKeys.KEY_FULL_SCREEN_NOTIFICATION_PERMISSION; +import static com.best.deskclock.settings.PreferencesKeys.KEY_IGNORE_BATTERY_OPTIMIZATIONS; +import static com.best.deskclock.settings.PreferencesKeys.KEY_NOTIFICATION_PERMISSION; +import static com.best.deskclock.settings.PreferencesKeys.KEY_SHOW_LOCKSCREEN_PERMISSION; + +import android.content.Context; +import android.content.SharedPreferences; +import android.graphics.Typeface; +import android.util.AttributeSet; +import android.widget.ImageButton; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.content.res.AppCompatResources; +import androidx.preference.Preference; +import androidx.preference.PreferenceViewHolder; + +import com.best.deskclock.R; +import com.best.deskclock.data.SettingsDAO; +import com.best.deskclock.uicomponents.CustomDialog; +import com.best.deskclock.utils.PermissionUtils; +import com.best.deskclock.utils.ThemeUtils; + +public class PermissionsManagementPreference extends Preference { + + private Context mContext; + + private TextView mStatusState; + + public PermissionsManagementPreference(@NonNull Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + } + + @Override + public void onBindViewHolder(@NonNull PreferenceViewHolder holder) { + if (holder.itemView.isInEditMode()) { + // Skip logic during Android Studio preview + return; + } + + mContext = getContext(); + SharedPreferences prefs = getDefaultSharedPreferences(mContext); + String fontPath = SettingsDAO.getGeneralFont(prefs); + Typeface regularTypeFace = ThemeUtils.loadFont(fontPath); + + super.onBindViewHolder(holder); + + final TextView requirementTitle = (TextView) holder.findViewById(R.id.requirement_title); + final TextView requirementAdvice = (TextView) holder.findViewById(R.id.requirement_advice); + final TextView statusTitle = (TextView) holder.findViewById(R.id.status_title); + mStatusState = (TextView) holder.findViewById(R.id.status_state); + + requirementTitle.setTypeface(regularTypeFace); + requirementAdvice.setTypeface(regularTypeFace); + statusTitle.setTypeface(regularTypeFace); + mStatusState.setTypeface(regularTypeFace); + + if (isShowLockScreenPermissionPreference()) { + statusTitle.setText(mContext.getString(R.string.permission_info_title)); + mStatusState.setVisibility(GONE); + } else { + statusTitle.setText(mContext.getString(R.string.permission_status_title)); + + if (isIgnoreBatteryOtimizationsPreference()) { + setStatusText(PermissionUtils.isIgnoringBatteryOptimizations(mContext)); + } else if (isNotificationPermissionPreference()) { + setStatusText(PermissionUtils.areNotificationsEnabled(mContext)); + } else if (isFullScreenNotificationPermissionPreference()) { + setStatusText(PermissionUtils.areFullScreenNotificationsEnabled(mContext)); + } + + mStatusState.setVisibility(VISIBLE); + } + + ImageButton detailsButton = (ImageButton) holder.findViewById(R.id.details_button); + detailsButton.setOnClickListener(v -> displayPermissionDetailsDialog()); + + } + + /** + * Sets the permission status text. + */ + public void setStatusText(boolean isGranted) { + if (isGranted) { + mStatusState.setText(mContext.getString(R.string.permission_granted)); + mStatusState.setTextColor(mContext.getColor(R.color.colorGranted)); + } else { + mStatusState.setText(mContext.getString(R.string.permission_denied)); + mStatusState.setTextColor(mContext.getColor(R.color.colorAlert)); + } + } + + /** + * @return {@code true} if the current preference is related to the battery optimizations; + * {@code false} otherwise. + */ + private boolean isIgnoreBatteryOtimizationsPreference() { + return getKey().equals(KEY_IGNORE_BATTERY_OPTIMIZATIONS); + } + + /** + * @return {@code true} if the current preference is related to the notification permission; + * {@code false} otherwise. + */ + private boolean isNotificationPermissionPreference() { + return getKey().equals(KEY_NOTIFICATION_PERMISSION); + } + + /** + * @return {@code true} if the current preference is related to the full screen notification + * permission; {@code false} otherwise. + */ + private boolean isFullScreenNotificationPermissionPreference() { + return getKey().equals(KEY_FULL_SCREEN_NOTIFICATION_PERMISSION); + } + + /** + * @return {@code true} if the current preference is related to the "Show lockscreen" permission + * for MIUI devices; {@code false} otherwise. + */ + private boolean isShowLockScreenPermissionPreference() { + return getKey().equals(KEY_SHOW_LOCKSCREEN_PERMISSION); + } + + /** + * Display dialog when user wants to read the permission details. + */ + private void displayPermissionDetailsDialog() { + int iconId; + int titleId; + int messageId; + + if (isIgnoreBatteryOtimizationsPreference()) { + iconId = R.drawable.ic_battery_settings; + titleId = R.string.ignore_battery_optimizations_dialog_title; + messageId = R.string.ignore_battery_optimizations_dialog_text; + } else if (isNotificationPermissionPreference()) { + iconId = R.drawable.ic_notifications; + titleId = R.string.notifications_dialog_title; + messageId = R.string.notifications_dialog_text; + } else if (isFullScreenNotificationPermissionPreference()) { + iconId = R.drawable.ic_fullscreen; + titleId = R.string.FSN_dialog_title; + messageId = R.string.FSN_dialog_text; + } else { + iconId = R.drawable.ic_screen_lock; + titleId = R.string.show_lockscreen_dialog_title; + messageId = R.string.show_lockscreen_dialog_text; + } + + CustomDialog.create( + mContext, + null, + AppCompatResources.getDrawable(mContext, iconId), + mContext.getString(titleId), + mContext.getString(messageId), + null, + mContext.getString(R.string.dialog_close), + null, + null, + null, + null, + null, + null, + CustomDialog.SoftInputMode.NONE + ).show(); + } + + public void refreshState() { + notifyChanged(); + } + +} diff --git a/app/src/main/java/com/best/deskclock/settings/custompreference/PreferenceStyler.java b/app/src/main/java/com/best/deskclock/settings/custompreference/PreferenceStyler.java new file mode 100644 index 000000000..e69de29bb diff --git a/app/src/main/java/com/best/deskclock/settings/custompreference/TimerAddTimeButtonValuePreference.java b/app/src/main/java/com/best/deskclock/settings/custompreference/TimerAddTimeButtonValuePreference.java new file mode 100644 index 000000000..e81e499f7 --- /dev/null +++ b/app/src/main/java/com/best/deskclock/settings/custompreference/TimerAddTimeButtonValuePreference.java @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: GPL-3.0-only + +package com.best.deskclock.settings.custompreference; + +import static com.best.deskclock.settings.PreferencesDefaultValues.DEFAULT_TIMER_ADD_TIME_BUTTON_VALUE; + +import android.content.Context; +import android.util.AttributeSet; + +import androidx.preference.DialogPreference; + +import com.best.deskclock.R; + +public class TimerAddTimeButtonValuePreference extends DialogPreference { + + public TimerAddTimeButtonValuePreference(Context context, AttributeSet attrs) { + super(context, attrs); + setPersistent(true); + } + + @Override + public CharSequence getSummary() { + int value = getAddTimeButtonValue(); + + int m = value / 60; + int s = value % 60; + + if (m > 0 && s > 0) { + String hoursString = getContext().getResources().getQuantityString(R.plurals.minutes, m, m); + String secondString = getContext().getResources().getQuantityString(R.plurals.seconds, s, s); + return String.format("%s %s", hoursString, secondString); + } else if (m == 60) { + return getContext().getResources().getQuantityString(R.plurals.hours, 1, 1); + } else if (m > 0) { + return getContext().getResources().getQuantityString(R.plurals.minutes, m, m); + } else { + return getContext().getResources().getQuantityString(R.plurals.seconds, s, s); + } + } + + public int getAddTimeButtonValue() { + return getPersistedInt(DEFAULT_TIMER_ADD_TIME_BUTTON_VALUE); + } + + public void setAddTimeButtonValue(int minutes) { + persistInt(minutes); + } + +} diff --git a/app/src/main/java/com/best/deskclock/settings/custompreference/VibrationPatternPreference.java b/app/src/main/java/com/best/deskclock/settings/custompreference/VibrationPatternPreference.java new file mode 100644 index 000000000..e54a3b50a --- /dev/null +++ b/app/src/main/java/com/best/deskclock/settings/custompreference/VibrationPatternPreference.java @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: GPL-3.0-only + +package com.best.deskclock.settings.custompreference; + +import static com.best.deskclock.settings.PreferencesDefaultValues.DEFAULT_VIBRATION_PATTERN; + +import android.content.Context; +import android.util.AttributeSet; + +import androidx.preference.DialogPreference; + +import com.best.deskclock.R; + +/** + * A custom {@link DialogPreference} that allows users to select a vibration pattern. + *+ * This preference persists the selected pattern as a {@link String}, which can + * later be retrieved and used to generate a {@link android.os.VibrationEffect}. + */ +public class VibrationPatternPreference extends DialogPreference { + + /** + * Creates a new {@link VibrationPatternPreference} instance. + * + * @param context The application context. + * @param attrs The attribute set containing custom XML attributes. + */ + public VibrationPatternPreference(Context context, AttributeSet attrs) { + super(context, attrs); + setPersistent(true); + } + + @Override + public CharSequence getSummary() { + String patternKey = getPattern(); + + String[] entries = getContext().getResources().getStringArray(R.array.vibration_pattern_entries); + String[] values = getContext().getResources().getStringArray(R.array.vibration_pattern_values); + + for (int i = 0; i < values.length; i++) { + if (values[i].equals(patternKey)) { + return entries[i]; + } + } + + return super.getSummary(); + } + + /** + * Retrieves the currently persisted vibration pattern key. + * + * @return The key corresponding to the selected vibration pattern, + * or a default value if none has been set. + */ + public String getPattern() { + return getPersistedString(DEFAULT_VIBRATION_PATTERN); + } + + /** + * Persists the given vibration pattern key. + * + * @param patternKey The key of the vibration pattern to store. + */ + public void setPattern(String patternKey) { + persistString(patternKey); + } + +} diff --git a/app/src/main/java/com/best/deskclock/settings/custompreference/VibrationStartDelayPreference.java b/app/src/main/java/com/best/deskclock/settings/custompreference/VibrationStartDelayPreference.java new file mode 100644 index 000000000..2082baac8 --- /dev/null +++ b/app/src/main/java/com/best/deskclock/settings/custompreference/VibrationStartDelayPreference.java @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: GPL-3.0-only + +package com.best.deskclock.settings.custompreference; + +import static com.best.deskclock.settings.PreferencesDefaultValues.DEFAULT_VIBRATION_START_DELAY; + +import android.content.Context; +import android.util.AttributeSet; + +import androidx.preference.DialogPreference; + +import com.best.deskclock.R; + +/** + * A custom {@link DialogPreference} that allows users to select a vibration start delay. + */ +public class VibrationStartDelayPreference extends DialogPreference { + + /** + * Constructs a new {@link VibrationStartDelayPreference} instance, used to manage user preferences + * related to vibration start delay. + * + * @param context The application context in which this preference is used. + * @param attrs The attribute set from XML that may include custom parameters. + */ + public VibrationStartDelayPreference(Context context, AttributeSet attrs) { + super(context, attrs); + setPersistent(true); + } + + /** + * Returns the currently persisted vibration start delay in seconds. + * + * @return The vibration start delay in seconds, or none if no value has been previously persisted. + */ + public int getVibrationStartDelay() { + return getPersistedInt(DEFAULT_VIBRATION_START_DELAY); + } + + /** + * Persists the vibration start delay in seconds. + * + * @param seconds The vibration start delay to be stored, in seconds. + */ + public void setVibrationStartDelay(int seconds) { + persistInt(seconds); + } + + @Override + public CharSequence getSummary() { + int seconds = getVibrationStartDelay(); + + if (seconds == DEFAULT_VIBRATION_START_DELAY) { + return getContext().getString(R.string.vibration_start_delay_none); + } else { + int minutes = seconds / 60; + return getContext().getResources().getQuantityString(R.plurals.minutes, minutes, minutes); + } + } + +} diff --git a/app/src/main/java/com/best/deskclock/settings/custompreference/VolumeCrescendoDurationPreference.java b/app/src/main/java/com/best/deskclock/settings/custompreference/VolumeCrescendoDurationPreference.java new file mode 100644 index 000000000..3a3d524d6 --- /dev/null +++ b/app/src/main/java/com/best/deskclock/settings/custompreference/VolumeCrescendoDurationPreference.java @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: GPL-3.0-only + +package com.best.deskclock.settings.custompreference; + +import static com.best.deskclock.settings.PreferencesDefaultValues.DEFAULT_VOLUME_CRESCENDO_DURATION; + +import android.content.Context; +import android.util.AttributeSet; + +import androidx.preference.DialogPreference; + +import com.best.deskclock.R; + +public class VolumeCrescendoDurationPreference extends DialogPreference { + + /** + * Constructs a new VolumeCrescendoDurationPreference instance, used to manage user preferences + * related to volume crescendo duration. + * + * @param context The application context in which this preference is used. + * @param attrs The attribute set from XML that may include custom parameters. + */ + public VolumeCrescendoDurationPreference(Context context, AttributeSet attrs) { + super(context, attrs); + setPersistent(true); + } + + @Override + public CharSequence getSummary() { + int seconds = getVolumeCrescendoDuration(); + + if (seconds == DEFAULT_VOLUME_CRESCENDO_DURATION) { + return getContext().getString(R.string.label_off); + } + + int m = seconds / 60; + int s = seconds % 60; + + if (m > 0 && s > 0) { + String minutesString = getContext().getResources().getQuantityString(R.plurals.minutes, m, m); + String secondsString = getContext().getResources().getQuantityString(R.plurals.seconds, s, s); + return String.format("%s %s", minutesString, secondsString); + } else if (m > 0) { + return getContext().getResources().getQuantityString(R.plurals.minutes, m, m); + } else { + return getContext().getResources().getQuantityString(R.plurals.seconds, s, s); + } + } + + /** + * Returns the currently persisted volume crescendo duration in seconds. + * + * @return The crescendo duration in seconds, or none if no value has been previously persisted. + */ + public int getVolumeCrescendoDuration() { + return getPersistedInt(DEFAULT_VOLUME_CRESCENDO_DURATION); + } + + /** + * Persists the volume crescendo duration in seconds. + * + * @param seconds The volume crescendo duration to be stored, in seconds. + */ + public void setVolumeCrescendoDuration(int seconds) { + persistInt(seconds); + } + +} diff --git a/app/src/main/java/com/best/deskclock/uicomponents/CollapsingToolbarBaseActivity.java b/app/src/main/java/com/best/deskclock/uicomponents/CollapsingToolbarBaseActivity.java new file mode 100644 index 000000000..20ab7b3bb --- /dev/null +++ b/app/src/main/java/com/best/deskclock/uicomponents/CollapsingToolbarBaseActivity.java @@ -0,0 +1,235 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * modified + * SPDX-License-Identifier: Apache-2.0 AND GPL-3.0-only + */ + +package com.best.deskclock.uicomponents; + +import static com.best.deskclock.DeskClockApplication.getDefaultSharedPreferences; +import static com.best.deskclock.settings.PreferencesDefaultValues.AMOLED_DARK_MODE; + +import android.content.SharedPreferences; +import android.graphics.Typeface; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import androidx.activity.OnBackPressedCallback; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.widget.Toolbar; +import androidx.coordinatorlayout.widget.CoordinatorLayout; +import androidx.core.graphics.Insets; +import androidx.core.view.WindowCompat; +import androidx.core.view.WindowInsetsCompat; + +import com.best.deskclock.BaseActivity; +import com.best.deskclock.R; +import com.best.deskclock.data.SettingsDAO; + +import com.best.deskclock.settings.SettingsActivity; +import com.best.deskclock.utils.InsetsUtils; +import com.best.deskclock.utils.SdkUtils; +import com.best.deskclock.utils.ThemeUtils; + +import com.google.android.material.appbar.AppBarLayout; +import com.google.android.material.appbar.CollapsingToolbarLayout; + +/** + * A base Activity that has a collapsing toolbar layout is used for the activities intending to + * enable the collapsing toolbar function. + */ +public abstract class CollapsingToolbarBaseActivity extends BaseActivity { + + @Nullable + private CollapsingToolbarLayout mCollapsingToolbarLayout; + + @Nullable + protected AppBarLayout mAppBarLayout; + + protected CoordinatorLayout mCoordinatorLayout; + + /** + * This method should be implemented by subclasses of CollapsingToolbarBaseActivity + * to provide the title for the activity's collapsing toolbar. + *
+ * The title returned by this method will be displayed in the collapsing toolbar layout + * and will be correctly translated when changing the language in settings. + * + * @return The title of the activity to be displayed in the collapsing toolbar. + */ + protected abstract String getActivityTitle(); + + @Override + protected void onCreate(@Nullable Bundle savedInstanceState) { + final SharedPreferences prefs = getDefaultSharedPreferences(this); + boolean isFadeTransitionEnabled = SettingsDAO.isFadeTransitionsEnabled(prefs); + + if (isFadeTransitionEnabled) { + if (SdkUtils.isAtLeastAndroid14()) { + overrideActivityTransition(OVERRIDE_TRANSITION_OPEN, R.anim.fade_in, R.anim.fade_out); + } else { + overridePendingTransition(R.anim.fade_in, R.anim.fade_out); + } + } else { + if (SdkUtils.isAtLeastAndroid14()) { + overrideActivityTransition(OVERRIDE_TRANSITION_OPEN, + R.anim.activity_slide_from_right, R.anim.activity_slide_to_left); + } else { + overridePendingTransition(R.anim.activity_slide_from_right, R.anim.activity_slide_to_left); + } + } + + super.onCreate(savedInstanceState); + + // To manually manage insets + WindowCompat.setDecorFitsSystemWindows(getWindow(), false); + + ThemeUtils.allowDisplayCutout(getWindow()); + + super.setContentView(R.layout.collapsing_toolbar_base_layout); + + mCoordinatorLayout = findViewById(R.id.coordinator_layout); + + final String getDarkMode = SettingsDAO.getDarkMode(prefs); + mCollapsingToolbarLayout = findViewById(R.id.collapsing_toolbar); + if (mCollapsingToolbarLayout == null) { + return; + } + + final Typeface typeface = ThemeUtils.loadFont(SettingsDAO.getGeneralFont(prefs)); + mCollapsingToolbarLayout.setExpandedTitleTypeface(typeface); + mCollapsingToolbarLayout.setCollapsedTitleTypeface(typeface); + + if (ThemeUtils.isNight(getResources()) && getDarkMode.equals(AMOLED_DARK_MODE)) { + mCollapsingToolbarLayout.setBackgroundColor(getColor(android.R.color.black)); + mCollapsingToolbarLayout.setContentScrimColor(getColor(android.R.color.black)); + } + + mAppBarLayout = findViewById(R.id.app_bar); + disableCollapsingToolbarLayoutScrollingBehavior(); + + final Toolbar toolbar = findViewById(R.id.action_bar); + setSupportActionBar(toolbar); + + applyWindowInsets(); + + // Exclude SettingsActivity as this is handled in SettingsFragment. + if (!(this instanceof SettingsActivity)) { + getOnBackPressedDispatcher().addCallback(this, new OnBackPressedCallback(true) { + @Override + public void handleOnBackPressed() { + finish(); + if (isFadeTransitionEnabled) { + if (SdkUtils.isAtLeastAndroid14()) { + overrideActivityTransition(OVERRIDE_TRANSITION_CLOSE, + R.anim.fade_in, R.anim.fade_out); + } else { + overridePendingTransition(R.anim.fade_in, R.anim.fade_out); + } + } else { + if (SdkUtils.isAtLeastAndroid14()) { + overrideActivityTransition(OVERRIDE_TRANSITION_CLOSE, + R.anim.activity_slide_from_left, R.anim.activity_slide_to_right); + } else { + overridePendingTransition( + R.anim.activity_slide_from_left, R.anim.activity_slide_to_right); + } + } + } + }); + } + } + + @Override + protected void onResume() { + super.onResume(); + if (mCollapsingToolbarLayout != null) { + mCollapsingToolbarLayout.setTitle(getActivityTitle()); + } + } + + @Override + public void setContentView(int layoutResID) { + final ViewGroup parent = findViewById(R.id.content_frame); + if (parent != null) { + parent.removeAllViews(); + } + LayoutInflater.from(this).inflate(layoutResID, parent); + } + + @Override + public void setContentView(View view) { + final ViewGroup parent = findViewById(R.id.content_frame); + if (parent != null) { + parent.addView(view); + } + } + + @Override + public void setContentView(View view, ViewGroup.LayoutParams params) { + final ViewGroup parent = findViewById(R.id.content_frame); + if (parent != null) { + parent.addView(view, params); + } + } + + @Override + public void setTitle(CharSequence title) { + if (mCollapsingToolbarLayout != null) { + mCollapsingToolbarLayout.setTitle(title); + } else { + super.setTitle(title); + } + } + + @Override + public void setTitle(int titleId) { + if (mCollapsingToolbarLayout != null) { + mCollapsingToolbarLayout.setTitle(getText(titleId)); + } else { + super.setTitle(titleId); + } + } + + @Override + public boolean onNavigateUp() { + if (!super.onNavigateUp()) { + finishAfterTransition(); + } + return true; + } + + private void disableCollapsingToolbarLayoutScrollingBehavior() { + if (mAppBarLayout == null) { + return; + } + final CoordinatorLayout.LayoutParams params = (CoordinatorLayout.LayoutParams) mAppBarLayout.getLayoutParams(); + final AppBarLayout.Behavior behavior = new AppBarLayout.Behavior(); + behavior.setDragCallback(new AppBarLayout.Behavior.DragCallback() { + @Override + public boolean canDrag(@NonNull AppBarLayout appBarLayout) { + return false; + } + }); + params.setBehavior(behavior); + } + + + /** + * This method adjusts the spacing of the Toolbar and content to take into account system insets, + * so that they are not obscured by system elements (status bar, navigation bar or cutout). + */ + private void applyWindowInsets() { + InsetsUtils.doOnApplyWindowInsets(mAppBarLayout, (v, insets) -> { + // Get the system bar and notch insets + Insets bars = insets.getInsets(WindowInsetsCompat.Type.systemBars() | + WindowInsetsCompat.Type.displayCutout()); + + v.setPadding(bars.left, bars.top, bars.right, 0); + }); + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/best/deskclock/uicomponents/CustomDialog.java b/app/src/main/java/com/best/deskclock/uicomponents/CustomDialog.java new file mode 100644 index 000000000..526edfa41 --- /dev/null +++ b/app/src/main/java/com/best/deskclock/uicomponents/CustomDialog.java @@ -0,0 +1,212 @@ +// SPDX-License-Identifier: GPL-3.0-only + +package com.best.deskclock.uicomponents; + +import static androidx.core.util.TypedValueCompat.dpToPx; +import static com.best.deskclock.DeskClockApplication.getDefaultSharedPreferences; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.content.DialogInterface; +import android.content.SharedPreferences; +import android.graphics.Typeface; +import android.graphics.drawable.Drawable; +import android.view.LayoutInflater; +import android.view.View; +import android.view.Window; +import android.view.WindowManager; +import android.widget.Button; +import android.widget.TextView; + +import androidx.annotation.DrawableRes; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.StringRes; +import androidx.annotation.StyleRes; +import androidx.appcompat.app.AlertDialog; +import androidx.appcompat.content.res.AppCompatResources; +import androidx.core.widget.NestedScrollView; + +import com.best.deskclock.R; +import com.best.deskclock.data.SettingsDAO; +import com.best.deskclock.utils.ThemeUtils; +import com.google.android.material.dialog.MaterialAlertDialogBuilder; + +public class CustomDialog { + + public static AlertDialog create( + @NonNull Context context, + @Nullable @StyleRes Integer styleRes, + @Nullable Drawable icon, + @Nullable CharSequence title, + @Nullable CharSequence message, + @Nullable View customView, + @Nullable CharSequence positiveText, + @Nullable DialogInterface.OnClickListener positiveListener, + @Nullable CharSequence negativeText, + @Nullable DialogInterface.OnClickListener negativeListener, + @Nullable CharSequence neutralText, + @Nullable DialogInterface.OnClickListener neutralListener, + @Nullable OnDialogReady onDialogReady, + @NonNull SoftInputMode softInputMode + ) { + + SharedPreferences prefs = getDefaultSharedPreferences(context); + Typeface typeface = ThemeUtils.loadFont(SettingsDAO.getGeneralFont(prefs)); + + LayoutInflater inflater = LayoutInflater.from(context); + + // Title + @SuppressLint("InflateParams") + View titleView = inflater.inflate(R.layout.dialog_title_custom, null); + TextView titleText = titleView.findViewById(R.id.dialog_title); + + if (icon != null) { + titleText.setCompoundDrawablesWithIntrinsicBounds(icon, null, null, null); + titleText.setCompoundDrawablePadding((int) dpToPx(18, context.getResources().getDisplayMetrics())); + } + + if (title != null) { + titleText.setText(title); + titleText.setTypeface(typeface); + } + + // Dialog view + View dialogContent; + + if (message != null) { + // Message + @SuppressLint("InflateParams") + View messageView = inflater.inflate(R.layout.dialog_message_custom, null); + TextView messageText = messageView.findViewById(R.id.dialog_message); + messageText.setText(message); + messageText.setTypeface(typeface); + + dialogContent = messageView; + } else { + dialogContent = customView; + } + + // Builder + MaterialAlertDialogBuilder builder = (styleRes != null) + ? new MaterialAlertDialogBuilder(context, styleRes) + : new MaterialAlertDialogBuilder(context); + + builder + .setCustomTitle(titleView) + .setView(dialogContent); + + if (positiveText != null) { + builder.setPositiveButton(positiveText, positiveListener); + } + if (negativeText != null) { + builder.setNegativeButton(negativeText, negativeListener); + } + if (neutralText != null) { + builder.setNeutralButton(neutralText, neutralListener); + } + + AlertDialog dialog = builder.create(); + + // Apply typeface to buttons + dialog.setOnShowListener(d -> { + if (dialogContent != null && dialogContent.findViewById(R.id.scrollView) != null) { + configureScrollView(dialogContent); + } + + Button positive = dialog.getButton(AlertDialog.BUTTON_POSITIVE); + Button negative = dialog.getButton(AlertDialog.BUTTON_NEGATIVE); + Button neutral = dialog.getButton(AlertDialog.BUTTON_NEUTRAL); + + if (positive != null) { + positive.setTypeface(typeface); + } + if (negative != null) { + negative.setTypeface(typeface); + } + if (neutral != null) { + neutral.setTypeface(typeface); + } + + if (onDialogReady != null) { + onDialogReady.onReady(dialog); + } + }); + + // Soft input mode + if (softInputMode == SoftInputMode.SHOW_KEYBOARD) { + Window window = dialog.getWindow(); + if (window != null) { + window.setSoftInputMode( + WindowManager.LayoutParams.SOFT_INPUT_ADJUST_PAN | + WindowManager.LayoutParams.SOFT_INPUT_STATE_VISIBLE + ); + } + } + + return dialog; + } + + public static AlertDialog createSimpleDialog( + Context context, + @DrawableRes int iconRes, + @StringRes int titleRes, + CharSequence message, + @StringRes int positiveTextRes, + DialogInterface.OnClickListener positiveListener + ) { + return create( + context, + null, + AppCompatResources.getDrawable(context, iconRes), + context.getString(titleRes), + message, + null, + context.getString(positiveTextRes), + positiveListener, + context.getString(android.R.string.cancel), + null, + null, + null, + null, + SoftInputMode.NONE + ); + } + + private static void configureScrollView(View dialogView) { + NestedScrollView scrollView = dialogView.findViewById(R.id.scrollView); + + boolean scrollable = scrollView.canScrollVertically(1) + || scrollView.canScrollVertically(-1); + + if (scrollable) { + scrollView.setScrollIndicators(View.SCROLL_INDICATOR_BOTTOM); + } + + scrollView.setOnScrollChangeListener((NestedScrollView.OnScrollChangeListener) ( + v, scrollX, scrollY, oldScrollX, oldScrollY) -> { + + scrollView.setScrollIndicators(View.SCROLL_INDICATOR_TOP | View.SCROLL_INDICATOR_BOTTOM); + + boolean atTop = !scrollView.canScrollVertically(-1); + boolean atBottom = !scrollView.canScrollVertically(1); + + if (atTop) { + scrollView.setScrollIndicators(View.SCROLL_INDICATOR_BOTTOM); + } + if (atBottom) { + scrollView.setScrollIndicators(View.SCROLL_INDICATOR_TOP); + } + }); + } + + public interface OnDialogReady { + void onReady(AlertDialog dialog); + } + + public enum SoftInputMode { + NONE, + SHOW_KEYBOARD + } + +} diff --git a/app/src/main/java/com/best/deskclock/uicomponents/CustomTooltip.java b/app/src/main/java/com/best/deskclock/uicomponents/CustomTooltip.java new file mode 100644 index 000000000..832593fd6 --- /dev/null +++ b/app/src/main/java/com/best/deskclock/uicomponents/CustomTooltip.java @@ -0,0 +1,120 @@ +package com.best.deskclock.uicomponents; + +import static com.best.deskclock.DeskClockApplication.getDefaultSharedPreferences; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.content.SharedPreferences; +import android.graphics.Typeface; +import android.view.Gravity; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.PopupWindow; +import android.widget.TextView; + +import com.best.deskclock.R; +import com.best.deskclock.data.SettingsDAO; +import com.best.deskclock.utils.ThemeUtils; + +/** + * Utility class for displaying custom tooltips anchored to views. + * + *
This class replaces the default system tooltips with fully customizable + * popup tooltips that can appear above or below a target view. The tooltip + * layout, colors, and typeface can be styled freely, allowing consistent + * visual integration with the application's theme.
+ * + *Tooltips are shown for a short duration and automatically dismissed.
+ */ +public class CustomTooltip { + + private static final int TOOLTIP_DURATION = 2000; + + /** + * Displays a custom tooltip above the given anchor view. + * + *This is a convenience method that delegates to the internal + * {@link #show(View, String, Position)} method using the ABOVE position.
+ * + * @param anchor the view above which the tooltip should appear + * @param text the text to display inside the tooltip + */ + public static void showAbove(View anchor, String text) { + show(anchor, text, Position.ABOVE); + } + + /** + * Displays a custom tooltip below the given anchor view. + * + *This is a convenience method that delegates to the internal + * {@link #show(View, String, Position)} method using the BELOW position.
+ * + * @param anchor the view under which the tooltip should appear + * @param text the text to display inside the tooltip + */ + public static void showBelow(View anchor, String text) { + show(anchor, text, Position.BELOW); + } + + /** + * Internal method that creates and displays a custom tooltip anchored to a view. + * + *The tooltip is horizontally centered relative to the anchor and positioned + * either above or below it depending on the specified {@link Position}.
+ * + * @param anchor the view used as the reference point for positioning + * @param text the text to display inside the tooltip + * @param position whether the tooltip should appear above or below the anchor + */ + private static void show(View anchor, String text, Position position) { + Context context = anchor.getContext(); + SharedPreferences prefs = getDefaultSharedPreferences(context); + Typeface typeface = ThemeUtils.loadFont(SettingsDAO.getGeneralFont(prefs)); + + // Inflate layout + @SuppressLint("InflateParams") + View tooltipView = LayoutInflater.from(context).inflate(R.layout.custom_tooltip, null); + TextView tooltipText = tooltipView.findViewById(R.id.tooltip_text); + tooltipText.setText(text); + tooltipText.setTypeface(typeface); + + // Create a popup window + PopupWindow popup = new PopupWindow( + tooltipView, + ViewGroup.LayoutParams.WRAP_CONTENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + false + ); + + popup.setBackgroundDrawable(ThemeUtils.pillBackground( + context, com.google.android.material.R.attr.colorSecondary) + ); + popup.setOutsideTouchable(true); + + // Position + int[] location = new int[2]; + anchor.getLocationOnScreen(location); + + tooltipView.measure(View.MeasureSpec.UNSPECIFIED, View.MeasureSpec.UNSPECIFIED); + int tooltipWidth = tooltipView.getMeasuredWidth(); + int tooltipHeight = tooltipView.getMeasuredHeight(); + int anchorWidth = anchor.getWidth(); + int anchorHeight = anchor.getHeight(); + + // Horizontal centering + int x = location[0] + (anchorWidth / 2) - (tooltipWidth / 2); + + // Vertical position + int y = position == Position.BELOW + ? location[1] + anchorHeight + : location[1] - tooltipHeight; + + popup.showAtLocation(anchor, Gravity.NO_GRAVITY, x, y); + + tooltipView.postDelayed(popup::dismiss, TOOLTIP_DURATION); + } + + private enum Position {ABOVE, BELOW} + +} diff --git a/app/src/main/java/com/best/deskclock/uicomponents/TextTime.java b/app/src/main/java/com/best/deskclock/uicomponents/TextTime.java new file mode 100644 index 000000000..ae0ec7fe5 --- /dev/null +++ b/app/src/main/java/com/best/deskclock/uicomponents/TextTime.java @@ -0,0 +1,197 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * modified + * SPDX-License-Identifier: Apache-2.0 AND GPL-3.0-only + */ + +package com.best.deskclock.uicomponents; + +import static com.best.deskclock.DeskClockApplication.getDefaultSharedPreferences; +import static java.util.Calendar.HOUR_OF_DAY; +import static java.util.Calendar.MINUTE; + +import android.content.ContentResolver; +import android.content.Context; +import android.content.SharedPreferences; +import android.database.ContentObserver; +import android.graphics.Typeface; +import android.net.Uri; +import android.os.Handler; +import android.os.Looper; +import android.provider.Settings; +import android.text.format.DateFormat; +import android.util.AttributeSet; + +import androidx.annotation.VisibleForTesting; +import androidx.appcompat.widget.AppCompatTextView; + +import com.best.deskclock.data.DataModel; +import com.best.deskclock.data.SettingsDAO; +import com.best.deskclock.utils.ClockUtils; +import com.best.deskclock.utils.ThemeUtils; + +import java.util.Calendar; +import java.util.TimeZone; + +/** + * Based on {@link android.widget.TextClock}, This widget displays a constant time of day using + * format specifiers. {@link android.widget.TextClock} doesn't support a non-ticking clock. + */ +public class TextTime extends AppCompatTextView { + + @VisibleForTesting() + static final CharSequence DEFAULT_FORMAT_12_HOUR = "h:mm a"; + @VisibleForTesting() + static final CharSequence DEFAULT_FORMAT_24_HOUR = "H:mm"; + /** + * UTC does not have DST rules and will not alter the {@link #mHour} and {@link #mMinute}. + */ + private static final TimeZone UTC = TimeZone.getTimeZone("UTC"); + private CharSequence mFormat12; + private CharSequence mFormat24; + private CharSequence mFormat; + private Context mContext; + private SharedPreferences mPrefs; + + private boolean mAttached; + + private int mHour; + private int mMinute; + + private final ContentObserver mFormatChangeObserver = new ContentObserver(new Handler(Looper.getMainLooper())) { + + @Override + public void onChange(boolean selfChange) { + chooseFormat(); + updateTime(); + } + + @Override + public void onChange(boolean selfChange, Uri uri) { + chooseFormat(); + updateTime(); + } + }; + + public TextTime(Context context) { + this(context, null); + } + + public TextTime(Context context, AttributeSet attrs) { + this(context, attrs, 0); + } + + public TextTime(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + + if (isInEditMode()) { + return; + } + + mContext = context; + mPrefs = getDefaultSharedPreferences(context); + setTimeFormat(0.45f, false); + chooseFormat(); + } + + public void setFormat12Hour(CharSequence format) { + mFormat12 = format; + + chooseFormat(); + updateTime(); + } + + public void setFormat24Hour(CharSequence format) { + mFormat24 = format; + + chooseFormat(); + updateTime(); + } + + private void chooseFormat() { + final boolean format24Requested = DataModel.getDataModel().is24HourFormat(); + if (format24Requested) { + mFormat = mFormat24 == null ? DEFAULT_FORMAT_24_HOUR : mFormat24; + } else { + mFormat = mFormat12 == null ? DEFAULT_FORMAT_12_HOUR : mFormat12; + } + } + + @Override + protected void onAttachedToWindow() { + super.onAttachedToWindow(); + + if (isInEditMode()) { + return; + } + + if (!mAttached) { + mAttached = true; + registerObserver(); + updateTime(); + } + } + + @Override + protected void onDetachedFromWindow() { + super.onDetachedFromWindow(); + if (mAttached) { + unregisterObserver(); + mAttached = false; + } + } + + private void registerObserver() { + final ContentResolver resolver = mContext.getContentResolver(); + resolver.registerContentObserver(Settings.System.CONTENT_URI, true, mFormatChangeObserver); + } + + private void unregisterObserver() { + final ContentResolver resolver = mContext.getContentResolver(); + resolver.unregisterContentObserver(mFormatChangeObserver); + } + + public void setTime(int hour, int minute) { + if (isInEditMode()) { + return; + } + + mHour = hour; + mMinute = minute; + updateTime(); + } + + public void setTypeface(boolean isAlarmEnabled) { + String fontPath = SettingsDAO.getAlarmFont(mPrefs); + Typeface typeface = isAlarmEnabled + ? ThemeUtils.boldTypeface(fontPath) + : ThemeUtils.loadFont(fontPath); + + setTypeface(typeface); + } + + public void setTimeFormat(float amPmRatio, boolean includeSeconds) { + CharSequence format12 = ClockUtils.get12ModeFormat(mContext, amPmRatio, includeSeconds, + true, false, false); + setFormat12Hour(format12); + + CharSequence format24 = ClockUtils.get24ModeFormat(includeSeconds, false); + setFormat24Hour(format24); + } + + private void updateTime() { + if (isInEditMode()) { + return; + } + + // Format the time relative to UTC to ensure hour and minute are not adjusted for DST. + final Calendar calendar = DataModel.getDataModel().getCalendar(); + calendar.setTimeZone(UTC); + calendar.set(HOUR_OF_DAY, mHour); + calendar.set(MINUTE, mMinute); + final CharSequence text = DateFormat.format(mFormat, calendar); + setText(text); + // Strip away the spans from text so talkback is not confused + setContentDescription(text.toString()); + } +} diff --git a/app/src/main/java/com/best/deskclock/uicomponents/toast/CustomToast.java b/app/src/main/java/com/best/deskclock/uicomponents/toast/CustomToast.java new file mode 100644 index 000000000..45c20a873 --- /dev/null +++ b/app/src/main/java/com/best/deskclock/uicomponents/toast/CustomToast.java @@ -0,0 +1,126 @@ +// SPDX-License-Identifier: GPL-3.0-only + +package com.best.deskclock.uicomponents.toast; + +import static com.best.deskclock.DeskClockApplication.getDefaultSharedPreferences; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.content.SharedPreferences; +import android.graphics.Typeface; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.annotation.StringRes; + +import com.best.deskclock.R; +import com.best.deskclock.data.SettingsDAO; +import com.best.deskclock.utils.ThemeUtils; + +/** + * Utility class for displaying custom styled toasts using the application's + * selected accent color and font preferences. + * + *This class automatically applies the correct themed context so that the toast + * respects the user's accent color, night mode settings, and custom font.
+ */ +public class CustomToast { + + /** + * Inflates and configures the custom toast layout. + * + *This method applies the themed context, sets the message text, + * applies the appropriate background and font, and returns the fully + * prepared view ready to be used inside a Toast.
+ * + * @param context the base context used to resolve theme and resources + * @param message the text to display inside the toast + * @return a fully configured toast layout view + */ + private static View createLayout(Context context, String message) { + SharedPreferences prefs = getDefaultSharedPreferences(context); + Context themedContext = ThemeUtils.getThemedContext(context, prefs); + + @SuppressLint("InflateParams") + View layout = LayoutInflater.from(themedContext).inflate(R.layout.custom_toast, null); + + TextView text = layout.findViewById(R.id.toast_text); + text.setBackground(ThemeUtils.pillBackground(themedContext, com.google.android.material.R.attr.colorSecondary)); + text.setText(message); + + Typeface typeface = ThemeUtils.loadFont(SettingsDAO.getGeneralFont(prefs)); + if (typeface != null) { + text.setTypeface(typeface); + } + + return layout; + } + + /** + * Creates a Toast instance using the given layout and duration. + * + *The toast is built using the themed context to ensure proper + * styling across light and dark modes. The returned Toast is not + * shown automatically and must be displayed by the caller.
+ * + *Note: even if {@link Toast#setView(View)} is obsolete, its use is not problematic; + * indeed, apps targeting API level 30 or higher that are in the background will + * not have custom toast views displayed.
+ * + * @param context the base context used to apply theming + * @param layout the custom layout to display inside the toast + * @param duration the toast duration (e.g., Toast.LENGTH_SHORT or Toast.LENGTH_LONG) + * @return a configured Toast instance ready to be shown + */ + private static Toast buildToast(Context context, View layout, int duration) { + SharedPreferences prefs = getDefaultSharedPreferences(context); + Context themedContext = ThemeUtils.getThemedContext(context, prefs); + + Toast toast = new Toast(themedContext); + toast.setDuration(duration); + toast.setView(layout); + + return toast; + } + + /** + * Displays a short custom toast using a string resource. + */ + public static void show(Context context, @StringRes int messageRes) { + show(context, context.getString(messageRes)); + } + + /** + * Displays a short custom toast with the given text. + */ + public static void show(Context context, String message) { + View layout = createLayout(context, message); + buildToast(context, layout, Toast.LENGTH_SHORT).show(); + } + + /** + * Displays a long-duration custom toast with the given text. + */ + public static void showLong(Context context, String message) { + View layout = createLayout(context, message); + buildToast(context, layout, Toast.LENGTH_LONG).show(); + } + + /** + * Displays a long-duration custom toast while ensuring that any previously + * shown toast is cancelled before displaying the new one. + * + *This is useful in situations where multiple toasts may be triggered in + * quick succession, such as alarm snooze actions.
+ */ + public static void showLongWithManager(Context context, String message) { + View layout = createLayout(context, message); + Toast toast = buildToast(context, layout, Toast.LENGTH_LONG); + + ToastManager.setToast(toast); + toast.show(); + } + +} diff --git a/app/src/main/java/com/best/deskclock/uicomponents/toast/SnackbarManager.java b/app/src/main/java/com/best/deskclock/uicomponents/toast/SnackbarManager.java new file mode 100644 index 000000000..e1b7a1805 --- /dev/null +++ b/app/src/main/java/com/best/deskclock/uicomponents/toast/SnackbarManager.java @@ -0,0 +1,50 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * modified + * SPDX-License-Identifier: Apache-2.0 AND GPL-3.0-only + */ + +package com.best.deskclock.uicomponents.toast; + +import static com.best.deskclock.DeskClockApplication.getDefaultSharedPreferences; + +import android.content.SharedPreferences; +import android.graphics.Typeface; + +import com.best.deskclock.R; +import com.best.deskclock.data.SettingsDAO; +import com.best.deskclock.utils.ThemeUtils; + +import com.google.android.material.snackbar.Snackbar; + +import java.lang.ref.WeakReference; + +/** + * Manages visibility of Snackbar and allow preemptive dismiss of current displayed Snackbar. + */ +public final class SnackbarManager { + + private static WeakReference