diff --git a/android/app/schemas/com.oppzippy.openscq30.room.AppDatabase/2.json b/android/app/schemas/com.oppzippy.openscq30.room.AppDatabase/2.json new file mode 100644 index 00000000..3a2f3365 --- /dev/null +++ b/android/app/schemas/com.oppzippy.openscq30.room.AppDatabase/2.json @@ -0,0 +1,88 @@ +{ + "formatVersion": 1, + "database": { + "version": 2, + "identityHash": "ee376b5d213217c100cea5afb6fb0b09", + "entities": [ + { + "tableName": "equalizer_custom_profile", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `values` BLOB NOT NULL, PRIMARY KEY(`name`))", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "values", + "columnName": "values", + "affinity": "BLOB", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "name" + ] + }, + "indices": [ + { + "name": "index_equalizer_custom_profile_values", + "unique": true, + "columnNames": [ + "values" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_equalizer_custom_profile_values` ON `${TABLE_NAME}` (`values`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "quick_preset", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `ambientSoundMode` TEXT, `noiseCancelingMode` TEXT, `equalizerProfileName` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ambientSoundMode", + "columnName": "ambientSoundMode", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "noiseCancelingMode", + "columnName": "noiseCancelingMode", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "equalizerProfileName", + "columnName": "equalizerProfileName", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'ee376b5d213217c100cea5afb6fb0b09')" + ] + } +} \ No newline at end of file diff --git a/android/app/src/androidTest/java/com/oppzippy/openscq30/DeviceSettingsSoundModeTest.kt b/android/app/src/androidTest/java/com/oppzippy/openscq30/DeviceSettingsSoundModeTest.kt index b77f6997..4be68272 100644 --- a/android/app/src/androidTest/java/com/oppzippy/openscq30/DeviceSettingsSoundModeTest.kt +++ b/android/app/src/androidTest/java/com/oppzippy/openscq30/DeviceSettingsSoundModeTest.kt @@ -8,7 +8,7 @@ import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.performClick import com.oppzippy.openscq30.lib.AmbientSoundMode import com.oppzippy.openscq30.lib.NoiseCancelingMode -import com.oppzippy.openscq30.ui.devicesettings.composables.SoundModeSettings +import com.oppzippy.openscq30.ui.soundmode.SoundModeSettings import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidTest import io.mockk.junit4.MockKRule diff --git a/android/app/src/main/java/com/oppzippy/openscq30/features/quickpresets/storage/QuickPreset.kt b/android/app/src/main/java/com/oppzippy/openscq30/features/quickpresets/storage/QuickPreset.kt new file mode 100644 index 00000000..7003b4a8 --- /dev/null +++ b/android/app/src/main/java/com/oppzippy/openscq30/features/quickpresets/storage/QuickPreset.kt @@ -0,0 +1,16 @@ +package com.oppzippy.openscq30.features.quickpresets.storage + +import androidx.room.Entity +import androidx.room.PrimaryKey +import com.oppzippy.openscq30.lib.AmbientSoundMode +import com.oppzippy.openscq30.lib.NoiseCancelingMode + +@Entity( + tableName = "quick_preset", +) +data class QuickPreset( + @PrimaryKey val id: Int, + val ambientSoundMode: AmbientSoundMode? = null, + val noiseCancelingMode: NoiseCancelingMode? = null, + val equalizerProfileName: String? = null, +) diff --git a/android/app/src/main/java/com/oppzippy/openscq30/features/quickpresets/storage/QuickPresetDao.kt b/android/app/src/main/java/com/oppzippy/openscq30/features/quickpresets/storage/QuickPresetDao.kt new file mode 100644 index 00000000..db554b50 --- /dev/null +++ b/android/app/src/main/java/com/oppzippy/openscq30/features/quickpresets/storage/QuickPresetDao.kt @@ -0,0 +1,21 @@ +package com.oppzippy.openscq30.features.quickpresets.storage + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query + +@Dao +interface QuickPresetDao { + @Query("SELECT * FROM quick_preset") + suspend fun getAll(): List + + @Query("SELECT * FROM quick_preset WHERE id = :id") + suspend fun get(id: Int): QuickPreset? + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(customProfile: QuickPreset) + + @Query("DELETE FROM quick_preset WHERE id = :id") + suspend fun delete(id: Int) +} diff --git a/android/app/src/main/java/com/oppzippy/openscq30/features/quickpresets/storage/QuickPresetDaoModule.kt b/android/app/src/main/java/com/oppzippy/openscq30/features/quickpresets/storage/QuickPresetDaoModule.kt new file mode 100644 index 00000000..47f023c2 --- /dev/null +++ b/android/app/src/main/java/com/oppzippy/openscq30/features/quickpresets/storage/QuickPresetDaoModule.kt @@ -0,0 +1,18 @@ +package com.oppzippy.openscq30.features.quickpresets.storage + +import com.oppzippy.openscq30.room.AppDatabase +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object QuickPresetDaoModule { + @Provides + @Singleton + fun provideQuickPresetDao(database: AppDatabase): QuickPresetDao { + return database.quickPresetDao() + } +} diff --git a/android/app/src/main/java/com/oppzippy/openscq30/features/soundcoredevice/service/DeviceConnectionManager.kt b/android/app/src/main/java/com/oppzippy/openscq30/features/soundcoredevice/service/DeviceConnectionManager.kt index c737b15a..a61d0904 100644 --- a/android/app/src/main/java/com/oppzippy/openscq30/features/soundcoredevice/service/DeviceConnectionManager.kt +++ b/android/app/src/main/java/com/oppzippy/openscq30/features/soundcoredevice/service/DeviceConnectionManager.kt @@ -105,6 +105,10 @@ class DeviceConnectionManager @Inject constructor( } } + fun setSoundMode(ambientSoundMode: AmbientSoundMode, noiseCancelingMode: NoiseCancelingMode) { + device?.setSoundMode(ambientSoundMode, noiseCancelingMode) + } + fun setAmbientSoundMode(ambientSoundMode: AmbientSoundMode) { device?.state?.noiseCancelingMode()?.let { noiseCancelingMode -> device?.setSoundMode(ambientSoundMode, noiseCancelingMode) diff --git a/android/app/src/main/java/com/oppzippy/openscq30/features/soundcoredevice/service/DeviceService.kt b/android/app/src/main/java/com/oppzippy/openscq30/features/soundcoredevice/service/DeviceService.kt index 0109bce9..baaf6578 100644 --- a/android/app/src/main/java/com/oppzippy/openscq30/features/soundcoredevice/service/DeviceService.kt +++ b/android/app/src/main/java/com/oppzippy/openscq30/features/soundcoredevice/service/DeviceService.kt @@ -8,6 +8,9 @@ import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Paint import android.graphics.drawable.Icon import android.os.Binder import android.os.IBinder @@ -15,13 +18,22 @@ import androidx.lifecycle.LifecycleService import androidx.lifecycle.lifecycleScope import com.oppzippy.openscq30.MainActivity import com.oppzippy.openscq30.R +import com.oppzippy.openscq30.features.quickpresets.storage.QuickPresetDao import com.oppzippy.openscq30.features.soundcoredevice.api.SoundcoreDeviceFactory +import com.oppzippy.openscq30.lib.AmbientSoundMode +import com.oppzippy.openscq30.lib.EqualizerConfiguration +import com.oppzippy.openscq30.lib.VolumeAdjustments +import com.oppzippy.openscq30.libextensions.resources.toStringResource +import com.oppzippy.openscq30.ui.equalizer.models.EqualizerLine +import com.oppzippy.openscq30.ui.equalizer.storage.CustomProfileDao import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.MainScope import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import javax.inject.Inject +import kotlin.time.Duration.Companion.milliseconds @AndroidEntryPoint class DeviceService : LifecycleService() { @@ -29,7 +41,9 @@ class DeviceService : LifecycleService() { private const val NOTIFICATION_CHANNEL_ID = "com.oppzippy.openscq30.notification.DeviceServiceChannel" private const val NOTIFICATION_ID = 1 - private const val DISCONNECT = "com.oppzippy.openscq30.broadcast.Disconnect" + private const val ACTION_QUICK_PRESET = "com.oppzippy.openscq30.broadcast.QuickPreset" + private const val ACTION_DISCONNECT = "com.oppzippy.openscq30.broadcast.Disconnect" + private const val INTENT_PRESET_NUMBER = "com.oppzippy.openscq30.presetNumber" /** Intent extra for setting mac address when launching service */ const val MAC_ADDRESS = "com.oppzippy.openscq30.macAddress" @@ -39,15 +53,48 @@ class DeviceService : LifecycleService() { lateinit var factory: SoundcoreDeviceFactory lateinit var connectionManager: DeviceConnectionManager + @Inject + lateinit var quickPresetDao: QuickPresetDao + + @Inject + lateinit var customProfileDao: CustomProfileDao + private val broadcastReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { when (intent?.action) { - DISCONNECT -> { + ACTION_DISCONNECT -> { MainScope().launch { // Disconnecting will trigger stopping this service connectionManager.disconnect() } } + + ACTION_QUICK_PRESET -> { + val presetNumber = intent.getIntExtra(INTENT_PRESET_NUMBER, 0) + lifecycleScope.launch { + quickPresetDao.get(presetNumber)?.let { quickPreset -> + val ambientSoundMode = quickPreset.ambientSoundMode + val noiseCancelingMode = quickPreset.noiseCancelingMode + val equalizerConfiguration = + quickPreset.equalizerProfileName?.let { + customProfileDao.get(it) + }?.let { + EqualizerConfiguration(VolumeAdjustments(it.values.toByteArray())) + } + + // Set them both in one go if possible to maybe save a packet + if (ambientSoundMode != null && noiseCancelingMode != null) { + connectionManager.setSoundMode(ambientSoundMode, noiseCancelingMode) + } else { + ambientSoundMode?.let { connectionManager.setAmbientSoundMode(it) } + noiseCancelingMode?.let { connectionManager.setNoiseCancelingMode(it) } + } + equalizerConfiguration?.let { + connectionManager.setEqualizerConfiguration(it) + } + } + } + } } } } @@ -61,7 +108,9 @@ class DeviceService : LifecycleService() { stopSelf() } - val filter = IntentFilter(DISCONNECT).apply {} + val filter = IntentFilter(ACTION_DISCONNECT).apply { + addAction(ACTION_QUICK_PRESET) + } registerReceiver(broadcastReceiver, filter) } @@ -87,9 +136,11 @@ class DeviceService : LifecycleService() { lifecycleScope.launch { connectionManager.connectionStatusFlow.collectLatest { - val notificationManager = - getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - notificationManager.notify(NOTIFICATION_ID, buildNotification()) + if (it is ConnectionStatus.Connected) { + it.device.stateFlow.debounce(500.milliseconds).collectLatest { + updateNotification() + } + } } } @@ -115,6 +166,12 @@ class DeviceService : LifecycleService() { manager.createNotificationChannel(channel) } + private fun updateNotification() { + val notificationManager = + getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + notificationManager.notify(NOTIFICATION_ID, buildNotification()) + } + private fun buildNotification(): Notification { val openAppIntent = Intent(this, MainActivity::class.java) openAppIntent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP) @@ -122,10 +179,18 @@ class DeviceService : LifecycleService() { val status = connectionManager.connectionStatusFlow.value val builder = Notification.Builder(this, NOTIFICATION_CHANNEL_ID).setOngoing(true) - .setSmallIcon(R.drawable.headphones).setContentTitle( + .setOnlyAlertOnce(true).setSmallIcon(R.drawable.headphones).setLargeIcon( + if (status is ConnectionStatus.Connected) { + buildEqualizerBitmap(status) + } else { + null + }, + ).setContentTitle( when (status) { is ConnectionStatus.AwaitingConnection -> getString(R.string.awaiting_connection) - is ConnectionStatus.Connected -> getString(R.string.connected_to).format(status.device.name) + is ConnectionStatus.Connected -> getString(R.string.connected_to).format( + status.device.name, + ) is ConnectionStatus.Connecting -> getString(R.string.connecting_to).format( status.macAddress, @@ -133,6 +198,21 @@ class DeviceService : LifecycleService() { ConnectionStatus.Disconnected -> getString(R.string.disconnected) }, + ).setContentText( + if (status is ConnectionStatus.Connected) { + val deviceState = status.device.state + if (deviceState.ambientSoundMode() == AmbientSoundMode.NoiseCanceling) { + getString( + R.string.ambient_sound_mode_and_noise_canceling_mode_values, + getString(deviceState.ambientSoundMode().toStringResource()), + getString(deviceState.noiseCancelingMode().toStringResource()), + ) + } else { + getString(deviceState.ambientSoundMode().toStringResource()) + } + } else { + null + }, ).setContentIntent( PendingIntent.getActivity( this, @@ -142,13 +222,41 @@ class DeviceService : LifecycleService() { ), ).addAction( Notification.Action.Builder( - Icon.createWithResource(this, R.drawable.headphones), + Icon.createWithResource(this, R.drawable.baseline_headset_off_24), getString(R.string.disconnect), PendingIntent.getBroadcast( this, 1, Intent().apply { - action = DISCONNECT + action = ACTION_DISCONNECT + }, + PendingIntent.FLAG_IMMUTABLE, + ), + ).build(), + ).addAction( + Notification.Action.Builder( + Icon.createWithResource(this, R.drawable.counter_1_48px), + getString(R.string.quick_preset_number, 1), + PendingIntent.getBroadcast( + this, + 2, + Intent().apply { + action = ACTION_QUICK_PRESET + putExtra(INTENT_PRESET_NUMBER, 0) + }, + PendingIntent.FLAG_IMMUTABLE, + ), + ).build(), + ).addAction( + Notification.Action.Builder( + Icon.createWithResource(this, R.drawable.counter_2_48px), + getString(R.string.quick_preset_number, 2), + PendingIntent.getBroadcast( + this, + 3, + Intent().apply { + action = ACTION_QUICK_PRESET + putExtra(INTENT_PRESET_NUMBER, 1) }, PendingIntent.FLAG_IMMUTABLE, ), @@ -157,6 +265,32 @@ class DeviceService : LifecycleService() { return builder.build() } + private fun buildEqualizerBitmap(status: ConnectionStatus.Connected): Bitmap { + val bitmap = Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888) + val equalizerConfiguration = status.device.state.equalizerConfiguration() + val canvas = Canvas(bitmap) + val line = EqualizerLine(equalizerConfiguration.volumeAdjustments().adjustments().toList()) + val points = line.draw(canvas.width.toFloat(), canvas.height.toFloat() / 2F, 4F) + val lineCoordinates = points.flatMapIndexed { index, pair -> + val scaledY = pair.second + canvas.height / 4 + if (index == 0 || index == points.size - 1) { + listOf(pair.first, scaledY) + } else { + listOf(pair.first, scaledY, pair.first, scaledY) + } + } + canvas.drawLines( + lineCoordinates.toFloatArray(), + Paint(Paint.ANTI_ALIAS_FLAG).apply { + strokeWidth = bitmap.height * 0.05F + color = 0xFF777777.toInt() + strokeCap = Paint.Cap.ROUND + strokeJoin = Paint.Join.ROUND + }, + ) + return bitmap + } + fun doesNotificationExist(): Boolean { val notificationManager = applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager @@ -173,7 +307,7 @@ class DeviceService : LifecycleService() { return binder } - suspend fun setMacAddress(macAddress: String?) { + private suspend fun setMacAddress(macAddress: String?) { if (macAddress != null) { connectionManager.connect(macAddress) } else { diff --git a/android/app/src/main/java/com/oppzippy/openscq30/libextensions/resources/AmbientSoundMode.kt b/android/app/src/main/java/com/oppzippy/openscq30/libextensions/resources/AmbientSoundMode.kt new file mode 100644 index 00000000..f12d5344 --- /dev/null +++ b/android/app/src/main/java/com/oppzippy/openscq30/libextensions/resources/AmbientSoundMode.kt @@ -0,0 +1,12 @@ +package com.oppzippy.openscq30.libextensions.resources + +import com.oppzippy.openscq30.R +import com.oppzippy.openscq30.lib.AmbientSoundMode + +fun AmbientSoundMode.toStringResource(): Int { + return when (this) { + AmbientSoundMode.NoiseCanceling -> R.string.noise_canceling + AmbientSoundMode.Transparency -> R.string.transparency + AmbientSoundMode.Normal -> R.string.normal + } +} diff --git a/android/app/src/main/java/com/oppzippy/openscq30/libextensions/resources/NoiseCancelingMode.kt b/android/app/src/main/java/com/oppzippy/openscq30/libextensions/resources/NoiseCancelingMode.kt new file mode 100644 index 00000000..91cf81ac --- /dev/null +++ b/android/app/src/main/java/com/oppzippy/openscq30/libextensions/resources/NoiseCancelingMode.kt @@ -0,0 +1,12 @@ +package com.oppzippy.openscq30.libextensions.resources + +import com.oppzippy.openscq30.R +import com.oppzippy.openscq30.lib.NoiseCancelingMode + +fun NoiseCancelingMode.toStringResource(): Int { + return when (this) { + NoiseCancelingMode.Transport -> R.string.transport + NoiseCancelingMode.Outdoor -> R.string.outdoor + NoiseCancelingMode.Indoor -> R.string.indoor + } +} diff --git a/android/app/src/main/java/com/oppzippy/openscq30/libextensions/resources/PresetEqualizerProfile.kt b/android/app/src/main/java/com/oppzippy/openscq30/libextensions/resources/PresetEqualizerProfile.kt new file mode 100644 index 00000000..6b99a9a4 --- /dev/null +++ b/android/app/src/main/java/com/oppzippy/openscq30/libextensions/resources/PresetEqualizerProfile.kt @@ -0,0 +1,31 @@ +package com.oppzippy.openscq30.libextensions.resources + +import com.oppzippy.openscq30.R +import com.oppzippy.openscq30.lib.PresetEqualizerProfile + +fun PresetEqualizerProfile.toStringResource(): Int { + return when (this) { + PresetEqualizerProfile.SoundcoreSignature -> R.string.soundcore_signature + PresetEqualizerProfile.Acoustic -> R.string.acoustic + PresetEqualizerProfile.BassBooster -> R.string.bass_booster + PresetEqualizerProfile.BassReducer -> R.string.bass_reducer + PresetEqualizerProfile.Classical -> R.string.classical + PresetEqualizerProfile.Podcast -> R.string.podcast + PresetEqualizerProfile.Dance -> R.string.dance + PresetEqualizerProfile.Deep -> R.string.deep + PresetEqualizerProfile.Electronic -> R.string.electronic + PresetEqualizerProfile.Flat -> R.string.flat + PresetEqualizerProfile.HipHop -> R.string.hip_hop + PresetEqualizerProfile.Jazz -> R.string.jazz + PresetEqualizerProfile.Latin -> R.string.latin + PresetEqualizerProfile.Lounge -> R.string.lounge + PresetEqualizerProfile.Piano -> R.string.piano + PresetEqualizerProfile.Pop -> R.string.pop + PresetEqualizerProfile.RnB -> R.string.rnb + PresetEqualizerProfile.Rock -> R.string.rock + PresetEqualizerProfile.SmallSpeakers -> R.string.small_speakers + PresetEqualizerProfile.SpokenWord -> R.string.spoken_word + PresetEqualizerProfile.TrebleBooster -> R.string.treble_booster + PresetEqualizerProfile.TrebleReducer -> R.string.treble_reducer + } +} diff --git a/android/app/src/main/java/com/oppzippy/openscq30/room/AppDatabase.kt b/android/app/src/main/java/com/oppzippy/openscq30/room/AppDatabase.kt index eacbc744..4b1b30e9 100644 --- a/android/app/src/main/java/com/oppzippy/openscq30/room/AppDatabase.kt +++ b/android/app/src/main/java/com/oppzippy/openscq30/room/AppDatabase.kt @@ -1,18 +1,26 @@ package com.oppzippy.openscq30.room +import androidx.room.AutoMigration import androidx.room.Database import androidx.room.RoomDatabase import androidx.room.TypeConverters +import com.oppzippy.openscq30.features.quickpresets.storage.QuickPreset +import com.oppzippy.openscq30.features.quickpresets.storage.QuickPresetDao import com.oppzippy.openscq30.ui.equalizer.storage.CustomProfile import com.oppzippy.openscq30.ui.equalizer.storage.CustomProfileDao @Database( - version = 1, + version = 2, entities = [ CustomProfile::class, + QuickPreset::class, + ], + autoMigrations = [ + AutoMigration(from = 1, to = 2), ], ) @TypeConverters(Converters::class) abstract class AppDatabase : RoomDatabase() { abstract fun equalizerCustomProfileDao(): CustomProfileDao + abstract fun quickPresetDao(): QuickPresetDao } diff --git a/android/app/src/main/java/com/oppzippy/openscq30/ui/deviceselection/composables/DeviceSelection.kt b/android/app/src/main/java/com/oppzippy/openscq30/ui/deviceselection/composables/DeviceSelection.kt index 7d56c31d..874c0f50 100644 --- a/android/app/src/main/java/com/oppzippy/openscq30/ui/deviceselection/composables/DeviceSelection.kt +++ b/android/app/src/main/java/com/oppzippy/openscq30/ui/deviceselection/composables/DeviceSelection.kt @@ -10,6 +10,7 @@ import androidx.navigation.compose.rememberNavController import com.oppzippy.openscq30.R import com.oppzippy.openscq30.features.bluetoothdeviceprovider.BluetoothDevice import com.oppzippy.openscq30.ui.deviceselection.models.Screen +import com.oppzippy.openscq30.ui.utils.PermissionCheck @Composable fun DeviceSelection( diff --git a/android/app/src/main/java/com/oppzippy/openscq30/ui/devicesettings/DeviceSettingsScreen.kt b/android/app/src/main/java/com/oppzippy/openscq30/ui/devicesettings/DeviceSettingsScreen.kt index de216486..125bb9bd 100644 --- a/android/app/src/main/java/com/oppzippy/openscq30/ui/devicesettings/DeviceSettingsScreen.kt +++ b/android/app/src/main/java/com/oppzippy/openscq30/ui/devicesettings/DeviceSettingsScreen.kt @@ -6,8 +6,8 @@ import com.oppzippy.openscq30.lib.EqualizerConfiguration import com.oppzippy.openscq30.lib.NoiseCancelingMode import com.oppzippy.openscq30.ui.devicesettings.composables.DeviceSettings import com.oppzippy.openscq30.ui.devicesettings.composables.Disconnected -import com.oppzippy.openscq30.ui.devicesettings.composables.Loading import com.oppzippy.openscq30.ui.devicesettings.models.UiDeviceState +import com.oppzippy.openscq30.ui.utils.Loading @Composable fun DeviceSettingsScreen( diff --git a/android/app/src/main/java/com/oppzippy/openscq30/ui/devicesettings/Screen.kt b/android/app/src/main/java/com/oppzippy/openscq30/ui/devicesettings/Screen.kt index f24ad220..8de63f79 100644 --- a/android/app/src/main/java/com/oppzippy/openscq30/ui/devicesettings/Screen.kt +++ b/android/app/src/main/java/com/oppzippy/openscq30/ui/devicesettings/Screen.kt @@ -10,4 +10,5 @@ import com.oppzippy.openscq30.R sealed class Screen(val route: String, @StringRes val resourceId: Int, val icon: ImageVector) { object General : Screen("general", R.string.general, Icons.Filled.Settings) object Equalizer : Screen("equalizer", R.string.equalizer, Icons.Filled.Equalizer) + object QuickPresets : Screen("quickPresets", R.string.quick_presets, Icons.Filled.Settings) } diff --git a/android/app/src/main/java/com/oppzippy/openscq30/ui/devicesettings/composables/DeviceSettings.kt b/android/app/src/main/java/com/oppzippy/openscq30/ui/devicesettings/composables/DeviceSettings.kt index c0f8f661..a7018138 100644 --- a/android/app/src/main/java/com/oppzippy/openscq30/ui/devicesettings/composables/DeviceSettings.kt +++ b/android/app/src/main/java/com/oppzippy/openscq30/ui/devicesettings/composables/DeviceSettings.kt @@ -29,6 +29,8 @@ import com.oppzippy.openscq30.lib.SoundcoreDeviceState import com.oppzippy.openscq30.ui.devicesettings.Screen import com.oppzippy.openscq30.ui.devicesettings.models.UiDeviceState import com.oppzippy.openscq30.ui.equalizer.EqualizerSettings +import com.oppzippy.openscq30.ui.quickpresets.QuickPresetScreen +import com.oppzippy.openscq30.ui.soundmode.SoundModeSettings import com.oppzippy.openscq30.ui.theme.OpenSCQ30Theme @OptIn(ExperimentalMaterial3Api::class) @@ -44,6 +46,7 @@ fun DeviceSettings( val navItems = listOf( Screen.General, Screen.Equalizer, + Screen.QuickPresets, ) Scaffold( topBar = { @@ -97,6 +100,9 @@ fun DeviceSettings( onEqualizerConfigurationChange = onEqualizerConfigurationChange, ) } + composable(Screen.QuickPresets.route) { + QuickPresetScreen() + } } } } diff --git a/android/app/src/main/java/com/oppzippy/openscq30/ui/devicesettings/composables/SoundModeSettings.kt b/android/app/src/main/java/com/oppzippy/openscq30/ui/devicesettings/composables/SoundModeSettings.kt deleted file mode 100644 index 129d8d7a..00000000 --- a/android/app/src/main/java/com/oppzippy/openscq30/ui/devicesettings/composables/SoundModeSettings.kt +++ /dev/null @@ -1,106 +0,0 @@ -package com.oppzippy.openscq30.ui.devicesettings.composables - -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.selection.selectable -import androidx.compose.foundation.selection.selectableGroup -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.RadioButton -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.Role -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import com.oppzippy.openscq30.R -import com.oppzippy.openscq30.lib.AmbientSoundMode -import com.oppzippy.openscq30.lib.NoiseCancelingMode -import com.oppzippy.openscq30.ui.theme.OpenSCQ30Theme - -@Composable -fun SoundModeSettings( - modifier: Modifier = Modifier, - ambientSoundMode: AmbientSoundMode, - noiseCancelingMode: NoiseCancelingMode, - onAmbientSoundModeChange: (ambientSoundMode: AmbientSoundMode) -> Unit = {}, - onNoiseCancelingModeChange: (noiseCancelingMode: NoiseCancelingMode) -> Unit = {}, -) { - Column(modifier = modifier) { - GroupHeader(stringResource(R.string.ambient_sound_mode)) - LabeledRadioButtonGroup( - selectedValue = ambientSoundMode, - values = linkedMapOf( - Pair(AmbientSoundMode.Normal, stringResource(R.string.normal)), - Pair(AmbientSoundMode.Transparency, stringResource(R.string.transparency)), - Pair(AmbientSoundMode.NoiseCanceling, stringResource(R.string.noise_canceling)), - ), - onValueChange = onAmbientSoundModeChange, - ) - Spacer(modifier = Modifier.padding(vertical = 8.dp)) - GroupHeader(stringResource(R.string.noise_canceling_mode)) - LabeledRadioButtonGroup( - selectedValue = noiseCancelingMode, - values = linkedMapOf( - Pair(NoiseCancelingMode.Transport, stringResource(R.string.transport)), - Pair(NoiseCancelingMode.Indoor, stringResource(R.string.indoor)), - Pair(NoiseCancelingMode.Outdoor, stringResource(R.string.outdoor)), - ), - onValueChange = onNoiseCancelingModeChange, - ) - } -} - -@Composable -private fun GroupHeader(text: String) { - Text( - text = text, - style = MaterialTheme.typography.titleMedium, - modifier = Modifier.padding(horizontal = 2.dp, vertical = 2.dp), - ) -} - -@Composable -private fun LabeledRadioButtonGroup( - selectedValue: T, - values: LinkedHashMap, - onValueChange: (value: T) -> Unit, -) { - Column(Modifier.selectableGroup()) { - values.forEach { (value, text) -> - LabeledRadioButton(text = text, selected = selectedValue == value, onClick = { - onValueChange(value) - }) - } - } -} - -@Composable -private fun LabeledRadioButton(text: String, selected: Boolean, onClick: () -> Unit) { - Row( - Modifier - .fillMaxWidth() - .selectable(selected = selected, onClick = onClick, role = Role.RadioButton) - .padding(horizontal = 2.dp, vertical = 2.dp), - ) { - RadioButton(selected = selected, onClick = null) - Text( - text = text, - modifier = Modifier.padding(start = 8.dp), - ) - } -} - -@Preview(showBackground = true) -@Composable -private fun PreviewSoundModeSettings() { - OpenSCQ30Theme { - SoundModeSettings( - ambientSoundMode = AmbientSoundMode.Normal, - noiseCancelingMode = NoiseCancelingMode.Indoor, - ) - } -} diff --git a/android/app/src/main/java/com/oppzippy/openscq30/ui/equalizer/composables/CustomProfileSelection.kt b/android/app/src/main/java/com/oppzippy/openscq30/ui/equalizer/composables/CustomProfileSelection.kt index b90b51db..c35941f2 100644 --- a/android/app/src/main/java/com/oppzippy/openscq30/ui/equalizer/composables/CustomProfileSelection.kt +++ b/android/app/src/main/java/com/oppzippy/openscq30/ui/equalizer/composables/CustomProfileSelection.kt @@ -6,6 +6,8 @@ import androidx.compose.ui.tooling.preview.Preview import com.oppzippy.openscq30.R import com.oppzippy.openscq30.ui.equalizer.storage.CustomProfile import com.oppzippy.openscq30.ui.theme.OpenSCQ30Theme +import com.oppzippy.openscq30.ui.utils.Dropdown +import com.oppzippy.openscq30.ui.utils.DropdownOption @Composable fun CustomProfileSelection( diff --git a/android/app/src/main/java/com/oppzippy/openscq30/ui/equalizer/composables/EqualizerLine.kt b/android/app/src/main/java/com/oppzippy/openscq30/ui/equalizer/composables/EqualizerLine.kt index e0577366..2ff35725 100644 --- a/android/app/src/main/java/com/oppzippy/openscq30/ui/equalizer/composables/EqualizerLine.kt +++ b/android/app/src/main/java/com/oppzippy/openscq30/ui/equalizer/composables/EqualizerLine.kt @@ -18,36 +18,29 @@ import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import com.oppzippy.openscq30.lib.VolumeAdjustments +import com.oppzippy.openscq30.ui.equalizer.models.EqualizerLine import com.oppzippy.openscq30.ui.theme.OpenSCQ30Theme @Composable fun EqualizerLine(values: List, width: Dp, height: Dp) { - val padding = 4 - val widthWithoutPadding = width.value - padding * 2 - val heightWithoutPadding = height.value - padding * 2 - val minVolume = VolumeAdjustments.minVolume() - val maxVolume = VolumeAdjustments.maxVolume() - val range = maxVolume - minVolume + val line = EqualizerLine(values) + val points = line.draw(width.value, height.value, 4F) - val points = values.mapIndexed { index, value -> - val normalizedX = index.toFloat() / values.size.toFloat() - val x = normalizedX * widthWithoutPadding + padding - val normalizedY = 1F - ((value - minVolume) / range.toFloat()) - val y = normalizedY * heightWithoutPadding + padding + val pathNodes = points.mapIndexed { index, pair -> if (index == 0) { - PathNode.MoveTo(x, y) + PathNode.MoveTo(pair.first, pair.second) } else { - PathNode.LineTo(x, y) + PathNode.LineTo(pair.first, pair.second) } } + val vector = ImageVector.Builder( defaultWidth = width, defaultHeight = height, viewportWidth = width.value, viewportHeight = height.value, ).addPath( - points, + pathNodes, stroke = SolidColor(MaterialTheme.colorScheme.primary), strokeLineWidth = 2F, strokeAlpha = 0.4F, diff --git a/android/app/src/main/java/com/oppzippy/openscq30/ui/equalizer/composables/PresetProfileSelection.kt b/android/app/src/main/java/com/oppzippy/openscq30/ui/equalizer/composables/PresetProfileSelection.kt index 8d295f3c..fb7336f2 100644 --- a/android/app/src/main/java/com/oppzippy/openscq30/ui/equalizer/composables/PresetProfileSelection.kt +++ b/android/app/src/main/java/com/oppzippy/openscq30/ui/equalizer/composables/PresetProfileSelection.kt @@ -10,6 +10,8 @@ import androidx.compose.ui.tooling.preview.Preview import com.oppzippy.openscq30.R import com.oppzippy.openscq30.ui.equalizer.models.EqualizerProfile import com.oppzippy.openscq30.ui.theme.OpenSCQ30Theme +import com.oppzippy.openscq30.ui.utils.Dropdown +import com.oppzippy.openscq30.ui.utils.DropdownOption @Composable fun PresetProfileSelection( diff --git a/android/app/src/main/java/com/oppzippy/openscq30/ui/equalizer/models/EqualizerLine.kt b/android/app/src/main/java/com/oppzippy/openscq30/ui/equalizer/models/EqualizerLine.kt new file mode 100644 index 00000000..63602367 --- /dev/null +++ b/android/app/src/main/java/com/oppzippy/openscq30/ui/equalizer/models/EqualizerLine.kt @@ -0,0 +1,22 @@ +package com.oppzippy.openscq30.ui.equalizer.models + +import com.oppzippy.openscq30.lib.VolumeAdjustments + +class EqualizerLine(private val values: List) { + fun draw(width: Float, height: Float, padding: Float): List> { + val widthWithoutPadding = width - padding * 2 + val heightWithoutPadding = height - padding * 2 + val minVolume = VolumeAdjustments.minVolume() + val maxVolume = VolumeAdjustments.maxVolume() + val range = maxVolume - minVolume + + val points = values.mapIndexed { index, value -> + val normalizedX = index.toFloat() / values.size.toFloat() + val x = normalizedX * widthWithoutPadding + padding + val normalizedY = 1F - ((value - minVolume) / range.toFloat()) + val y = normalizedY * heightWithoutPadding + padding + Pair(x, y) + } + return points + } +} diff --git a/android/app/src/main/java/com/oppzippy/openscq30/ui/equalizer/storage/CustomProfileDao.kt b/android/app/src/main/java/com/oppzippy/openscq30/ui/equalizer/storage/CustomProfileDao.kt index dad020d7..17127347 100644 --- a/android/app/src/main/java/com/oppzippy/openscq30/ui/equalizer/storage/CustomProfileDao.kt +++ b/android/app/src/main/java/com/oppzippy/openscq30/ui/equalizer/storage/CustomProfileDao.kt @@ -4,12 +4,19 @@ import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query +import kotlinx.coroutines.flow.Flow @Dao interface CustomProfileDao { @Query("SELECT * FROM equalizer_custom_profile") suspend fun getAll(): List + @Query("SELECT name FROM equalizer_custom_profile") + fun allNames(): Flow> + + @Query("SELECT * FROM equalizer_custom_profile WHERE name = :name") + suspend fun get(name: String): CustomProfile? + @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insert(customProfile: CustomProfile) diff --git a/android/app/src/main/java/com/oppzippy/openscq30/ui/quickpresets/QuickPresetScreen.kt b/android/app/src/main/java/com/oppzippy/openscq30/ui/quickpresets/QuickPresetScreen.kt new file mode 100644 index 00000000..9a8dd1e7 --- /dev/null +++ b/android/app/src/main/java/com/oppzippy/openscq30/ui/quickpresets/QuickPresetScreen.kt @@ -0,0 +1,127 @@ +package com.oppzippy.openscq30.ui.quickpresets + +import android.Manifest +import android.os.Build +import androidx.compose.foundation.layout.Column +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.hilt.navigation.compose.hiltViewModel +import com.oppzippy.openscq30.R +import com.oppzippy.openscq30.features.quickpresets.storage.QuickPreset +import com.oppzippy.openscq30.lib.AmbientSoundMode +import com.oppzippy.openscq30.lib.NoiseCancelingMode +import com.oppzippy.openscq30.ui.quickpresets.composables.QuickPresetConfiguration +import com.oppzippy.openscq30.ui.quickpresets.composables.QuickPresetSelection +import com.oppzippy.openscq30.ui.theme.OpenSCQ30Theme +import com.oppzippy.openscq30.ui.utils.Loading +import com.oppzippy.openscq30.ui.utils.PermissionCheck + +@Composable +fun QuickPresetScreen(viewModel: QuickPresetViewModel = hiltViewModel()) { + val preset = viewModel.quickPreset.collectAsState().value + val allEqualizerProfileNames by viewModel.equalizerProfileNames.collectAsState() + + // We can't nest the content inside the permission check since we need to ensure the permission + // check doesn't run on versions of android that don't require permission for foreground service + // notifications. + val isTiramisuOrNewer = Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU + var permissionCheckPassed by remember { mutableStateOf(!isTiramisuOrNewer) } + // Redundant check to fix lint error since permissionCheckPassed will always be true if + // isTiramisuOrNewer is false. + if (!permissionCheckPassed && isTiramisuOrNewer) { + PermissionCheck( + permission = Manifest.permission.POST_NOTIFICATIONS, + prompt = stringResource(R.string.notification_permission_is_required), + ) { + permissionCheckPassed = true + } + } + + if (permissionCheckPassed) { + if (preset != null) { + QuickPresetScreen( + preset = preset, + allEqualizerProfileNames = allEqualizerProfileNames, + onSelectedIndexChange = { viewModel.selectQuickPreset(it) }, + onAmbientSoundModeChange = { + viewModel.upsertQuickPreset( + preset.copy( + ambientSoundMode = it, + ), + ) + }, + onNoiseCancelingModeChange = { + viewModel.upsertQuickPreset( + preset.copy( + noiseCancelingMode = it, + ), + ) + }, + onEqualizerProfileNameChange = { + viewModel.upsertQuickPreset(preset.copy(equalizerProfileName = it)) + }, + ) + } else { + Loading() + } + } +} + +@Composable +private fun QuickPresetScreen( + preset: QuickPreset, + allEqualizerProfileNames: List, + onSelectedIndexChange: (index: Int) -> Unit = {}, + onAmbientSoundModeChange: (ambientSoundMode: AmbientSoundMode?) -> Unit = {}, + onNoiseCancelingModeChange: (noiseCancelingMode: NoiseCancelingMode?) -> Unit = {}, + onEqualizerProfileNameChange: (name: String?) -> Unit = {}, +) { + Column { + QuickPresetSelection( + selectedIndex = preset.id, + onSelectedIndexChange = onSelectedIndexChange, + ) + QuickPresetConfiguration( + ambientSoundMode = preset.ambientSoundMode, + noiseCancelingMode = preset.noiseCancelingMode, + equalizerProfileName = preset.equalizerProfileName, + allEqualizerProfileNames = allEqualizerProfileNames, + onAmbientSoundModeChange = onAmbientSoundModeChange, + onNoiseCancelingModeChange = onNoiseCancelingModeChange, + onEqualizerProfileNameChange = onEqualizerProfileNameChange, + ) + } +} + +@Preview(showBackground = true) +@Composable +fun PreviewQuickPresetScreenWithAllOptionsChecked() { + OpenSCQ30Theme { + QuickPresetScreen( + preset = QuickPreset( + 0, + AmbientSoundMode.Normal, + NoiseCancelingMode.Transport, + "Test EQ Profile", + ), + allEqualizerProfileNames = emptyList(), + ) + } +} + +@Preview(showBackground = true) +@Composable +fun PreviewQuickPresetScreenWithNoOptionsChecked() { + OpenSCQ30Theme { + QuickPresetScreen( + preset = QuickPreset(0), + allEqualizerProfileNames = emptyList(), + ) + } +} diff --git a/android/app/src/main/java/com/oppzippy/openscq30/ui/quickpresets/QuickPresetViewModel.kt b/android/app/src/main/java/com/oppzippy/openscq30/ui/quickpresets/QuickPresetViewModel.kt new file mode 100644 index 00000000..b7bbd535 --- /dev/null +++ b/android/app/src/main/java/com/oppzippy/openscq30/ui/quickpresets/QuickPresetViewModel.kt @@ -0,0 +1,42 @@ +package com.oppzippy.openscq30.ui.quickpresets + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.oppzippy.openscq30.features.quickpresets.storage.QuickPreset +import com.oppzippy.openscq30.features.quickpresets.storage.QuickPresetDao +import com.oppzippy.openscq30.ui.equalizer.storage.CustomProfileDao +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class QuickPresetViewModel @Inject constructor( + private val quickPresetDao: QuickPresetDao, + customProfileDao: CustomProfileDao, +) : ViewModel() { + private val _quickPreset = MutableStateFlow(null) + val quickPreset = _quickPreset.asStateFlow() + val equalizerProfileNames = + customProfileDao.allNames().stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) + + init { + selectQuickPreset(0) + } + + fun selectQuickPreset(id: Int) { + viewModelScope.launch { + _quickPreset.value = quickPresetDao.get(id) ?: QuickPreset(id) + } + } + + fun upsertQuickPreset(quickPreset: QuickPreset) { + _quickPreset.value = quickPreset + viewModelScope.launch { + quickPresetDao.insert(quickPreset) + } + } +} diff --git a/android/app/src/main/java/com/oppzippy/openscq30/ui/quickpresets/composables/QuickPresetConfiguration.kt b/android/app/src/main/java/com/oppzippy/openscq30/ui/quickpresets/composables/QuickPresetConfiguration.kt new file mode 100644 index 00000000..fef5e0ff --- /dev/null +++ b/android/app/src/main/java/com/oppzippy/openscq30/ui/quickpresets/composables/QuickPresetConfiguration.kt @@ -0,0 +1,105 @@ +package com.oppzippy.openscq30.ui.quickpresets.composables + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Divider +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import com.oppzippy.openscq30.R +import com.oppzippy.openscq30.lib.AmbientSoundMode +import com.oppzippy.openscq30.lib.NoiseCancelingMode +import com.oppzippy.openscq30.ui.soundmode.AmbientSoundModeSelection +import com.oppzippy.openscq30.ui.soundmode.NoiseCancelingModeSelection +import com.oppzippy.openscq30.ui.theme.OpenSCQ30Theme +import com.oppzippy.openscq30.ui.utils.CheckboxWithLabel +import com.oppzippy.openscq30.ui.utils.Dropdown +import com.oppzippy.openscq30.ui.utils.DropdownOption + +@Composable +fun QuickPresetConfiguration( + ambientSoundMode: AmbientSoundMode?, + noiseCancelingMode: NoiseCancelingMode?, + equalizerProfileName: String?, + allEqualizerProfileNames: List, + onAmbientSoundModeChange: (ambientSoundMode: AmbientSoundMode?) -> Unit = {}, + onNoiseCancelingModeChange: (noiseCancelingMode: NoiseCancelingMode?) -> Unit = {}, + onEqualizerProfileNameChange: (profileName: String?) -> Unit = {}, +) { + Column(Modifier.verticalScroll(rememberScrollState())) { + CheckboxWithLabel( + text = stringResource(R.string.ambient_sound_mode), + isChecked = ambientSoundMode != null, + onCheckedChange = { + onAmbientSoundModeChange(if (it) AmbientSoundMode.Normal else null) + }, + ) + if (ambientSoundMode != null) { + AmbientSoundModeSelection( + ambientSoundMode = ambientSoundMode, + onAmbientSoundModeChange = onAmbientSoundModeChange, + ) + } + Divider() + CheckboxWithLabel( + text = stringResource(R.string.noise_canceling_mode), + isChecked = noiseCancelingMode != null, + onCheckedChange = { + onNoiseCancelingModeChange(if (it) NoiseCancelingMode.Transport else null) + }, + ) + if (noiseCancelingMode != null) { + NoiseCancelingModeSelection( + noiseCancelingMode = noiseCancelingMode, + onNoiseCancelingModeChange = onNoiseCancelingModeChange, + ) + } + Divider() + + var isEqualizerChecked by remember { mutableStateOf(equalizerProfileName != null) } + CheckboxWithLabel( + text = stringResource(R.string.equalizer), + isChecked = equalizerProfileName != null || isEqualizerChecked, + onCheckedChange = { + isEqualizerChecked = it + if (!isEqualizerChecked) { + onEqualizerProfileNameChange(null) + } + }, + ) + if (equalizerProfileName != null || isEqualizerChecked) { + Dropdown( + value = equalizerProfileName, + options = allEqualizerProfileNames.map { + DropdownOption( + name = it, + value = it, + label = { Text(it) }, + ) + }, + label = stringResource(R.string.custom_profile), + onItemSelected = onEqualizerProfileNameChange, + ) + } + } +} + +@Preview(showBackground = true) +@Composable +private fun PreviewQuickPresetConfiguration() { + OpenSCQ30Theme { + QuickPresetConfiguration( + ambientSoundMode = AmbientSoundMode.NoiseCanceling, + noiseCancelingMode = NoiseCancelingMode.Transport, + equalizerProfileName = "test", + allEqualizerProfileNames = emptyList(), + ) + } +} diff --git a/android/app/src/main/java/com/oppzippy/openscq30/ui/quickpresets/composables/QuickPresetSelection.kt b/android/app/src/main/java/com/oppzippy/openscq30/ui/quickpresets/composables/QuickPresetSelection.kt new file mode 100644 index 00000000..ec9dead0 --- /dev/null +++ b/android/app/src/main/java/com/oppzippy/openscq30/ui/quickpresets/composables/QuickPresetSelection.kt @@ -0,0 +1,31 @@ +package com.oppzippy.openscq30.ui.quickpresets.composables + +import androidx.compose.material3.Tab +import androidx.compose.material3.TabRow +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import com.oppzippy.openscq30.R +import com.oppzippy.openscq30.ui.theme.OpenSCQ30Theme + +@Composable +fun QuickPresetSelection(selectedIndex: Int, onSelectedIndexChange: (index: Int) -> Unit) { + TabRow(selectedTabIndex = selectedIndex) { + for (i in 0..1) { + Tab( + selected = selectedIndex == i, + onClick = { onSelectedIndexChange(i) }, + text = { Text(stringResource(R.string.quick_preset_number, i + 1)) }, + ) + } + } +} + +@Preview(showBackground = true) +@Composable +private fun PreviewQuickPresetSelection() { + OpenSCQ30Theme { + QuickPresetSelection(selectedIndex = 0, onSelectedIndexChange = {}) + } +} diff --git a/android/app/src/main/java/com/oppzippy/openscq30/ui/soundmode/AmbientSoundModeSelection.kt b/android/app/src/main/java/com/oppzippy/openscq30/ui/soundmode/AmbientSoundModeSelection.kt new file mode 100644 index 00000000..aae7d796 --- /dev/null +++ b/android/app/src/main/java/com/oppzippy/openscq30/ui/soundmode/AmbientSoundModeSelection.kt @@ -0,0 +1,36 @@ +package com.oppzippy.openscq30.ui.soundmode + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import com.oppzippy.openscq30.R +import com.oppzippy.openscq30.lib.AmbientSoundMode +import com.oppzippy.openscq30.ui.theme.OpenSCQ30Theme +import com.oppzippy.openscq30.ui.utils.LabeledRadioButtonGroup + +@Composable +fun AmbientSoundModeSelection( + ambientSoundMode: AmbientSoundMode, + onAmbientSoundModeChange: (ambientSoundMode: AmbientSoundMode) -> Unit, +) { + LabeledRadioButtonGroup( + selectedValue = ambientSoundMode, + values = linkedMapOf( + Pair(AmbientSoundMode.Normal, stringResource(R.string.normal)), + Pair(AmbientSoundMode.Transparency, stringResource(R.string.transparency)), + Pair(AmbientSoundMode.NoiseCanceling, stringResource(R.string.noise_canceling)), + ), + onValueChange = onAmbientSoundModeChange, + ) +} + +@Preview(showBackground = true) +@Composable +private fun PreviewAmbientSoundModeSelection() { + OpenSCQ30Theme { + AmbientSoundModeSelection( + ambientSoundMode = AmbientSoundMode.Normal, + onAmbientSoundModeChange = {}, + ) + } +} diff --git a/android/app/src/main/java/com/oppzippy/openscq30/ui/soundmode/NoiseCancelingModeSelection.kt b/android/app/src/main/java/com/oppzippy/openscq30/ui/soundmode/NoiseCancelingModeSelection.kt new file mode 100644 index 00000000..4414a55b --- /dev/null +++ b/android/app/src/main/java/com/oppzippy/openscq30/ui/soundmode/NoiseCancelingModeSelection.kt @@ -0,0 +1,36 @@ +package com.oppzippy.openscq30.ui.soundmode + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import com.oppzippy.openscq30.R +import com.oppzippy.openscq30.lib.NoiseCancelingMode +import com.oppzippy.openscq30.ui.theme.OpenSCQ30Theme +import com.oppzippy.openscq30.ui.utils.LabeledRadioButtonGroup + +@Composable +fun NoiseCancelingModeSelection( + noiseCancelingMode: NoiseCancelingMode, + onNoiseCancelingModeChange: (noiseCancelingMode: NoiseCancelingMode) -> Unit, +) { + LabeledRadioButtonGroup( + selectedValue = noiseCancelingMode, + values = linkedMapOf( + Pair(NoiseCancelingMode.Transport, stringResource(R.string.transport)), + Pair(NoiseCancelingMode.Indoor, stringResource(R.string.indoor)), + Pair(NoiseCancelingMode.Outdoor, stringResource(R.string.outdoor)), + ), + onValueChange = onNoiseCancelingModeChange, + ) +} + +@Preview(showBackground = true) +@Composable +private fun PreviewNoiseCancelingModeSelection() { + OpenSCQ30Theme { + NoiseCancelingModeSelection( + noiseCancelingMode = NoiseCancelingMode.Transport, + onNoiseCancelingModeChange = {}, + ) + } +} diff --git a/android/app/src/main/java/com/oppzippy/openscq30/ui/soundmode/SoundModeSettings.kt b/android/app/src/main/java/com/oppzippy/openscq30/ui/soundmode/SoundModeSettings.kt new file mode 100644 index 00000000..62acf925 --- /dev/null +++ b/android/app/src/main/java/com/oppzippy/openscq30/ui/soundmode/SoundModeSettings.kt @@ -0,0 +1,59 @@ +package com.oppzippy.openscq30.ui.soundmode + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.oppzippy.openscq30.R +import com.oppzippy.openscq30.lib.AmbientSoundMode +import com.oppzippy.openscq30.lib.NoiseCancelingMode +import com.oppzippy.openscq30.ui.theme.OpenSCQ30Theme + +@Composable +fun SoundModeSettings( + modifier: Modifier = Modifier, + ambientSoundMode: AmbientSoundMode, + noiseCancelingMode: NoiseCancelingMode, + onAmbientSoundModeChange: (ambientSoundMode: AmbientSoundMode) -> Unit = {}, + onNoiseCancelingModeChange: (noiseCancelingMode: NoiseCancelingMode) -> Unit = {}, +) { + Column(modifier = modifier) { + GroupHeader(stringResource(R.string.ambient_sound_mode)) + AmbientSoundModeSelection( + ambientSoundMode = ambientSoundMode, + onAmbientSoundModeChange = onAmbientSoundModeChange, + ) + Spacer(modifier = Modifier.padding(vertical = 8.dp)) + GroupHeader(stringResource(R.string.noise_canceling_mode)) + NoiseCancelingModeSelection( + noiseCancelingMode = noiseCancelingMode, + onNoiseCancelingModeChange = onNoiseCancelingModeChange, + ) + } +} + +@Composable +private fun GroupHeader(text: String) { + Text( + text = text, + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(horizontal = 2.dp, vertical = 2.dp), + ) +} + +@Preview(showBackground = true) +@Composable +private fun PreviewSoundModeSettings() { + OpenSCQ30Theme { + SoundModeSettings( + ambientSoundMode = AmbientSoundMode.Normal, + noiseCancelingMode = NoiseCancelingMode.Indoor, + ) + } +} diff --git a/android/app/src/main/java/com/oppzippy/openscq30/ui/utils/CheckboxWithLabel.kt b/android/app/src/main/java/com/oppzippy/openscq30/ui/utils/CheckboxWithLabel.kt new file mode 100644 index 00000000..dbb72f61 --- /dev/null +++ b/android/app/src/main/java/com/oppzippy/openscq30/ui/utils/CheckboxWithLabel.kt @@ -0,0 +1,49 @@ +package com.oppzippy.openscq30.ui.utils + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.selection.toggleable +import androidx.compose.material.Checkbox +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.oppzippy.openscq30.ui.theme.OpenSCQ30Theme + +@Composable +fun CheckboxWithLabel( + text: String, + isChecked: Boolean, + onCheckedChange: (value: Boolean) -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(2.dp) + .toggleable( + value = isChecked, + onValueChange = onCheckedChange, + role = Role.Checkbox, + ), + verticalAlignment = Alignment.CenterVertically, + ) { + Checkbox( + checked = isChecked, + onCheckedChange = null, + ) + Text(text, modifier = Modifier.padding(start = 8.dp)) + } +} + +@Preview(showBackground = true) +@Composable +private fun PreviewCheckboxWithLabel() { + OpenSCQ30Theme { + CheckboxWithLabel(text = "Checkbox", isChecked = true, onCheckedChange = {}) + } +} diff --git a/android/app/src/main/java/com/oppzippy/openscq30/ui/equalizer/composables/Dropdown.kt b/android/app/src/main/java/com/oppzippy/openscq30/ui/utils/Dropdown.kt similarity index 98% rename from android/app/src/main/java/com/oppzippy/openscq30/ui/equalizer/composables/Dropdown.kt rename to android/app/src/main/java/com/oppzippy/openscq30/ui/utils/Dropdown.kt index 97df42ab..9571bda7 100644 --- a/android/app/src/main/java/com/oppzippy/openscq30/ui/equalizer/composables/Dropdown.kt +++ b/android/app/src/main/java/com/oppzippy/openscq30/ui/utils/Dropdown.kt @@ -1,4 +1,4 @@ -package com.oppzippy.openscq30.ui.equalizer.composables +package com.oppzippy.openscq30.ui.utils import androidx.compose.foundation.layout.width import androidx.compose.material3.DropdownMenuItem diff --git a/android/app/src/main/java/com/oppzippy/openscq30/ui/utils/LabeledRadioButton.kt b/android/app/src/main/java/com/oppzippy/openscq30/ui/utils/LabeledRadioButton.kt new file mode 100644 index 00000000..c0665f77 --- /dev/null +++ b/android/app/src/main/java/com/oppzippy/openscq30/ui/utils/LabeledRadioButton.kt @@ -0,0 +1,63 @@ +package com.oppzippy.openscq30.ui.utils + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.material.RadioButton +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.oppzippy.openscq30.ui.theme.OpenSCQ30Theme + +@Composable +fun LabeledRadioButtonGroup( + selectedValue: T, + values: LinkedHashMap, + onValueChange: (value: T) -> Unit, +) { + Column(Modifier.selectableGroup()) { + values.forEach { (value, text) -> + LabeledRadioButton(text = text, selected = selectedValue == value, onClick = { + onValueChange(value) + }) + } + } +} + +@Composable +private fun LabeledRadioButton(text: String, selected: Boolean, onClick: () -> Unit) { + Row( + Modifier + .fillMaxWidth() + .selectable(selected = selected, onClick = onClick, role = Role.RadioButton) + .padding(horizontal = 2.dp, vertical = 2.dp), + ) { + RadioButton(selected = selected, onClick = null) + Text( + text = text, + modifier = Modifier.padding(start = 8.dp), + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun PreviewLabeledRadioButtonGroup() { + OpenSCQ30Theme { + LabeledRadioButtonGroup( + selectedValue = 1, + values = linkedMapOf( + Pair(1, "Item 1"), + Pair(2, "Item 2"), + Pair(3, "Item 3"), + ), + onValueChange = {}, + ) + } +} diff --git a/android/app/src/main/java/com/oppzippy/openscq30/ui/devicesettings/composables/Loading.kt b/android/app/src/main/java/com/oppzippy/openscq30/ui/utils/Loading.kt similarity index 94% rename from android/app/src/main/java/com/oppzippy/openscq30/ui/devicesettings/composables/Loading.kt rename to android/app/src/main/java/com/oppzippy/openscq30/ui/utils/Loading.kt index b4557b91..6af7e91c 100644 --- a/android/app/src/main/java/com/oppzippy/openscq30/ui/devicesettings/composables/Loading.kt +++ b/android/app/src/main/java/com/oppzippy/openscq30/ui/utils/Loading.kt @@ -1,4 +1,4 @@ -package com.oppzippy.openscq30.ui.devicesettings.composables +package com.oppzippy.openscq30.ui.utils import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column diff --git a/android/app/src/main/java/com/oppzippy/openscq30/ui/deviceselection/composables/PermissionCheck.kt b/android/app/src/main/java/com/oppzippy/openscq30/ui/utils/PermissionCheck.kt similarity index 96% rename from android/app/src/main/java/com/oppzippy/openscq30/ui/deviceselection/composables/PermissionCheck.kt rename to android/app/src/main/java/com/oppzippy/openscq30/ui/utils/PermissionCheck.kt index 51c97667..fa15ae2e 100644 --- a/android/app/src/main/java/com/oppzippy/openscq30/ui/deviceselection/composables/PermissionCheck.kt +++ b/android/app/src/main/java/com/oppzippy/openscq30/ui/utils/PermissionCheck.kt @@ -1,4 +1,4 @@ -package com.oppzippy.openscq30.ui.deviceselection.composables +package com.oppzippy.openscq30.ui.utils import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column diff --git a/android/app/src/main/res/drawable/baseline_headset_off_24.xml b/android/app/src/main/res/drawable/baseline_headset_off_24.xml new file mode 100644 index 00000000..676c4ec7 --- /dev/null +++ b/android/app/src/main/res/drawable/baseline_headset_off_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/android/app/src/main/res/drawable/counter_1_48px.xml b/android/app/src/main/res/drawable/counter_1_48px.xml new file mode 100644 index 00000000..a5a9f5a2 --- /dev/null +++ b/android/app/src/main/res/drawable/counter_1_48px.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/counter_2_48px.xml b/android/app/src/main/res/drawable/counter_2_48px.xml new file mode 100644 index 00000000..7a7ac2da --- /dev/null +++ b/android/app/src/main/res/drawable/counter_2_48px.xml @@ -0,0 +1,11 @@ + + + diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 5bd639d6..6b4953d2 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -84,4 +84,8 @@ Disconnect Awaiting Connection An error has occurred + %s, %s + Quick Presets + Quick Preset %d + Notification Permission is Required