diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 570bfc49..213de24d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -106,7 +106,7 @@ android { generateLocaleConfig = true } - applicationVariants.all { + applicationVariants.all { outputs.all { this as BaseVariantOutputImpl outputFileName = "VoiceNotify_v${defaultConfig.versionName}-${name}_$gitCommitHash.apk" @@ -117,15 +117,16 @@ android { dependencies { implementation("androidx.core:core-ktx:1.13.1") implementation("androidx.activity:activity-compose:1.9.0") - implementation("androidx.compose.material3:material3:1.3.0-beta01") - implementation("androidx.compose.material:material-icons-extended-android:1.6.7") - implementation("androidx.compose.ui:ui-tooling-preview:1.6.7") - debugImplementation("androidx.compose.ui:ui-tooling:1.6.7") + implementation("androidx.compose.material3:material3:1.3.0-beta04") + implementation("androidx.compose.material:material-icons-extended-android:1.6.8") + implementation("androidx.compose.ui:ui-tooling-preview:1.6.8") + debugImplementation("androidx.compose.ui:ui-tooling:1.6.8") implementation("androidx.navigation:navigation-compose:2.7.7") - implementation("androidx.glance:glance-appwidget:1.0.0") + implementation("androidx.glance:glance-appwidget:1.1.0") implementation("androidx.preference:preference-ktx:1.2.1") implementation("androidx.room:room-ktx:2.6.1") ksp("androidx.room:room-compiler:2.6.1") implementation("org.jetbrains.kotlin:kotlin-reflect:1.9.24") + implementation("org.jetbrains.kotlinx:kotlinx-collections-immutable:0.3.4") implementation("com.google.accompanist:accompanist-permissions:0.34.0") } diff --git a/app/schemas/com.pilot51.voicenotify.db.AppDatabase/2.json b/app/schemas/com.pilot51.voicenotify.db.AppDatabase/2.json index 5916f980..9e086e3f 100644 --- a/app/schemas/com.pilot51.voicenotify.db.AppDatabase/2.json +++ b/app/schemas/com.pilot51.voicenotify.db.AppDatabase/2.json @@ -6,7 +6,7 @@ "entities": [ { "tableName": "apps", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`package` TEXT NOT NULL, `name` TEXT NOT NULL COLLATE NOCASE, `is_enabled` INTEGER, PRIMARY KEY(`package`))", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`package` TEXT NOT NULL, `name` TEXT NOT NULL COLLATE NOCASE, `is_enabled` INTEGER, PRIMARY KEY(`package`))", "fields": [ { "fieldPath": "packageName", diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index ce9ecc81..b4b9b8b3 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -14,52 +14,58 @@ limitations under the License. --> - - - - - - - - - - - - - - - - - - - - - - - - - - - - + xmlns:tools="http://schemas.android.com/tools" + android:installLocation="internalOnly"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/java/com/pilot51/voicenotify/AppListScreen.kt b/app/src/main/java/com/pilot51/voicenotify/AppListScreen.kt index 88dee3a0..d2307fab 100644 --- a/app/src/main/java/com/pilot51/voicenotify/AppListScreen.kt +++ b/app/src/main/java/com/pilot51/voicenotify/AppListScreen.kt @@ -15,212 +15,396 @@ */ package com.pilot51.voicenotify + +import android.content.Context +import android.content.pm.PackageManager +import android.content.res.Configuration +import android.util.Log +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.Image +import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.focusable -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.selection.toggleable +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.CheckBox -import androidx.compose.material.icons.filled.CheckBoxOutlineBlank -import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.outlined.Cancel -import androidx.compose.material3.* -import androidx.compose.runtime.* +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.painter.BitmapPainter +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInWindow +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource 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 androidx.compose.ui.unit.sp +import androidx.core.graphics.drawable.toBitmap import androidx.lifecycle.ViewModelStoreOwner import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner import androidx.lifecycle.viewmodel.compose.viewModel import com.pilot51.voicenotify.AppListViewModel.IgnoreType import com.pilot51.voicenotify.db.App -import kotlinx.coroutines.delay +import com.pilot51.voicenotify.ui.Layout +import com.pilot51.voicenotify.ui.LazyAlphabetIndexRow +import com.pilot51.voicenotify.ui.ListItem +import com.pilot51.voicenotify.ui.ScrollingBubble +import com.pilot51.voicenotify.ui.SearchBar +import com.pilot51.voicenotify.ui.Switch +import com.pilot51.voicenotify.ui.bottomBorder +import com.pilot51.voicenotify.ui.theme.VoiceNotifyTheme private lateinit var vmStoreOwner: ViewModelStoreOwner + @Composable -fun AppListActions() { - val vm: AppListViewModel = viewModel(vmStoreOwner) - var showSearchBar by remember { mutableStateOf(false) } - if (showSearchBar) { - val focusRequester = remember { FocusRequester() } - val keyboard = LocalSoftwareKeyboardController.current - TextField( - value = vm.searchQuery ?: "", - onValueChange = { - vm.searchQuery = it - vm.filterApps(it) - }, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 4.dp) - .focusRequester(focusRequester), - maxLines = 1, - singleLine = true, - leadingIcon = { - Icon( - imageVector = Icons.Filled.Search, - contentDescription = null - ) - }, - trailingIcon = { - IconButton(onClick = { - showSearchBar = false - vm.searchQuery = null - vm.filterApps(null) - }) { - Icon( - imageVector = Icons.Filled.Close, - contentDescription = stringResource(R.string.close) - ) - } - } - ) - LaunchedEffect(focusRequester) { - focusRequester.requestFocus() - delay(100) - keyboard?.show() - } - } else { - var showConfirmDialog by remember { mutableStateOf(null) } - IconButton( - onClick = { showSearchBar = true } - ) { - Icon( - imageVector = Icons.Filled.Search, - contentDescription = stringResource(R.string.filter) - ) - } - IconButton( - onClick = { showConfirmDialog = IgnoreType.IGNORE_ALL } - ) { - Icon( - imageVector = Icons.Filled.CheckBoxOutlineBlank, - contentDescription = stringResource(R.string.ignore_all) - ) - } - IconButton( - onClick = { showConfirmDialog = IgnoreType.IGNORE_NONE } - ) { - Icon( - imageVector = Icons.Filled.CheckBox, - contentDescription = stringResource(R.string.ignore_none) - ) - } - showConfirmDialog?.let { - val isEnableAll = it == IgnoreType.IGNORE_NONE - ConfirmDialog( - text = stringResource( - R.string.ignore_enable_apps_confirm, - stringResource(if (isEnableAll) R.string.enable else R.string.ignore).lowercase() - ), - onConfirm = { vm.massIgnore(it) }, - onDismiss = { showConfirmDialog = null } - ) - } - } +fun AppListActions(modifier: Modifier = Modifier) { + val vm: AppListViewModel = viewModel(vmStoreOwner) + SearchBar( + modifier = modifier, + text = vm.searchQuery ?: "", + onValueChange = { + vm.searchQuery = it + vm.filterApps(it) + }, + placeholderText = "Search" + ) } + @Composable fun AppListScreen( - onConfigureApp: (app: App) -> Unit + list: List = emptyList(), + onConfigureApp: (app: App) -> Unit ) { - vmStoreOwner = LocalViewModelStoreOwner.current!! - val vm: AppListViewModel = viewModel(vmStoreOwner) - val packagesWithOverride by vm.packagesWithOverride - AppList( - vm.filteredApps, - vm.showList, - packagesWithOverride, - toggleIgnore = { app -> - vm.setIgnore(app, IgnoreType.IGNORE_TOGGLE) - }, - onConfigureApp = onConfigureApp, - onRemoveOverrides = vm::removeOverrides - ) + vmStoreOwner = LocalViewModelStoreOwner.current!! + val vm: AppListViewModel = viewModel(vmStoreOwner) + val packagesWithOverride by vm.packagesWithOverride + var showConfirmDialog by remember { mutableStateOf(null) } + var filteredApps = list.takeIf { it.isNotEmpty() } ?: vm.filteredApps + val lazyListState = rememberLazyListState() + val showList by vm::showList + val appListLocalMap by vm::appListLocalMap + + Layout(modifier = Modifier) { + AppListActions( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp, 4.dp, 16.dp, 8.dp) + ) + AppList( + lazyListState = lazyListState, + filteredApps = filteredApps, + appListLocalMap = appListLocalMap, + showList = showList, + stickyHeader = { + Row( + modifier = Modifier + .fillMaxWidth() + .background(VoiceNotifyTheme.colors.boxItem) + ) { + Row( + modifier = Modifier + .weight(1f) + .padding(start = 8.dp, end = 8.dp) + .bottomBorder(2f, VoiceNotifyTheme.colors.divider), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text(text = stringResource(R.string.ignore_none)) + Switch( + checked = vm.appEnable, + onCheckedChange = { + showConfirmDialog = + if (it) IgnoreType.IGNORE_NONE else IgnoreType.IGNORE_ALL + } + ) + showConfirmDialog?.let { + val isEnableAll = it == IgnoreType.IGNORE_NONE + ConfirmDialog( + text = stringResource( + R.string.ignore_enable_apps_confirm, + stringResource(if (isEnableAll) R.string.enable else R.string.ignore).lowercase() + ), + onConfirm = { vm.massIgnore(it) }, + onDismiss = { showConfirmDialog = null } + ) + } + } + } + }, + packagesWithOverride = packagesWithOverride, + toggleIgnore = { app -> vm.setIgnore(app, IgnoreType.IGNORE_TOGGLE) }, + onConfigureApp = onConfigureApp, + onRemoveOverrides = vm::removeOverrides + ) + } } +@Composable +fun SkeletonListItem() { + ListItem( + modifier = Modifier + .fillMaxWidth() + .background(Color.Gray.copy(alpha = 0.1f)), + leadingContent = { + Box( + modifier = Modifier + .size(48.dp) + .background(Color.Gray.copy(alpha = 0.3f), shape = CircleShape) + ) + }, + headlineContent = { + Box( + modifier = Modifier + .height(18.dp) + .fillMaxWidth(0.5f) + .background(Color.Gray.copy(alpha = 0.3f)) + ) + }, + supportingContent = { + Box( + modifier = Modifier + .height(12.dp) + .fillMaxWidth(0.7f) + .background(Color.Gray.copy(alpha = 0.3f)) + ) + }, + trailingContent = { + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + Box( + modifier = Modifier + .size(24.dp) + .background(Color.Gray.copy(alpha = 0.3f)) + ) + } + } + ) +} + + +@OptIn(ExperimentalFoundationApi::class) @Composable private fun AppList( - filteredApps: List, - showList: Boolean, - packagesWithOverride: List, - toggleIgnore: (app: App) -> Unit, - onConfigureApp: (app: App) -> Unit, - onRemoveOverrides: (app: App) -> Unit + lazyListState: LazyListState = rememberLazyListState(), + filteredApps: List, + appListLocalMap: MutableMap, + showList: Boolean, + packagesWithOverride: List, + toggleIgnore: (app: App) -> Unit, + onConfigureApp: (app: App) -> Unit, + onRemoveOverrides: (app: App) -> Unit, + stickyHeader: @Composable () -> Unit = {} ) { - if (!showList) return - LazyColumn(modifier = Modifier.fillMaxSize()) { - items(filteredApps) { - val hasOverride = packagesWithOverride.contains(it.packageName) - AppListItem(it, hasOverride, toggleIgnore, onConfigureApp, onRemoveOverrides) - } - } + + if (!showList) return + var alphabetRelativeDragYOffset: Float? by remember { mutableStateOf(null) } + var alphabetDistanceFromTopOfScreen: Float by remember { mutableStateOf(0F) } + var currentLetter by remember { mutableStateOf(null) } + var appListTopOffset by remember { mutableStateOf(0F) } + + BoxWithConstraints( + Modifier.onGloballyPositioned { coordinates -> + appListTopOffset = coordinates.positionInWindow().y + } + ) { + LazyAlphabetIndexRow( + items = filteredApps, + keySelector = { appListLocalMap[it.packageName].toString() }, + lazyListState = lazyListState, + alphabetModifier = Modifier + .fillMaxHeight() + .width(16.dp) + .clip(RoundedCornerShape(8.dp)), + alphabetPaddingValues = PaddingValues(0.dp, 0.dp, 0.dp, 60.dp), + onAlphabetListDrag = { relativeDragYOffset, containerDistance, char -> + alphabetRelativeDragYOffset = relativeDragYOffset + alphabetDistanceFromTopOfScreen = containerDistance + currentLetter = char + } + ) { + Box( + modifier = Modifier + .padding(12.dp, 0.dp, 0.dp, 0.dp) + .clip(RoundedCornerShape(12.dp)) + .background(VoiceNotifyTheme.colors.boxItem) + ) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + state = lazyListState, + contentPadding = PaddingValues(end = 0.dp) + ) { + stickyHeader { stickyHeader() } + itemsIndexed(items = filteredApps) { index, it -> + val hasOverride = packagesWithOverride.contains(it.packageName) + AppListItem( + it, + hasOverride, + toggleIgnore, + onConfigureApp, + onRemoveOverrides + ) + } + } + } + } + val yOffset = alphabetRelativeDragYOffset + if (yOffset != null && currentLetter != null) { + ScrollingBubble( + bubbleOffsetX = this.maxWidth - 80.dp, + bubbleOffsetY = this.maxHeight / 4, + currAlphabetScrolledOn = currentLetter, + ) + } + } } +@Composable +fun PackageImage(context: Context, packageName: String, modifier: Modifier = Modifier) { + val packageManager: PackageManager = context.packageManager + val result = runCatching { + val appInfo = packageManager.getApplicationInfo(packageName, 0) + val iconBitmap = appInfo.loadIcon(packageManager).toBitmap().asImageBitmap() + Image( + painter = BitmapPainter(iconBitmap), + contentDescription = null, + modifier = modifier + ) + } + result.onFailure { + Image( + painter = painterResource(R.drawable.ic_launcher_foreground), + contentDescription = null, + modifier = modifier + ) + } +} + + @Composable private fun AppListItem( - app: App, - hasOverride: Boolean, - toggleIgnore: (app: App) -> Unit, - onConfigureApp: (app: App) -> Unit, - onRemoveOverrides: (app: App) -> Unit + app: App, + hasOverride: Boolean, + toggleIgnore: (app: App) -> Unit, + onConfigureApp: (app: App) -> Unit, + onRemoveOverrides: (app: App) -> Unit ) { - var showRemoveOverridesDialog by remember { mutableStateOf(false) } - ListItem( - modifier = Modifier.clickable { - onConfigureApp(app) - }, - headlineContent = { - Text( - text = app.label, - fontSize = 24.sp - ) - }, - supportingContent = { - Text(app.packageName) - }, - trailingContent = { - Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { - if (hasOverride) { - IconButton(onClick = { showRemoveOverridesDialog = true }) { - Icon( - imageVector = Icons.Outlined.Cancel, - contentDescription = stringResource(R.string.remove_app_overrides) - ) - } - } - Checkbox( - checked = app.enabled, - modifier = Modifier.focusable(false), - onCheckedChange = { toggleIgnore(app) } - ) - } - } - ) - if (showRemoveOverridesDialog) { - ConfirmDialog( - text = stringResource(R.string.remove_app_overrides_confirm, app.label), - onConfirm = { onRemoveOverrides(app) }, - onDismiss = { showRemoveOverridesDialog = false } - ) - } + var showRemoveOverridesDialog by remember { mutableStateOf(false) } + var context = LocalContext.current + + ListItem( + modifier = Modifier + // .toggleable( + // value = app.enabled, + // role = Role.Checkbox, + // onValueChange = { toggleIgnore(app) } + // ) + .clickable { + onConfigureApp(app) + } + .fillMaxWidth(), + leadingContent = { + PackageImage( + context = context, + packageName = app.packageName, + modifier = Modifier.size(48.dp) + ) + }, + headlineContent = { + Text( + text = app.label, + fontSize = 18.sp + ) + }, + supportingContent = { + Text( + text = app.packageName, + fontSize = 12.sp + ) + }, + trailingContent = { + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + if (hasOverride) { + IconButton(onClick = { showRemoveOverridesDialog = true }) { + Icon( + imageVector = Icons.Outlined.Cancel, + contentDescription = stringResource(R.string.remove_app_overrides) + ) + } + } + Switch( + checked = app.enabled, + onCheckedChange = { toggleIgnore(app) }, + modifier = Modifier.focusable(false) + ) + } + } + ) + if (showRemoveOverridesDialog) { + ConfirmDialog( + text = stringResource(R.string.remove_app_overrides_confirm, app.label), + onConfirm = { onRemoveOverrides(app) }, + onDismiss = { showRemoveOverridesDialog = false } + ) + } } -@VNPreview +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) @Composable private fun AppListPreview() { - val apps = listOf( - App("package.name.one", "App Name 1", true), - App("package.name.two", "App Name 2", false) - ) - AppTheme { - AppList(apps, true, listOf("package.name.one"), {}, {}, {}) - } + val apps = listOf( + App("package.name.one", "App Name 1", true), + App("package.name.two", "App Name 2", false) + ) + var lazyListState = rememberLazyListState() + AppTheme { + AppList( + lazyListState = lazyListState, + filteredApps = apps, + showList = true, + appListLocalMap = mutableMapOf("package.name.one" to 'A', "package.name.two" to 'B'), + packagesWithOverride = listOf("package.name.one"), + toggleIgnore = {}, + onConfigureApp = {}, + onRemoveOverrides = {} + ) + } } + + + diff --git a/app/src/main/java/com/pilot51/voicenotify/AppListViewModel.kt b/app/src/main/java/com/pilot51/voicenotify/AppListViewModel.kt index 0703ab1e..6bc182ea 100644 --- a/app/src/main/java/com/pilot51/voicenotify/AppListViewModel.kt +++ b/app/src/main/java/com/pilot51/voicenotify/AppListViewModel.kt @@ -16,8 +16,14 @@ package com.pilot51.voicenotify import android.app.Application +import android.content.Context +import android.content.Intent +import android.content.pm.ApplicationInfo import android.content.pm.PackageManager +import android.graphics.drawable.Drawable +import android.icu.text.Collator import android.os.Build +import android.util.Log import android.widget.Toast import androidx.compose.runtime.* import androidx.lifecycle.AndroidViewModel @@ -29,12 +35,23 @@ import com.pilot51.voicenotify.PreferenceHelper.getPrefFlow import com.pilot51.voicenotify.PreferenceHelper.setPref import com.pilot51.voicenotify.db.App import com.pilot51.voicenotify.db.AppDatabase +import kotlinx.collections.immutable.ImmutableMap import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.coroutines.sync.withLock +import java.util.Locale + +data class AppInfo( + val icon: Drawable, + val label: String, + val packageName: String, + val isEnabled: Boolean +) + class AppListViewModel(application: Application) : AndroidViewModel(application) { private val appContext = application.applicationContext @@ -47,6 +64,12 @@ class AppListViewModel(application: Application) : AndroidViewModel(application) private val settingsDao = AppDatabase.db.settingsDao val packagesWithOverride @Composable get() = settingsDao.packagesWithOverride().collectAsState(listOf()) + var appEnable by mutableStateOf(false) + // mutableMap + var appListLocalMap = mutableMapOf( + "" to ' ' + ) + init { updateAppsList() @@ -64,55 +87,83 @@ class AppListViewModel(application: Application) : AndroidViewModel(application) isUpdating = true CoroutineScope(Dispatchers.IO).launch { syncAppsMutex.withLock { - apps.clear() - apps.addAll(AppDatabase.db.appDao.getAll()) - val isFirstLoad = apps.isEmpty() - val packMan = appContext.packageManager - - // Remove uninstalled - for (a in apps.indices.reversed()) { - val app = apps[a] + val collator = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + Collator.getInstance(Locale.getDefault()) + } else { + TODO("VERSION.SDK_INT < N") + } + // Use a HashSet to quickly check if an app is already in the list + val existingAppSet = apps.map { it.packageName }.toHashSet() + + + + // Fetch installed apps + val installedApps = Common.getAppsInfo(appContext) + +// val packMan = appContext.packageManager +// val installedApps = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { +// packMan.getInstalledApplications(PackageManager.ApplicationInfoFlags.of(0L)) +// } else { +// packMan.getInstalledApplications(0) +// } + + // Remove uninstalled apps + apps.retainAll { app -> try { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - packMan.getApplicationInfo(app.packageName, PackageManager.ApplicationInfoFlags.of(0L)) + appContext.packageManager.getApplicationInfo(app.packageName, PackageManager.ApplicationInfoFlags.of(0L)) } else { - packMan.getApplicationInfo(app.packageName, 0) + appContext.packageManager.getApplicationInfo(app.packageName, 0) } + true } catch (e: PackageManager.NameNotFoundException) { - if (!isFirstLoad) app.remove() - apps.removeAt(a) + app.remove() + false } } + // Remove uninstalled apps +// apps.retainAll { app -> +// installedApps.any { it.packageName == app.packageName } +// } - // Add new - val installedApps = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - packMan.getInstalledApplications(PackageManager.ApplicationInfoFlags.of(0L)) - } else { - packMan.getInstalledApplications(0) - } - inst@ for (appInfo in installedApps) { - for (app in apps) { - if (app.packageName == appInfo.packageName) { - continue@inst - } - } - val app = App( + // Add new apps + val newApps = installedApps.filter { appInfo -> + !existingAppSet.contains(appInfo.packageName) + }.map { appInfo -> + App( packageName = appInfo.packageName, - label = appInfo.loadLabel(packMan).toString(), + label = appInfo.loadLabel(appContext.packageManager).toString(), isEnabled = appDefaultEnable - ) - apps.add(app) - if (!isFirstLoad) app.updateDb() + ).apply { + if (apps.isNotEmpty()) updateDb() + } } - apps.sortWith { app1, app2 -> app1.label.compareTo(app2.label, ignoreCase = true) } - if (isFirstLoad) AppDatabase.db.appDao.upsert(apps) + + // Update the app list + apps.addAll(newApps) + + + appListLocalMap = apps.associateBy({ it.packageName }, { AlphabeticIndexHelper.computeSectionName(it.label).uppercase().first() }).toMutableMap() + + + apps.sortWith(compareBy(collator) { appListLocalMap[it.packageName].toString() + it.label }) + + // Batch update the database if it's the first load + if (apps.isEmpty()) AppDatabase.db.appDao.upsert(apps) + + isUpdating = false + filterApps() + updateAppEnableState() + showList = true } - isUpdating = false - filterApps() - showList = true } } + + private fun updateAppEnableState() { + appEnable = apps.all { it.enabled } + } + fun filterApps(search: String? = searchQuery) { filteredApps.clear() filteredApps.addAll(if (search.isNullOrEmpty()) { @@ -133,12 +184,14 @@ class AppListViewModel(application: Application) : AndroidViewModel(application) fun massIgnore(ignoreType: IgnoreType) { if (ignoreType == IGNORE_ALL) appDefaultEnable = false else if (ignoreType == IGNORE_NONE) appDefaultEnable = true + appEnable = appDefaultEnable CoroutineScope(Dispatchers.IO).launch { syncAppsMutex.withLock { if (apps.isEmpty()) return@launch for (app in apps) { setIgnore(app, ignoreType) } + updateAppEnableState() filterApps() } AppDatabase.db.appDao.upsert(apps) @@ -160,6 +213,8 @@ class AppListViewModel(application: Application) : AndroidViewModel(application) if (ignoreType == IGNORE_TOGGLE) { filterApps() } + + updateAppEnableState() } enum class IgnoreType { @@ -180,3 +235,6 @@ class AppListViewModel(application: Application) : AndroidViewModel(application) } } } + + + diff --git a/app/src/main/java/com/pilot51/voicenotify/Common.kt b/app/src/main/java/com/pilot51/voicenotify/Common.kt index d490d4d4..f1288659 100644 --- a/app/src/main/java/com/pilot51/voicenotify/Common.kt +++ b/app/src/main/java/com/pilot51/voicenotify/Common.kt @@ -15,14 +15,19 @@ */ package com.pilot51.voicenotify +import android.app.Activity +import android.content.Context import android.content.Intent +import android.content.pm.ApplicationInfo import android.content.pm.PackageManager import android.os.Build import android.provider.Settings import android.util.Pair +import androidx.activity.ComponentActivity import androidx.compose.runtime.mutableStateListOf import com.pilot51.voicenotify.AppListViewModel.Companion.appDefaultEnable import com.pilot51.voicenotify.VNApplication.Companion.appContext +import kotlinx.coroutines.CoroutineScope import com.pilot51.voicenotify.db.App import com.pilot51.voicenotify.db.AppDatabase import kotlinx.coroutines.Dispatchers @@ -30,6 +35,7 @@ import kotlinx.coroutines.runBlocking import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock + object Common { val notificationListenerSettingsIntent: Intent by lazy { Intent( @@ -78,4 +84,61 @@ object Common { } } } + + fun convertTextReplaceListToString(list: List?>): String { + val saveString = StringBuilder() + for (pair in list) { + if (pair == null) break + if (saveString.isNotEmpty()) { + saveString.append("\n") + } + saveString.append(pair.first) + saveString.append("\n") + saveString.append(pair.second) + } + return saveString.toString() + } + + /** + * Converts a string of paired substrings separated by newlines into a list of string pairs. + * @param string The string to convert. Each string in and between pairs must be separated by a newline. + * There should be an odd number of newlines for an even number of substrings (including zero-length), + * otherwise the last substring will be discarded. + * @return A List of string pairs. + */ + fun convertTextReplaceStringToList(string: String?): List> { + val list = mutableListOf>() + if (string.isNullOrEmpty()) return list + val array = string.split("\n").dropLastWhile { it.isEmpty() }.toTypedArray() + var i = 0 + while (i + 1 < array.size) { + list.add(Pair(array[i], array[i + 1])) + i += 2 + } + return list + } + + fun isSystemApp(applicationInfo: ApplicationInfo): Boolean { + return applicationInfo.flags and ApplicationInfo.FLAG_SYSTEM != 0 + } + + /** + * get application info use queryIntentActivities method + * @param activity + * @return + */ + fun getAppsInfo(context: Context): List { + val pm = context.packageManager + val intent = Intent(Intent.ACTION_MAIN, null) + intent.addCategory(Intent.CATEGORY_LAUNCHER) + val resolveInfos = pm.queryIntentActivities(intent, PackageManager.GET_META_DATA) + val apps: MutableList = ArrayList(0) + for (resolveInfo in resolveInfos) { + // filer system apps + if (isSystemApp(resolveInfo.activityInfo.applicationInfo)) continue + apps.add(resolveInfo.activityInfo.applicationInfo) + } + return apps + } + } diff --git a/app/src/main/java/com/pilot51/voicenotify/MainActivity.kt b/app/src/main/java/com/pilot51/voicenotify/MainActivity.kt index 3a642332..c97850cb 100644 --- a/app/src/main/java/com/pilot51/voicenotify/MainActivity.kt +++ b/app/src/main/java/com/pilot51/voicenotify/MainActivity.kt @@ -15,27 +15,48 @@ */ package com.pilot51.voicenotify + +import android.os.Build import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.viewModels import androidx.annotation.StringRes +import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.foundation.background import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material3.* +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.sp +import androidx.core.view.ViewCompat +import androidx.core.view.WindowCompat import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle @@ -48,12 +69,23 @@ import androidx.navigation.compose.rememberNavController import androidx.navigation.navArgument import com.pilot51.voicenotify.db.App import com.pilot51.voicenotify.db.Settings +import com.pilot51.voicenotify.ui.LargeTopAppBar +import com.pilot51.voicenotify.ui.Page +import com.pilot51.voicenotify.ui.SmallTopAppBar +import com.pilot51.voicenotify.ui.theme.VoiceNotifyTheme import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + WindowCompat.setDecorFitsSystemWindows(window, false) + ViewCompat.setOnApplyWindowInsetsListener(window.decorView) { v, insets -> + v.setPadding(0, 0, 0, 0) + insets + } + val vm: PreferencesViewModel by viewModels() lifecycleScope.launch(Dispatchers.IO) { repeatOnLifecycle(Lifecycle.State.STARTED) { @@ -62,12 +94,29 @@ class MainActivity : ComponentActivity() { } } } + setContent { + val isSystemInDarkTheme = isSystemInDarkTheme() + LaunchedEffect(isSystemInDarkTheme) { + setSystemBarAppearance(isSystemInDarkTheme) + } AppTheme { AppMain() } } } + private fun setSystemBarAppearance(isDark: Boolean) { + WindowCompat.getInsetsController(window, window.decorView.rootView).apply { + isAppearanceLightStatusBars = !isDark + isAppearanceLightNavigationBars = !isDark + } + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { + window.statusBarColor = (if (isDark) Color.Transparent else Color.Black.copy(alpha = 0.2f)).toArgb() + } + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + window.navigationBarColor = (if (isDark) Color.Transparent else Color.Black.copy(alpha = 0.2f)).toArgb() + } + } } private enum class Screen(@StringRes val title: Int) { @@ -76,28 +125,18 @@ private enum class Screen(@StringRes val title: Int) { TTS(R.string.tts) } + @Composable fun AppTheme(content: @Composable () -> Unit) { - MaterialTheme( - colorScheme = if (isSystemInDarkTheme()) { - darkColorScheme(primary = Color(0xFF1CB7D5), primaryContainer = Color(0xFF1E4696)) - } else { - lightColorScheme(primary = Color(0xFF2A54A5), primaryContainer = Color(0xFF64F0FF)) - }, - typography = MaterialTheme.typography.copy( - // Increased font size for dialog buttons - labelLarge = TextStyle( - fontFamily = FontFamily.SansSerif, - fontWeight = FontWeight.Medium, - fontSize = 20.sp, - lineHeight = 20.sp, - letterSpacing = 0.1.sp, - ) - ), + VoiceNotifyTheme( + darkTheme = isSystemInDarkTheme(), + dynamicColor = true, content = content ) } + + @OptIn(ExperimentalMaterial3Api::class) @Composable private fun AppBar( @@ -105,9 +144,15 @@ private fun AppBar( configApp: App?, canNavigateBack: Boolean, navigateUp: () -> Unit, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + scrollBehavior: TopAppBarScrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(), + actions: @Composable RowScope.() -> Unit = {} ) { - TopAppBar( + var colors = TopAppBarDefaults.mediumTopAppBarColors( + containerColor = VoiceNotifyTheme.colors.background, + scrolledContainerColor = VoiceNotifyTheme.colors.background, + ) + SmallTopAppBar( title = { Text(configApp?.run { stringResource(R.string.app_overrides, label) @@ -118,23 +163,57 @@ private fun AppBar( if (canNavigateBack) { IconButton(onClick = navigateUp) { Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, + imageVector = Icons.Filled.ArrowBack, contentDescription = stringResource(R.string.back) ) } } }, - actions = { - if (currentScreen == Screen.APP_LIST) { - AppListActions() + actions = actions, + colors = colors + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun MainAppBar( + currentScreen: Screen, + configApp: App?, + canNavigateBack: Boolean, + navigateUp: () -> Unit, + modifier: Modifier = Modifier, + scrollBehavior: TopAppBarScrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() +) { + var colors = TopAppBarDefaults.mediumTopAppBarColors( + containerColor = VoiceNotifyTheme.colors.background, + scrolledContainerColor = VoiceNotifyTheme.colors.background, + ) + LargeTopAppBar( + title = { + Text(configApp?.run { + stringResource(R.string.app_overrides, label) + } ?: stringResource(currentScreen.title)) + }, + modifier = modifier, + navigationIcon = { + if (canNavigateBack) { + IconButton(onClick = navigateUp) { + Icon( + imageVector = Icons.Filled.ArrowBack, + contentDescription = stringResource(R.string.back) + ) + } } }, - colors = TopAppBarDefaults.mediumTopAppBarColors( - containerColor = MaterialTheme.colorScheme.primaryContainer - ) + scrollBehavior = scrollBehavior, + actions = { + + }, + colors = colors ) } +@OptIn(ExperimentalMaterial3Api::class) @Composable fun AppMain( vm: IPreferencesViewModel = viewModel() @@ -155,24 +234,36 @@ fun AppMain( nullable = true } ) - Scaffold( - topBar = { - AppBar( - currentScreen = currentScreen, - configApp = configApp, - canNavigateBack = navController.previousBackStackEntry != null, - navigateUp = navController::navigateUp - ) - } - ) { innerPadding -> - NavHost( - navController = navController, - startDestination = Screen.MAIN.name, - modifier = Modifier.padding(innerPadding) + val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior( + rememberTopAppBarState(), + canScroll = { true }) + NavHost( + navController = navController, + startDestination = Screen.MAIN.name, + enterTransition = { + slideInHorizontally { it } + }, + exitTransition = { slideOutHorizontally { -it } }, + popEnterTransition = { -> EnterTransition.None }, + popExitTransition = { -> ExitTransition.None } + ) { + composable( + route = "${Screen.MAIN.name}?appPkg={appPkg}", + arguments = argsAppPkg ) { - composable( - route = "${Screen.MAIN.name}?appPkg={appPkg}", - arguments = argsAppPkg + Page( + scrollBehavior = scrollBehavior, + topBar = { + MainAppBar( + currentScreen = Screen.valueOf(Screen.MAIN.name), + configApp = configApp, + canNavigateBack = navController.previousBackStackEntry != null, + navigateUp = navController::navigateUp, + modifier = Modifier + .fillMaxWidth(), + scrollBehavior = scrollBehavior + ) + } ) { MainScreen( vm = vm, @@ -183,23 +274,65 @@ fun AppMain( } ) } - composable(route = Screen.APP_LIST.name) { + + } + composable(route = Screen.APP_LIST.name) { + Page( + scrollBehavior = scrollBehavior, + topBar = { + AppBar( + currentScreen = currentScreen, + configApp = configApp, + canNavigateBack = navController.previousBackStackEntry != null, + navigateUp = navController::navigateUp, + modifier = Modifier + .fillMaxWidth(), + scrollBehavior = scrollBehavior, + actions = { + // dropdown menu + + + + + } + ) + } + ) { AppListScreen( onConfigureApp = { app -> navController.navigate("${Screen.MAIN.name}?appPkg=${app.packageName}") } ) } - composable( - route = "${Screen.TTS.name}?appPkg={appPkg}", - arguments = argsAppPkg + } + composable( + route = "${Screen.TTS.name}?appPkg={appPkg}", + arguments = argsAppPkg + ) { + Page( + scrollBehavior = scrollBehavior, + topBar = { + AppBar( + currentScreen = currentScreen, + configApp = configApp, + canNavigateBack = navController.previousBackStackEntry != null, + navigateUp = navController::navigateUp, + modifier = Modifier + .fillMaxWidth(), + scrollBehavior = scrollBehavior + ) + } ) { - TtsConfigScreen(vm) + TtsConfigScreen( + vm = vm + ) } } } } + + @VNPreview @Composable private fun AppPreview() { diff --git a/app/src/main/java/com/pilot51/voicenotify/MainScreen.kt b/app/src/main/java/com/pilot51/voicenotify/MainScreen.kt index 99ff26c5..f84c53c7 100644 --- a/app/src/main/java/com/pilot51/voicenotify/MainScreen.kt +++ b/app/src/main/java/com/pilot51/voicenotify/MainScreen.kt @@ -23,15 +23,25 @@ import android.content.Context import android.content.Intent import android.os.Build import android.widget.Toast -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.gestures.ScrollableDefaults +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.overscroll +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.* import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.lifecycle.Lifecycle @@ -45,10 +55,18 @@ import com.pilot51.voicenotify.db.App import com.pilot51.voicenotify.db.Settings.Companion.DEFAULT_AUDIO_FOCUS import com.pilot51.voicenotify.db.Settings.Companion.DEFAULT_IGNORE_EMPTY import com.pilot51.voicenotify.db.Settings.Companion.DEFAULT_IGNORE_GROUPS +import com.pilot51.voicenotify.ui.Layout +import com.pilot51.voicenotify.ui.ListBox +import com.pilot51.voicenotify.ui.overScrollVertical +import com.pilot51.voicenotify.ui.rememberOverscrollFlingBehavior import kotlinx.coroutines.flow.MutableStateFlow import java.util.* +import com.pilot51.voicenotify.ui.theme.VoiceNotifyTheme + + -@OptIn(ExperimentalPermissionsApi::class) + +@OptIn(ExperimentalPermissionsApi::class, ExperimentalFoundationApi::class) @Composable fun MainScreen( vm: IPreferencesViewModel, @@ -106,6 +124,7 @@ fun MainScreen( var showReadPhoneStateRationale by remember { mutableStateOf(false) } var showPostNotificationRationale by remember { mutableStateOf(false) } val lifeCycleOwner = LocalLifecycleOwner.current + DisposableEffect(lifeCycleOwner) { val observer = LifecycleEventObserver { _, event -> if (event == Lifecycle.Event.ON_CREATE) { @@ -121,178 +140,190 @@ fun MainScreen( } } } - Column( + + var scrollState = rememberScrollState() + Layout( modifier = Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) + .padding(12.dp, 0.dp) + .overScrollVertical() + .verticalScroll(state = scrollState, flingBehavior = rememberOverscrollFlingBehavior { scrollState }) ) { - if (settings.isGlobal) { - PreferenceRowLink( - title = statusTitle, - summary = statusSummary, - onClick = { - if (isRunning) Service.toggleSuspend() - else context.startActivity(statusIntent) - }, - onLongClick = { context.startActivity(statusIntent) } - ) + ListBox( + ) { + if (settings.isGlobal) { + PreferenceRowLink( + title = statusTitle, + summary = statusSummary, + onClick = { + if (isRunning) Service.toggleSuspend() + else context.startActivity(statusIntent) + }, + onLongClick = { context.startActivity(statusIntent) } + ) + PreferenceRowLink( + titleRes = R.string.app_list, + summaryRes = R.string.app_list_summary, + onClick = onClickAppList + ) + } PreferenceRowLink( - titleRes = R.string.app_list, - summaryRes = R.string.app_list_summary, - onClick = onClickAppList + titleRes = R.string.tts, + summaryRes = R.string.tts_summary, + onClick = onClickTtsConfig, + isEnd = true ) } - PreferenceRowLink( - titleRes = R.string.tts, - summaryRes = R.string.tts_summary, - onClick = onClickTtsConfig - ) - PreferenceRowCheckbox( - titleRes = R.string.audio_focus, - summaryResOn = R.string.audio_focus_summary, - initialValue = settingsCombo.audioFocus ?: DEFAULT_AUDIO_FOCUS, - app = configApp, - showRemove = !settings.isGlobal && settings.audioFocus != null, - onRemove = { - vm.save(settings.copy(audioFocus = null)) - } + Spacer(modifier = Modifier.size(16.dp)) + ListBox( ) { - vm.save(settings.copy(audioFocus = it)) - } - if (settings.isGlobal) { + PreferenceRowCheckbox( + titleRes = R.string.audio_focus, + summaryResOn = R.string.audio_focus_summary, + initialValue = settingsCombo.audioFocus ?: DEFAULT_AUDIO_FOCUS, + app = configApp, + showRemove = !settings.isGlobal && settings.audioFocus != null, + onRemove = { + vm.save(settings.copy(audioFocus = null)) + } + ) { + vm.save(settings.copy(audioFocus = it)) + } + if (settings.isGlobal) { + PreferenceRowLink( + titleRes = R.string.shake_to_silence, + summaryRes = R.string.shake_to_silence_summary, + onClick = { showShakeToSilence = true } + ) + } PreferenceRowLink( - titleRes = R.string.shake_to_silence, - summaryRes = R.string.shake_to_silence_summary, - onClick = { showShakeToSilence = true } + titleRes = R.string.require_strings, + summaryRes = R.string.require_strings_summary, + app = configApp, + showRemove = !settings.isGlobal && settings.requireStrings != null, + onRemove = { + vm.save(settings.copy(requireStrings = null)) + }, + onClick = { showRequireText = true } ) - } - PreferenceRowLink( - titleRes = R.string.require_strings, - summaryRes = R.string.require_strings_summary, - app = configApp, - showRemove = !settings.isGlobal && settings.requireStrings != null, - onRemove = { - vm.save(settings.copy(requireStrings = null)) - }, - onClick = { showRequireText = true } - ) - PreferenceRowLink( - titleRes = R.string.ignore_strings, - summaryRes = R.string.ignore_strings_summary, - app = configApp, - showRemove = !settings.isGlobal && settings.ignoreStrings != null, - onRemove = { - vm.save(settings.copy(ignoreStrings = null)) - }, - onClick = { showIgnoreText = true } - ) - PreferenceRowCheckbox( - titleRes = R.string.ignore_empty, - summaryResOn = R.string.ignore_empty_summary_on, - summaryResOff = R.string.ignore_empty_summary_off, - initialValue = settingsCombo.ignoreEmpty ?: DEFAULT_IGNORE_EMPTY, - app = configApp, - showRemove = !settings.isGlobal && settings.ignoreEmpty != null, - onRemove = { - vm.save(settings.copy(ignoreEmpty = null)) + PreferenceRowLink( + titleRes = R.string.ignore_strings, + summaryRes = R.string.ignore_strings_summary, + app = configApp, + showRemove = !settings.isGlobal && settings.ignoreStrings != null, + onRemove = { + vm.save(settings.copy(ignoreStrings = null)) + }, + onClick = { showIgnoreText = true } + ) + PreferenceRowCheckbox( + titleRes = R.string.ignore_empty, + summaryResOn = R.string.ignore_empty_summary_on, + summaryResOff = R.string.ignore_empty_summary_off, + initialValue = settingsCombo.ignoreEmpty ?: DEFAULT_IGNORE_EMPTY, + app = configApp, + showRemove = !settings.isGlobal && settings.ignoreEmpty != null, + onRemove = { + vm.save(settings.copy(ignoreEmpty = null)) + } + ) { + vm.save(settings.copy(ignoreEmpty = it)) } - ) { - vm.save(settings.copy(ignoreEmpty = it)) - } - PreferenceRowCheckbox( - titleRes = R.string.ignore_groups, - summaryResOn = R.string.ignore_groups_summary_on, - summaryResOff = R.string.ignore_groups_summary_off, - initialValue = settingsCombo.ignoreGroups ?: DEFAULT_IGNORE_GROUPS, - app = configApp, - showRemove = !settings.isGlobal && settings.ignoreGroups != null, - onRemove = { - vm.save(settings.copy(ignoreGroups = null)) + PreferenceRowCheckbox( + titleRes = R.string.ignore_groups, + summaryResOn = R.string.ignore_groups_summary_on, + summaryResOff = R.string.ignore_groups_summary_off, + initialValue = settingsCombo.ignoreGroups ?: DEFAULT_IGNORE_GROUPS, + app = configApp, + showRemove = !settings.isGlobal && settings.ignoreGroups != null, + onRemove = { + vm.save(settings.copy(ignoreGroups = null)) + } + ) { + vm.save(settings.copy(ignoreGroups = it)) } - ) { - vm.save(settings.copy(ignoreGroups = it)) - } - PreferenceRowLink( - titleRes = R.string.ignore_repeat, - summaryRes = R.string.ignore_repeat_summary, - app = configApp, - showRemove = !settings.isGlobal && settings.ignoreRepeat != null, - onRemove = { - vm.save(settings.copy(ignoreRepeat = null)) - }, - onClick = { showIgnoreRepeats = true } - ) - PreferenceRowLink( - titleRes = R.string.device_state, - summaryRes = R.string.device_state_summary, - app = configApp, - showRemove = !settings.isGlobal && settings.run { - speakScreenOff != null || - speakScreenOn != null || - speakHeadsetOff != null || - speakHeadsetOn != null || - speakSilentOn != null - }, - onRemove = { - vm.save(settings.copy( - speakScreenOff = null, - speakScreenOn = null, - speakHeadsetOff = null, - speakHeadsetOn = null, - speakSilentOn = null - )) - }, - onClick = { showDeviceStates = true } - ) - PreferenceRowLink( - titleRes = R.string.quiet_start, - summaryRes = R.string.quiet_start_summary, - app = configApp, - showRemove = !settings.isGlobal && settings.quietStart != null, - onRemove = { - vm.save(settings.copy(quietStart = null)) - }, - onClick = { showQuietTimeStart = true } - ) - PreferenceRowLink( - titleRes = R.string.quiet_end, - summaryRes = R.string.quiet_end_summary, - app = configApp, - showRemove = !settings.isGlobal && settings.quietEnd != null, - onRemove = { - vm.save(settings.copy(quietEnd = null)) - }, - onClick = { showQuietTimeEnd = true } - ) - if (settings.isGlobal) { PreferenceRowLink( - titleRes = R.string.test, - summaryRes = R.string.test_summary, - onClick = { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || - postNotificationPermissionState!!.requestPermission { - showPostNotificationRationale = true - } - ) { - runTestNotification(context) - } - } + titleRes = R.string.ignore_repeat, + summaryRes = R.string.ignore_repeat_summary, + app = configApp, + showRemove = !settings.isGlobal && settings.ignoreRepeat != null, + onRemove = { + vm.save(settings.copy(ignoreRepeat = null)) + }, + onClick = { showIgnoreRepeats = true } ) PreferenceRowLink( - titleRes = R.string.notify_log, - summary = stringResource(R.string.notify_log_summary, NotifyList.HISTORY_LIMIT), - onClick = { showLog = true } + titleRes = R.string.device_state, + summaryRes = R.string.device_state_summary, + app = configApp, + showRemove = !settings.isGlobal && settings.run { + speakScreenOff != null || + speakScreenOn != null || + speakHeadsetOff != null || + speakHeadsetOn != null || + speakSilentOn != null + }, + onRemove = { + vm.save(settings.copy( + speakScreenOff = null, + speakScreenOn = null, + speakHeadsetOff = null, + speakHeadsetOn = null, + speakSilentOn = null + )) + }, + onClick = { showDeviceStates = true } ) PreferenceRowLink( - titleRes = R.string.backup_restore, - summaryRes = R.string.backup_restore_summary, - onClick = { showBackupRestore = true } + titleRes = R.string.quiet_start, + summaryRes = R.string.quiet_start_summary, + app = configApp, + showRemove = !settings.isGlobal && settings.quietStart != null, + onRemove = { + vm.save(settings.copy(quietStart = null)) + }, + onClick = { showQuietTimeStart = true } ) PreferenceRowLink( - titleRes = R.string.support, - summaryRes = R.string.support_summary, - onClick = { showSupport = true } + titleRes = R.string.quiet_end, + summaryRes = R.string.quiet_end_summary, + app = configApp, + showRemove = !settings.isGlobal && settings.quietEnd != null, + onRemove = { + vm.save(settings.copy(quietEnd = null)) + }, + onClick = { showQuietTimeEnd = true } ) + if (settings.isGlobal) { + PreferenceRowLink( + titleRes = R.string.test, + summaryRes = R.string.test_summary, + onClick = { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || + postNotificationPermissionState!!.requestPermission { + showPostNotificationRationale = true + } + ) { + runTestNotification(context) + } + } + ) + PreferenceRowLink( + titleRes = R.string.notify_log, + summary = stringResource(R.string.notify_log_summary, NotifyList.HISTORY_LIMIT), + onClick = { showLog = true } + ) + PreferenceRowLink( + titleRes = R.string.backup_restore, + summaryRes = R.string.backup_restore_summary, + onClick = { showBackupRestore = true } + ) + PreferenceRowLink( + titleRes = R.string.support, + summaryRes = R.string.support_summary, + onClick = { showSupport = true }, + isEnd = true + ) + } } } if (showShakeToSilence) { @@ -347,6 +378,7 @@ fun MainScreen( } } + private const val NOTIFICATION_CHANNEL_ID = "test" private fun runTestNotification(context: Context) { diff --git a/app/src/main/java/com/pilot51/voicenotify/PreferenceDialogs.kt b/app/src/main/java/com/pilot51/voicenotify/PreferenceDialogs.kt index fd6e3ddd..845732fa 100644 --- a/app/src/main/java/com/pilot51/voicenotify/PreferenceDialogs.kt +++ b/app/src/main/java/com/pilot51/voicenotify/PreferenceDialogs.kt @@ -36,7 +36,9 @@ import androidx.compose.foundation.selection.toggleable import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material3.* +import androidx.compose.material3.TimePickerState import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.mapSaver import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -517,17 +519,49 @@ private fun rememberTimePickerState( initialMinute: Int = 0, is24Hour: Boolean = DateFormat.is24HourFormat(LocalContext.current), key: Any -): TimePickerState = rememberSaveable( - saver = TimePickerState.Saver(), - inputs = arrayOf(key) -) { - TimePickerState( - initialHour = initialHour, - initialMinute = initialMinute, - is24Hour = is24Hour, +): TimePickerState { + return rememberSaveable( + saver = timePickerStateSaver, + inputs = arrayOf(key) + ) { + TimePickerState( + initialHour = initialHour, + initialMinute = initialMinute, + is24Hour = is24Hour, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +val timePickerStateSaver = run { + val hourKey = "hour" + val minuteKey = "minute" + val is24HourKey = "is24Hour" + + mapSaver( + save = { state -> + mapOf( + hourKey to state.hour, + minuteKey to state.minute, + is24HourKey to state.is24hour + ) + }, + restore = { map -> + TimePickerState( + initialHour = map[hourKey] as Int, + initialMinute = map[minuteKey] as Int, + is24Hour = map[is24HourKey] as Boolean + ) + } ) } +data class TimePickerState( + val hour: Int, + val minute: Int, + val is24Hour: Boolean +) + @Composable fun BackupDialog(onDismiss: () -> Unit) { val exportBackupLauncher = rememberLauncherForActivityResult( diff --git a/app/src/main/java/com/pilot51/voicenotify/PreferenceRows.kt b/app/src/main/java/com/pilot51/voicenotify/PreferenceRows.kt index 7824aac2..f188400c 100644 --- a/app/src/main/java/com/pilot51/voicenotify/PreferenceRows.kt +++ b/app/src/main/java/com/pilot51/voicenotify/PreferenceRows.kt @@ -17,20 +17,33 @@ package com.pilot51.voicenotify import androidx.annotation.StringRes import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.selection.toggleable import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Cancel import androidx.compose.material3.* -import androidx.compose.runtime.* +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp +import com.pilot51.voicenotify.ui.theme.VoiceNotifyTheme import com.pilot51.voicenotify.db.App +import com.pilot51.voicenotify.ui.Switch +import com.pilot51.voicenotify.ui.addIf +import com.pilot51.voicenotify.ui.bottomBorder +import com.pilot51.voicenotify.ui.ListItem // Simplified and heavily modified from https://github.com/alorma/Compose-Settings @@ -41,19 +54,20 @@ import com.pilot51.voicenotify.db.App @OptIn(ExperimentalFoundationApi::class) @Composable fun PreferenceRowLink( - @StringRes titleRes: Int = 0, - @StringRes summaryRes: Int = 0, - title: String = titleRes.takeUnless { it == 0 }?.let { stringResource(it) }!!, - summary: String = summaryRes.takeUnless { it == 0 }?.let { stringResource(it) }!!, - enabled: Boolean = true, - app: App? = null, - showRemove: Boolean = false, - onRemove: (() -> Unit)? = null, - onClick: () -> Unit, - onLongClick: (() -> Unit)? = null + @StringRes titleRes: Int = 0, + @StringRes summaryRes: Int = 0, + title: String = titleRes.takeUnless { it == 0 }?.let { stringResource(it) }!!, + summary: String = summaryRes.takeUnless { it == 0 }?.let { stringResource(it) }!!, + enabled: Boolean = true, + app: App? = null, + showRemove: Boolean = false, + onRemove: (() -> Unit)? = null, + onClick: () -> Unit, + onLongClick: (() -> Unit)? = null, + isEnd: Boolean = false ) { - Row( - modifier = Modifier + Row( + modifier = Modifier .fillMaxWidth() .height(IntrinsicSize.Min) .combinedClickable( @@ -61,17 +75,18 @@ fun PreferenceRowLink( onClick = onClick, onLongClick = onLongClick ), - verticalAlignment = Alignment.CenterVertically - ) { - PreferenceRowScaffold( - title = title, - enabled = enabled, - summary = summary, - app = app, - showRemove = showRemove, - onRemove = onRemove, - ) - } + verticalAlignment = Alignment.CenterVertically + ) { + PreferenceRowScaffold( + title = title, + enabled = enabled, + summary = summary, + app = app, + showRemove = showRemove, + onRemove = onRemove, + isEnd = isEnd + ) + } } /** @@ -80,19 +95,20 @@ fun PreferenceRowLink( */ @Composable fun PreferenceRowCheckbox( - @StringRes titleRes: Int, - @StringRes summaryResOn: Int, - @StringRes summaryResOff: Int = summaryResOn, - initialValue: Boolean, - app: App? = null, - showRemove: Boolean = false, - onRemove: (() -> Unit)? = null, - onChange: (Boolean) -> Unit + @StringRes titleRes: Int, + @StringRes summaryResOn: Int, + @StringRes summaryResOff: Int = summaryResOn, + initialValue: Boolean, + app: App? = null, + showRemove: Boolean = false, + isEnd: Boolean = false, + onRemove: (() -> Unit)? = null, + onChange: (Boolean) -> Unit ) { - var prefValue by remember(initialValue) { mutableStateOf(initialValue) } - val summaryRes = if (prefValue) summaryResOn else summaryResOff - Row( - modifier = Modifier + var prefValue by remember(initialValue) { mutableStateOf(initialValue) } + val summaryRes = if (prefValue) summaryResOn else summaryResOff + Row( + modifier = Modifier .fillMaxWidth() .height(IntrinsicSize.Min) .toggleable( @@ -103,118 +119,135 @@ fun PreferenceRowCheckbox( onChange(prefValue) } ), - verticalAlignment = Alignment.CenterVertically - ) { - PreferenceRowScaffold( - title = stringResource(titleRes), - summary = stringResource(summaryRes), - app = app, - showRemove = showRemove, - onRemove = onRemove, - action = { - Checkbox( - checked = prefValue, - onCheckedChange = { - prefValue = it - onChange(it) - } - ) - } - ) - } + verticalAlignment = Alignment.CenterVertically + ) { + PreferenceRowScaffold( + title = stringResource(titleRes), + summary = stringResource(summaryRes), + app = app, + showRemove = showRemove, + onRemove = onRemove, + action = { + Switch( + checked = prefValue, + onCheckedChange = { + prefValue = it + onChange(it) + } + ) + }, + isEnd = isEnd + ) + } } @Composable private fun PreferenceRowScaffold( - enabled: Boolean = true, - title: String, - summary: String, - app: App? = null, - showRemove: Boolean = false, - onRemove: (() -> Unit)? = null, - action: (@Composable (Boolean) -> Unit)? = null + enabled: Boolean = true, + title: String, + summary: String, + app: App? = null, + showRemove: Boolean = false, + onRemove: (() -> Unit)? = null, + action: (@Composable (Boolean) -> Unit)? = null, + isEnd: Boolean ) { - var showRemoveDialog by remember { mutableStateOf(false) } - ListItem( - modifier = Modifier.defaultMinSize(minHeight = 88.dp), - headlineContent = { - ColorWrap(enabled) { - Text(title) - } - }, - supportingContent = { - ColorWrap(enabled) { - Text(summary) - } - }, - trailingContent = { - Row( - modifier = Modifier.fillMaxHeight(), - verticalAlignment = Alignment.CenterVertically - ) { - if (showRemove) { - IconButton(onClick = { showRemoveDialog = true }) { - Icon( - imageVector = Icons.Outlined.Cancel, - contentDescription = stringResource(R.string.remove_override) - ) - } - } - action?.invoke(enabled) - } - } - ) - if (showRemoveDialog && app != null) { - ConfirmDialog( - text = stringResource(R.string.remove_override_confirm, title, app.label), - onConfirm = onRemove!!, - onDismiss = { showRemoveDialog = false } - ) - } + var showRemoveDialog by remember { mutableStateOf(false) } + Column( + ) { + ListItem( + modifier = Modifier + .defaultMinSize(minHeight = 88.dp), + colors = ListItemDefaults.colors( + containerColor = VoiceNotifyTheme.colors.boxItem + ), + headlineContent = { + ColorWrap(enabled) { + Text(title) + } + }, + supportingContent = { + ColorWrap(enabled) { + Text(summary) + } + }, + trailingContent = { + Row( + modifier = Modifier.fillMaxHeight(), + verticalAlignment = Alignment.CenterVertically + ) { + if (showRemove) { + IconButton(onClick = { showRemoveDialog = true }) { + Icon( + imageVector = Icons.Outlined.Cancel, + contentDescription = stringResource(R.string.remove_override) + ) + } + } + action?.invoke(enabled) + } + } + ) + Box( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .addIf(!isEnd) { + Modifier.bottomBorder(2f, VoiceNotifyTheme.colors.divider) + } + ) + } + if (showRemoveDialog && app != null) { + ConfirmDialog( + text = stringResource(R.string.remove_override_confirm, title, app.label), + onConfirm = onRemove!!, + onDismiss = { showRemoveDialog = false } + ) + } } @Composable private fun ColorWrap( - enabled: Boolean, - content: @Composable () -> Unit + enabled: Boolean, + content: @Composable () -> Unit ) { - val color = LocalContentColor.current.let { - if (enabled) it else it.copy(alpha = 0.6f) - } - CompositionLocalProvider(LocalContentColor provides color) { - content() - } + val color = LocalContentColor.current.let { + if (enabled) it else it.copy(alpha = 0.6f) + } + CompositionLocalProvider(LocalContentColor provides color) { + content() + } } @VNPreview @Composable private fun PreferenceRowLinkPreview( - @PreviewParameter(BooleanProvider::class) showRemove: Boolean + @PreviewParameter(BooleanProvider::class) showRemove: Boolean ) { - AppTheme { - PreferenceRowLink( - titleRes = R.string.tts_settings, - summaryRes = R.string.tts_settings_summary_fail, - enabled = false, - showRemove = showRemove, - onClick = {} - ) - } + AppTheme { + PreferenceRowLink( + titleRes = R.string.tts_settings, + summaryRes = R.string.tts_settings_summary_fail, + enabled = false, + showRemove = showRemove, + onClick = {} + ) + } } @VNPreview @Composable private fun PreferenceRowCheckboxPreview( - @PreviewParameter(BooleanProvider::class) value: Boolean + @PreviewParameter(BooleanProvider::class) value: Boolean ) { - AppTheme { - PreferenceRowCheckbox( - titleRes = R.string.ignore_groups, - summaryResOn = R.string.ignore_groups_summary_on, - summaryResOff = R.string.ignore_groups_summary_off, - initialValue = value, - showRemove = value, - onChange = {} - ) - } + AppTheme { + PreferenceRowCheckbox( + titleRes = R.string.ignore_groups, + summaryResOn = R.string.ignore_groups_summary_on, + summaryResOff = R.string.ignore_groups_summary_off, + initialValue = value, + showRemove = value, + onChange = {} + ) + } } diff --git a/app/src/main/java/com/pilot51/voicenotify/TtsConfigScreen.kt b/app/src/main/java/com/pilot51/voicenotify/TtsConfigScreen.kt index d4413184..6e10d98c 100644 --- a/app/src/main/java/com/pilot51/voicenotify/TtsConfigScreen.kt +++ b/app/src/main/java/com/pilot51/voicenotify/TtsConfigScreen.kt @@ -16,130 +16,161 @@ package com.pilot51.voicenotify import android.content.Intent +import android.content.res.Configuration +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.overscroll +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.* import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.pilot51.voicenotify.ui.Layout +import com.pilot51.voicenotify.ui.ListBox +import com.pilot51.voicenotify.ui.overScrollVertical +import com.pilot51.voicenotify.ui.rememberOverscrollFlingBehavior +import com.pilot51.voicenotify.ui.theme.VoiceNotifyTheme @Composable -fun TtsConfigScreen(vm: IPreferencesViewModel) { - val context = LocalContext.current - val configApp by vm.configuringAppState.collectAsState() - val settings by vm.configuringSettingsState.collectAsState() - var ttsEnabled by remember { mutableStateOf(true) } - var ttsSummary by remember { mutableStateOf("") } - var ttsIntent by remember { mutableStateOf(null) } - Intent("com.android.settings.TTS_SETTINGS").let { - if (it.resolveActivity(context.packageManager) != null) { - ttsIntent = it - ttsSummary = stringResource(R.string.tts_settings_summary) - } else { - ttsEnabled = false - ttsSummary = stringResource(R.string.tts_settings_summary_fail) - } - } - var showTtsMessage by remember { mutableStateOf(false) } - var showTextReplaceDialog by remember { mutableStateOf(false) } - var showMaxMessage by remember { mutableStateOf(false) } - var showTtsStream by remember { mutableStateOf(false) } - var showTtsDelay by remember { mutableStateOf(false) } - var showTtsRepeat by remember { mutableStateOf(false) } - Column(modifier = Modifier.fillMaxSize()) { - if (settings.isGlobal) { - PreferenceRowLink( - titleRes = R.string.tts_settings, - summary = ttsSummary, - enabled = ttsEnabled, - onClick = { ttsIntent?.let { context.startActivity(it) } } - ) - } - PreferenceRowLink( - titleRes = R.string.tts_message, - summaryRes = R.string.tts_message_summary, - app = configApp, - showRemove = !settings.isGlobal && settings.ttsString != null, - onRemove = { - vm.save(settings.copy(ttsString = null)) - }, - onClick = { showTtsMessage = true } - ) - PreferenceRowLink( - titleRes = R.string.tts_text_replace, - summaryRes = R.string.tts_text_replace_summary, - app = configApp, - showRemove = !settings.isGlobal && settings.ttsTextReplace != null, - onRemove = { - vm.save(settings.copy(ttsTextReplace = null)) - }, - onClick = { showTextReplaceDialog = true } - ) - PreferenceRowLink( - titleRes = R.string.max_length, - summaryRes = R.string.max_length_summary, - app = configApp, - showRemove = !settings.isGlobal && settings.ttsMaxLength != null, - onRemove = { - vm.save(settings.copy(ttsMaxLength = null)) - }, - onClick = { showMaxMessage = true } - ) - PreferenceRowLink( - titleRes = R.string.tts_stream, - summaryRes = R.string.tts_stream_summary, - app = configApp, - showRemove = !settings.isGlobal && settings.ttsStream != null, - onRemove = { - vm.save(settings.copy(ttsStream = null)) - }, - onClick = { showTtsStream = true } - ) - PreferenceRowLink( - titleRes = R.string.tts_delay, - summaryRes = R.string.tts_delay_summary, - app = configApp, - showRemove = !settings.isGlobal && settings.ttsDelay != null, - onRemove = { - vm.save(settings.copy(ttsDelay = null)) - }, - onClick = { showTtsDelay = true } - ) - PreferenceRowLink( - titleRes = R.string.tts_repeat, - summaryRes = R.string.tts_repeat_summary, - app = configApp, - showRemove = !settings.isGlobal && settings.ttsRepeat != null, - onRemove = { - vm.save(settings.copy(ttsRepeat = null)) - }, - onClick = { showTtsRepeat = true } - ) - } - if (showTtsMessage) { - TtsMessageDialog(vm) { showTtsMessage = false } - } - if (showTextReplaceDialog) { - TextReplaceDialog(vm) { showTextReplaceDialog = false } - } - if (showMaxMessage) { - TtsMaxLengthDialog(vm) { showMaxMessage = false } - } - if (showTtsStream) { - TtsStreamDialog(vm) { showTtsStream = false } - } - if (showTtsDelay) { - TtsDelayDialog(vm) { showTtsDelay = false } - } - if (showTtsRepeat) { - TtsRepeatDialog(vm) { showTtsRepeat = false } - } +fun TtsConfigScreen( + vm: IPreferencesViewModel +) { + val context = LocalContext.current + val configApp by vm.configuringAppState.collectAsState() + val settings by vm.configuringSettingsState.collectAsState() + var ttsEnabled by remember { mutableStateOf(true) } + var ttsSummary by remember { mutableStateOf("") } + var ttsIntent by remember { mutableStateOf(null) } + Intent("com.android.settings.TTS_SETTINGS").let { + if (it.resolveActivity(context.packageManager) != null) { + ttsIntent = it + ttsSummary = stringResource(R.string.tts_settings_summary) + } else { + ttsEnabled = false + ttsSummary = stringResource(R.string.tts_settings_summary_fail) + } + } + var showTtsMessage by remember { mutableStateOf(false) } + var showTextReplaceDialog by remember { mutableStateOf(false) } + var showMaxMessage by remember { mutableStateOf(false) } + var showTtsStream by remember { mutableStateOf(false) } + var showTtsDelay by remember { mutableStateOf(false) } + var showTtsRepeat by remember { mutableStateOf(false) } + var scrollState = rememberScrollState() + Layout( + modifier = Modifier + .padding(12.dp, 0.dp) + .fillMaxSize() + .overScrollVertical() + .verticalScroll( + state = scrollState, + flingBehavior = rememberOverscrollFlingBehavior { scrollState }) + ) { + ListBox( + modifier = Modifier + ) { + if (settings.isGlobal) { + PreferenceRowLink( + titleRes = R.string.tts_settings, + summary = ttsSummary, + enabled = ttsEnabled, + onClick = { ttsIntent?.let { context.startActivity(it) } } + ) + } + PreferenceRowLink( + titleRes = R.string.tts_message, + summaryRes = R.string.tts_message_summary, + app = configApp, + showRemove = !settings.isGlobal && settings.ttsString != null, + onRemove = { + vm.save(settings.copy(ttsString = null)) + }, + onClick = { showTtsMessage = true } + ) + PreferenceRowLink( + titleRes = R.string.tts_text_replace, + summaryRes = R.string.tts_text_replace_summary, + app = configApp, + showRemove = !settings.isGlobal && settings.ttsTextReplace != null, + onRemove = { + vm.save(settings.copy(ttsTextReplace = null)) + }, + onClick = { showTextReplaceDialog = true } + ) + PreferenceRowLink( + titleRes = R.string.max_length, + summaryRes = R.string.max_length_summary, + app = configApp, + showRemove = !settings.isGlobal && settings.ttsMaxLength != null, + onRemove = { + vm.save(settings.copy(ttsMaxLength = null)) + }, + onClick = { showMaxMessage = true } + ) + PreferenceRowLink( + titleRes = R.string.tts_stream, + summaryRes = R.string.tts_stream_summary, + app = configApp, + showRemove = !settings.isGlobal && settings.ttsStream != null, + onRemove = { + vm.save(settings.copy(ttsStream = null)) + }, + onClick = { showTtsStream = true } + ) + PreferenceRowLink( + titleRes = R.string.tts_delay, + summaryRes = R.string.tts_delay_summary, + app = configApp, + showRemove = !settings.isGlobal && settings.ttsDelay != null, + onRemove = { + vm.save(settings.copy(ttsDelay = null)) + }, + onClick = { showTtsDelay = true } + ) + PreferenceRowLink( + titleRes = R.string.tts_repeat, + summaryRes = R.string.tts_repeat_summary, + app = configApp, + showRemove = !settings.isGlobal && settings.ttsRepeat != null, + onRemove = { + vm.save(settings.copy(ttsRepeat = null)) + }, + onClick = { showTtsRepeat = true } + ) + } + } + if (showTtsMessage) { + TtsMessageDialog(vm) { showTtsMessage = false } + } + if (showTextReplaceDialog) { + TextReplaceDialog(vm) { showTextReplaceDialog = false } + } + if (showMaxMessage) { + TtsMaxLengthDialog(vm) { showMaxMessage = false } + } + if (showTtsStream) { + TtsStreamDialog(vm) { showTtsStream = false } + } + if (showTtsDelay) { + TtsDelayDialog(vm) { showTtsDelay = false } + } + if (showTtsRepeat) { + TtsRepeatDialog(vm) { showTtsRepeat = false } + } } @VNPreview @Composable private fun TtsConfigScreenPreview() { - AppTheme { - TtsConfigScreen(PreferencesPreviewVM) - } + AppTheme { + TtsConfigScreen(PreferencesPreviewVM) + } } diff --git a/app/src/main/java/com/pilot51/voicenotify/Utils.kt b/app/src/main/java/com/pilot51/voicenotify/Utils.kt index 6ff3b1dd..ef5f8217 100644 --- a/app/src/main/java/com/pilot51/voicenotify/Utils.kt +++ b/app/src/main/java/com/pilot51/voicenotify/Utils.kt @@ -16,10 +16,17 @@ package com.pilot51.voicenotify import android.content.res.Configuration +import android.icu.text.AlphabeticIndex +import android.icu.text.Collator +import android.os.Build +import android.os.LocaleList +import androidx.annotation.RequiresApi import androidx.compose.runtime.Composable +import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import java.util.Locale fun T.isAny(vararg list: T) = list.any { this == it } @@ -32,3 +39,78 @@ annotation class VNPreview class BooleanProvider: PreviewParameterProvider { override val values: Sequence = sequenceOf(false, true) } + + +/** + * Help to get a alphabetic of a char. + * + * Use: + * ``` + * val sectionName = AlphabeticIndexHelper.computeSectionName("食神") + * log: sectionName = "S" + * ``` + */ +object AlphabeticIndexHelper { + + @JvmStatic + fun computeSectionName(c: CharSequence): String { + if (isStartsWithDigit(c)) return c.toString() + return computeSectionName(Locale.getDefault(), c) + } + + + @JvmStatic + fun computeCNSectionName(c: CharSequence): String = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + computeSectionName( + LocaleList(Locale.CHINESE, Locale.SIMPLIFIED_CHINESE), c + ) + } else { + TODO("VERSION.SDK_INT < N") + } + + @JvmStatic + fun computeSectionName(locale: Locale, c: CharSequence): String { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + AlphabeticIndex(locale).buildImmutableIndex().let { + it.getBucket(it.getBucketIndex(c)).label + } + } else { + TODO("VERSION.SDK_INT < N") + } + } + + + @JvmStatic + fun computeSectionName(localeList: LocaleList, c: CharSequence): String { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + val primaryLocale = if (localeList.isEmpty) Locale.ENGLISH else localeList[0] + val ai = AlphabeticIndex(primaryLocale) + for (index in 1 until localeList.size()) { + ai.addLabels(localeList[index]) + } + ai.buildImmutableIndex().let { + it.getBucket(it.getBucketIndex(c)).label + } + } else { + TODO("VERSION.SDK_INT < N") + } + } + // write CompareCollator wrap computeSectionName + @RequiresApi(Build.VERSION_CODES.N) + fun computeCompareCollator(c: CharSequence): String { + val collator = Collator.getInstance(Locale.getDefault()) + return collator.getCollationKey(c.toString()).sourceString + } + + + @JvmStatic + fun isStartsWithDigit(c: CharSequence): Boolean = Character.isDigit(c[0]) + + + fun getAlphabeticIndex(locale: Locale): AlphabeticIndex = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + AlphabeticIndex(locale) + } else { + TODO("VERSION.SDK_INT < N") + } +} + diff --git a/app/src/main/java/com/pilot51/voicenotify/db/App.kt b/app/src/main/java/com/pilot51/voicenotify/db/App.kt index 17d21f23..6b4452f5 100644 --- a/app/src/main/java/com/pilot51/voicenotify/db/App.kt +++ b/app/src/main/java/com/pilot51/voicenotify/db/App.kt @@ -16,10 +16,15 @@ package com.pilot51.voicenotify.db import android.content.Context +import android.os.Build +import android.provider.BaseColumns import android.widget.Toast +import androidx.annotation.RequiresApi +import androidx.compose.ui.graphics.ImageBitmap import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.Ignore +import androidx.room.MapColumn import androidx.room.PrimaryKey import com.pilot51.voicenotify.R import com.pilot51.voicenotify.db.AppDatabase.Companion.db @@ -42,6 +47,8 @@ data class App( get() = isEnabled!! set(value) { isEnabled = value } + + /** * Updates self in database. * @return This instance. diff --git a/app/src/main/java/com/pilot51/voicenotify/ui/Layout.kt b/app/src/main/java/com/pilot51/voicenotify/ui/Layout.kt new file mode 100644 index 00000000..f204b82b --- /dev/null +++ b/app/src/main/java/com/pilot51/voicenotify/ui/Layout.kt @@ -0,0 +1,31 @@ +package com.pilot51.voicenotify.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.pilot51.voicenotify.ui.theme.VoiceNotifyTheme + + +/** + * A layout with padding and background. + */ +@Composable +fun Layout( + modifier: Modifier = Modifier.padding(12.dp, 0.dp), + content: @Composable () -> Unit +) { + Column( + modifier = modifier + .fillMaxSize() + .background(VoiceNotifyTheme.colors.background) + ) { + content() + } +} + + + diff --git a/app/src/main/java/com/pilot51/voicenotify/ui/LazyAlphabetIndexColumn.kt b/app/src/main/java/com/pilot51/voicenotify/ui/LazyAlphabetIndexColumn.kt new file mode 100644 index 00000000..1e232dce --- /dev/null +++ b/app/src/main/java/com/pilot51/voicenotify/ui/LazyAlphabetIndexColumn.kt @@ -0,0 +1,534 @@ +package com.pilot51.voicenotify.ui + +import android.os.Build +import android.os.VibrationEffect +import android.os.VibratorManager +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectVerticalDragGestures +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Surface +import androidx.compose.material3.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.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInRoot +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.pilot51.voicenotify.AppTheme +import com.pilot51.voicenotify.ui.theme.VoiceNotifyTheme +import kotlinx.collections.immutable.ImmutableMap +import kotlinx.collections.immutable.toImmutableList +import kotlinx.collections.immutable.toPersistentMap +import kotlinx.coroutines.launch + + +val vibrationCooldown = 50 // Cooldown time in milliseconds +val alphabetItemSize = 20.dp + +val alphabetCharList = (listOf('↑','#') + ('A'..'Z')) + + +val alphabetCharSize = alphabetCharList.size + + +internal fun Float.getIndexOfCharBasedOnYPosition( + alphabetHeightInPixels: Float, +): Char { + + var index = ((this) / alphabetHeightInPixels).toInt() + index = when { + index > alphabetCharSize -> alphabetCharSize + index < 0 -> 0 + else -> index + } + return alphabetCharList[index] +} + + + +fun List.getFirstUniqueSeenCharIndex(callback: (T) -> String): ImmutableMap { + val firstLetterIndexes = mutableMapOf() + this + .map { + // if is number return # + val firstLetter = callback(it).uppercase().first() + if (firstLetter.isDigit()) { + '#' + } else { + firstLetter + } + } + .forEachIndexed { index, char -> + if (!firstLetterIndexes.contains(char)) { + firstLetterIndexes[char] = index + } + // else don't care about letters that don't exist + } + return firstLetterIndexes.toPersistentMap() +} + + +@Composable +fun LazyAlphabetIndexColumn( + items: List, + keySelector: (item: T) -> String = keySelector@{ it.toString() }, + onAlphabetListDrag: (Float?, Float, Char?) -> Unit, + lazyListState: LazyListState = rememberLazyListState(), + vibratorEnabled: Boolean = false, + alphabetModifier: Modifier = Modifier, + alphabetPaddingValues: PaddingValues = PaddingValues(0.dp, 0.dp, 0.dp, 0.dp), + content: LazyListScope.(firstLetterIndexes: ImmutableMap) -> Unit +) { + val mapOfFirstLetterIndex: ImmutableMap = + remember(items) { items.getFirstUniqueSeenCharIndex(keySelector) } + val coroutineScope = rememberCoroutineScope() + + Row( + modifier = Modifier.fillMaxSize(), + verticalAlignment = Alignment.CenterVertically, + ) { + AlphabetLazyColumn( + Modifier + .fillMaxHeight() + .weight(1f), + lazyListState, + mapOfFirstLetterIndex + ) { firstLetterIndexes -> + content(firstLetterIndexes) + } + AlphabetScroller( + modifier = alphabetModifier + .align(Alignment.CenterVertically), + vibratorEnabled = vibratorEnabled, + alphabetPaddingValues = alphabetPaddingValues, + onAlphabetListDrag = { relativeDragYOffset, containerDistanceFromTopOfScreen, indexOfChar -> + onAlphabetListDrag(relativeDragYOffset, containerDistanceFromTopOfScreen, indexOfChar) + coroutineScope.launch { + if (indexOfChar == null) { + return@launch + } + val index = mapOfFirstLetterIndex[indexOfChar] + // '↑' is a special case where it should scroll to the top + if (indexOfChar == '↑') { + lazyListState.scrollToItem(0) + } else if (index != null) { + lazyListState.scrollToItem(index) + } + } + }, + ) + } +} + + +@Composable +fun LazyAlphabetIndexRow( + items: List, + keySelector: (item: T) -> String = keySelector@{ it.toString() }, + onAlphabetListDrag: (Float?, Float, Char?) -> Unit, + lazyListState: LazyListState = rememberLazyListState(), + vibratorEnabled: Boolean = false, + alphabetModifier: Modifier = Modifier, + alphabetPaddingValues: PaddingValues = PaddingValues(0.dp, 0.dp, 0.dp, 0.dp), + content: @Composable (firstLetterIndexes: ImmutableMap) -> Unit +) { + val mapOfFirstLetterIndex: ImmutableMap = + remember(items) { items.getFirstUniqueSeenCharIndex(keySelector) } + val coroutineScope = rememberCoroutineScope() + + Row( + modifier = Modifier.fillMaxSize(), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .fillMaxSize() + .weight(1f), + ) { + content(mapOfFirstLetterIndex) + } + AlphabetScroller( + modifier = alphabetModifier + .align(Alignment.CenterVertically), + vibratorEnabled = vibratorEnabled, + alphabetPaddingValues = alphabetPaddingValues, + onAlphabetListDrag = { relativeDragYOffset, containerDistanceFromTopOfScreen, indexOfChar -> + onAlphabetListDrag(relativeDragYOffset, containerDistanceFromTopOfScreen, indexOfChar) + coroutineScope.launch { + if (indexOfChar == null) { + return@launch + } + val index = mapOfFirstLetterIndex[indexOfChar] + // '↑' is a special case where it should scroll to the top + if (indexOfChar == '↑') { + lazyListState.scrollToItem(0) + } else if (index != null) { + lazyListState.scrollToItem(index) + } + } + }, + ) + } +} + +@Composable +fun RowScope.AlphabetLazyColumn( + modifier: Modifier, + lazyListState: LazyListState, + mapOfFirstLetterIndex: ImmutableMap, + content: LazyListScope.(firstLetterIndexes: ImmutableMap) -> Unit, +) { + LazyColumn( + modifier = modifier, + state = lazyListState + ) { + content(mapOfFirstLetterIndex) + } +} + + +@Composable +private fun AlphabetScroller( + modifier: Modifier, + vibratorEnabled: Boolean, + alphabetPaddingValues: PaddingValues, + onAlphabetListDrag: (relativeDragYOffset: Float?, distanceFromTopOfScreen: Float, alphabetChar: Char?) -> Unit, +) { + + var currentLetter by remember { mutableStateOf(null) } + + var alphabetRelativeDragYOffset: Float? by remember { mutableStateOf(null) } + var distanceFromTopOfScreen by remember { mutableStateOf(0F) } + val context = LocalContext.current + val vibrator = remember { + if (vibratorEnabled) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + context.getSystemService(VibratorManager::class.java)?.defaultVibrator + } else { + TODO("VERSION.SDK_INT < S") + } + } else { + TODO("VERSION.SDK_INT < M") + } + } else { + null + } + } + var lastVibrationTime by remember { mutableStateOf(0L) } + Column( + modifier = Modifier + .wrapContentHeight() + + ) { + Spacer(modifier = Modifier.height(alphabetPaddingValues.calculateTopPadding())) + Column( + modifier = modifier + .onGloballyPositioned { + distanceFromTopOfScreen = it.positionInRoot().y + } + .pointerInput(Unit) { + detectVerticalDragGestures( + onDragStart = { + onAlphabetListDrag(it.y, distanceFromTopOfScreen, null) + }, + onDragEnd = { + alphabetRelativeDragYOffset = null + onAlphabetListDrag(null, distanceFromTopOfScreen, null) + } + ) { change, _ -> + if (vibratorEnabled) { + val currentTime = System.currentTimeMillis() + if (currentTime - lastVibrationTime >= vibrationCooldown) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + vibrator?.vibrate( + VibrationEffect.createOneShot( + 50, + VibrationEffect.DEFAULT_AMPLITUDE + ) + ) + } + lastVibrationTime = currentTime + } + } + alphabetRelativeDragYOffset = change.position.y + val itemHeight = size.height / alphabetCharSize + val index = ((alphabetRelativeDragYOffset!! / itemHeight).toInt()).coerceIn( + alphabetCharList.indices + ) + currentLetter = alphabetCharList.getOrNull(index) + onAlphabetListDrag( + alphabetRelativeDragYOffset, + distanceFromTopOfScreen, + currentLetter + ) + } + }, + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + for (i in alphabetCharList) { + if (i == currentLetter && alphabetRelativeDragYOffset != null) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .size(alphabetItemSize) + .align(Alignment.CenterHorizontally) + .background(VoiceNotifyTheme.colorScheme.primary, CircleShape), + ) { + Text( + text = i.toString(), + fontSize = 14.sp, + ) + } + } else { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .align(Alignment.CenterHorizontally) + .size(alphabetItemSize), + ) { + Text( + text = i.toString(), + fontSize = 14.sp, + ) + } + } + } + } + Spacer(modifier = Modifier.height(alphabetPaddingValues.calculateBottomPadding())) + + } + +} + + + +@Composable +fun ScrollingBubble( + bubbleOffsetY: Dp, + bubbleOffsetX: Dp, + currAlphabetScrolledOn: Char?, + bubbleSize: Dp = 50.dp +) { + Surface( + shape = RoundedCornerShape(8.dp), + modifier = Modifier + .size(bubbleSize) + .offset( + x = (bubbleOffsetX - (bubbleSize + alphabetItemSize)), + y = bubbleOffsetY - (bubbleSize / 2), + ) + ) { + Box( + modifier = Modifier.fillMaxSize() + .background(Color.Gray.copy(alpha = 0.5f)), + contentAlignment = Alignment.Center + ) { + Text( + text = currAlphabetScrolledOn.toString(), + fontSize = 30.sp, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center, + color = Color.White, + modifier = Modifier.align(Alignment.Center) + ) + } + } +} + + + +@Composable +fun ContactItem( + contact: String, + isAlphabeticallyFirstInCharGroup: Boolean +) { + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + modifier = Modifier.width(48.dp), + contentAlignment = Alignment.Center, + ) { + if (isAlphabeticallyFirstInCharGroup) { + Text( + text = contact.first().toString(), + style = VoiceNotifyTheme.typography.bodyLarge + ) + } + } + + Surface( + shape = CircleShape, + modifier = Modifier.size(32.dp), + color = VoiceNotifyTheme.colorScheme.secondary + ) { + Box(contentAlignment = Alignment.Center) { + Text( + text = contact.first().toString(), + style = VoiceNotifyTheme.typography.bodyLarge + ) + } + } + Text( + modifier = Modifier.padding(16.dp), + text = contact, + style = VoiceNotifyTheme.typography.titleLarge, + ) + } +} + + + +@Composable +fun LazyAlphabetIndexColumnDemo() { + val items = listOf("360", "HK01", "Apple", "Banana", "Cherry", + "Date", "Fig", "Grape", "Lemon", "Mango", "Nvidia", "Man", "Orange", "Peach", "Quince", + "Raspberry", "Strawberry", "Tomato", "Ugli Fruit", + "Watermelon", "Xigua", "Yam", "Zucchini").toImmutableList() + val lazyListState = rememberLazyListState() + val context = LocalDensity.current + val alphabetHeightInPixels = remember { with(context) { alphabetItemSize.toPx() } } + var alphabetRelativeDragYOffset: Float? by remember { mutableStateOf(null) } + var alphabetDistanceFromTopOfScreen: Float by remember { mutableStateOf(0F) } + var currentLetter by remember { mutableStateOf(null) } + AppTheme { + Surface( + modifier = Modifier.fillMaxSize(), + color = VoiceNotifyTheme.colorScheme.background + ) { + BoxWithConstraints { + LazyAlphabetIndexColumn( + items = items, + alphabetModifier = Modifier, + onAlphabetListDrag = { relativeDragYOffset, containerDistance, char -> + alphabetRelativeDragYOffset = relativeDragYOffset + alphabetDistanceFromTopOfScreen = containerDistance + currentLetter = char + } + ) { firstLetterIndexes -> + itemsIndexed(items) { index, contact -> + ContactItem( + contact = contact, + isAlphabeticallyFirstInCharGroup = + firstLetterIndexes[contact.uppercase().first()] == index, + ) + } + + } + val yOffset = alphabetRelativeDragYOffset + if (yOffset != null && currentLetter != null) { + ScrollingBubble( + bubbleOffsetX = this.maxWidth - 50.dp, + bubbleOffsetY = with(LocalDensity.current) { (yOffset + alphabetDistanceFromTopOfScreen).toDp() }, + currAlphabetScrolledOn = currentLetter, + ) + } + } + } + } +} + + +@Composable +fun LazyAlphabetIndexRowDemo() { + val items = listOf("360", "HK01", "Apple", "Banana", "Cherry", + "Date", "Fig", "Grape", "Lemon", "Mango", "Nvidia", "Man", "Orange", "Peach", "Quince", + "Raspberry", "Strawberry", "Tomato", "Ugli Fruit", + "Watermelon", "Xigua", "Yam", "Zucchini").toImmutableList() + val lazyListState = rememberLazyListState() + val context = LocalDensity.current + val alphabetHeightInPixels = remember { with(context) { alphabetItemSize.toPx() } } + var alphabetRelativeDragYOffset: Float? by remember { mutableStateOf(null) } + var alphabetDistanceFromTopOfScreen: Float by remember { mutableStateOf(0F) } + var currentLetter by remember { mutableStateOf(null) } + AppTheme { + Surface( + modifier = Modifier.fillMaxSize(), + color = VoiceNotifyTheme.colorScheme.background + ) { + BoxWithConstraints { + LazyAlphabetIndexRow( + items = items, + alphabetModifier = Modifier, + onAlphabetListDrag = { relativeDragYOffset, containerDistance, char -> + alphabetRelativeDragYOffset = relativeDragYOffset + alphabetDistanceFromTopOfScreen = containerDistance + currentLetter = char + } + ) { firstLetterIndexes -> + LazyColumn( + modifier = Modifier.fillMaxHeight(), + state = lazyListState + ) { + itemsIndexed(items) { index, contact -> + ContactItem( + contact = contact, + isAlphabeticallyFirstInCharGroup = + firstLetterIndexes[contact.uppercase().first()] == index, + ) + } + } + } + val yOffset = alphabetRelativeDragYOffset + if (yOffset != null) { + ScrollingBubble( + bubbleOffsetX = this.maxWidth - 50.dp, + bubbleOffsetY = with(LocalDensity.current) { (yOffset + alphabetDistanceFromTopOfScreen).toDp() }, + currAlphabetScrolledOn = currentLetter, + ) + } + } + } + } +} + + +@Composable +@Preview +fun PreviewLazyAlphabetIndexColumnDemo() { + LazyAlphabetIndexColumnDemo() +} + +@Composable +@Preview +fun PreviewLazyAlphabetIndexRowDemo() { + LazyAlphabetIndexRowDemo() +} + + diff --git a/app/src/main/java/com/pilot51/voicenotify/ui/ListBox.kt b/app/src/main/java/com/pilot51/voicenotify/ui/ListBox.kt new file mode 100644 index 00000000..eb6756f6 --- /dev/null +++ b/app/src/main/java/com/pilot51/voicenotify/ui/ListBox.kt @@ -0,0 +1,26 @@ +package com.pilot51.voicenotify.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.dp +import com.pilot51.voicenotify.ui.theme.VoiceNotifyTheme + +@Composable +fun ListBox( + modifier: Modifier = Modifier, + content: @Composable () -> Unit +) { + Column( + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(VoiceNotifyTheme.colors.boxItem) + ) { + content() + } +} diff --git a/app/src/main/java/com/pilot51/voicenotify/ui/ListItem.kt b/app/src/main/java/com/pilot51/voicenotify/ui/ListItem.kt new file mode 100644 index 00000000..93e4917c --- /dev/null +++ b/app/src/main/java/com/pilot51/voicenotify/ui/ListItem.kt @@ -0,0 +1,58 @@ +package com.pilot51.voicenotify.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.pilot51.voicenotify.ui.theme.VoiceNotifyTheme + +@Composable +fun ListItem( + modifier: Modifier = Modifier, + leadingContent: @Composable (() -> Unit)? = null, + headlineContent: @Composable (() -> Unit)? = null, + supportingContent: @Composable (() -> Unit)? = null, + trailingContent: @Composable (() -> Unit)? = null +) { + Row( + modifier = modifier + .fillMaxWidth(), + horizontalArrangement = Arrangement.Start, + verticalAlignment = Alignment.CenterVertically + ) { + leadingContent?.let { + it?.let { + Column( + modifier = Modifier + .padding(start = 8.dp, end = 16.dp, top = 8.dp, bottom = 8.dp) + ) { + it() + } + } + } + Row( + modifier = Modifier + .weight(1f) + .fillMaxHeight() + .padding(end = 8.dp) + .bottomBorder(2f, VoiceNotifyTheme.colors.divider) + .padding(0.dp, 8.dp, 0.dp, 8.dp), + ) { + Column( + modifier = Modifier + .weight(1f) + ) { + headlineContent?.invoke() + supportingContent?.invoke() + } + trailingContent?.invoke() + } + + } +} \ No newline at end of file diff --git a/app/src/main/java/com/pilot51/voicenotify/ui/Modifier.kt b/app/src/main/java/com/pilot51/voicenotify/ui/Modifier.kt new file mode 100644 index 00000000..3c39fbf0 --- /dev/null +++ b/app/src/main/java/com/pilot51/voicenotify/ui/Modifier.kt @@ -0,0 +1,89 @@ +package com.pilot51.voicenotify.ui + +import androidx.compose.foundation.border +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.layout.onPlaced +import androidx.compose.ui.unit.dp + + +fun Modifier.bottomBorder(strokeWidth: Float, color: Color) = composed( + factory = { + val strokeWidthPx = strokeWidth + Modifier.drawBehind { + val width = size.width + val height = size.height + Math.round(-strokeWidthPx/2) + drawLine( + color = color, + start = Offset(x = 0f, y = height), + end = Offset(x = width , y = height), + strokeWidth = strokeWidthPx + ) + } + } +) + + +fun Modifier.topBorder(strokeWidth: Float, color: Color) = composed( + factory = { + val strokeWidthPx = strokeWidth + Modifier.drawBehind { + val width = size.width + val height = 0f + Math.round(-strokeWidthPx/2) + drawLine( + color = color, + start = Offset(x = 0f, y = height), + end = Offset(x = width , y = height), + strokeWidth = strokeWidthPx + ) + } + } +) + +/** + * @example + * Modifier.addIf(condition) { Modifier.padding(16.dp) } + */ +inline fun Modifier.addIf( + predicate: Boolean, + crossinline whenTrue: @Composable () -> Modifier, +): Modifier = composed { + if (predicate) { + this.then(whenTrue()) + } else { + this + } +} + +/** + * @example + * + * Modifier.measured { topBarHeight = it.height } + * + * Modifier.measured { size -> + * topBarHeight = size.height + * } + * + */ +fun Modifier.measured(block: (DpSize) -> Unit): Modifier = composed { + val density = LocalDensity.current + onPlaced { block(it.size.toDp(density)) } +} + + +/** + * @example + * Modifier.debugBounds() + */ +fun Modifier.debugBounds(color: Color = Color.Magenta, shape: Shape = RectangleShape) = + this.border(1.dp, color, shape) + diff --git a/app/src/main/java/com/pilot51/voicenotify/ui/OverScroll.kt b/app/src/main/java/com/pilot51/voicenotify/ui/OverScroll.kt new file mode 100644 index 00000000..90601904 --- /dev/null +++ b/app/src/main/java/com/pilot51/voicenotify/ui/OverScroll.kt @@ -0,0 +1,308 @@ +package com.pilot51.voicenotify.ui + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.AnimationState +import androidx.compose.animation.core.AnimationVector1D +import androidx.compose.animation.core.DecayAnimationSpec +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateDecay +import androidx.compose.animation.core.exponentialDecay +import androidx.compose.animation.core.spring +import androidx.compose.animation.rememberSplineBasedDecay +import androidx.compose.foundation.gestures.FlingBehavior +import androidx.compose.foundation.gestures.ScrollScope +import androidx.compose.foundation.gestures.ScrollableState +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollDispatcher +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Velocity +import kotlinx.coroutines.launch +import kotlin.math.abs +import kotlin.math.sign +import kotlin.math.sqrt + +// Define a small threshold to ignore minor offsets +private const val SCROLL_THRESHOLD = 0.1f + +/** + * A parabolic rolling easing curve. + * + * When rolling in the same direction, the farther away from 0, the greater the "resistance"; the closer to 0, the smaller the "resistance"; + * + * No drag effect is applied when the scrolling direction is opposite to the currently existing overscroll offset + * + * Note: when [p] = 50f, its performance should be consistent with iOS + * @param currentOffset Offset value currently out of bounds + * @param newOffset The offset of the new scroll + * @param p Key parameters for parabolic curve calculation + * @param density Without this param, the unit of offset is pixels, + * so we need this variable to have the same expectations on different devices. + */ +@Stable +fun parabolaScrollEasing(currentOffset: Float, newOffset: Float, p: Float = 50f, density: Float = 4f): Float { + val realP = p * density + val ratio = (realP / (sqrt(realP * abs(currentOffset + newOffset / 2).coerceAtLeast(Float.MIN_VALUE)))).coerceIn(Float.MIN_VALUE, 1f) + return if (sign(currentOffset) == sign(newOffset)) { + currentOffset + newOffset * ratio + } else { + currentOffset + newOffset + } +} + +/** + * Linear, you probably wouldn't think of using it. + */ +val LinearScrollEasing: (currentOffset: Float, newOffset: Float) -> Float = { currentOffset, newOffset -> currentOffset + newOffset } + +internal val DefaultParabolaScrollEasing: (currentOffset: Float, newOffset: Float) -> Float + @Composable + get() { + val density = LocalDensity.current.density + return { currentOffset, newOffset -> + parabolaScrollEasing(currentOffset, newOffset, density = density) + } + } + +internal const val OutBoundSpringStiff = 150f +internal const val OutBoundSpringDamp = 0.86f + +/** + * @see overScrollOutOfBound + */ +fun Modifier.overScrollVertical( + nestedScrollToParent: Boolean = true, + scrollEasing: ((currentOffset: Float, newOffset: Float) -> Float)? = null, + springStiff: Float = OutBoundSpringStiff, + springDamp: Float = OutBoundSpringDamp, +): Modifier = overScrollOutOfBound(isVertical = true, nestedScrollToParent, scrollEasing, springStiff, springDamp) + +/** + * @see overScrollOutOfBound + */ +fun Modifier.overScrollHorizontal( + nestedScrollToParent: Boolean = true, + scrollEasing: ((currentOffset: Float, newOffset: Float) -> Float)? = null, + springStiff: Float = OutBoundSpringStiff, + springDamp: Float = OutBoundSpringDamp, +): Modifier = overScrollOutOfBound(isVertical = false, nestedScrollToParent, scrollEasing, springStiff, springDamp) + +/** + * OverScroll effect for scrollable Composable . + * + * - You should call it before Modifiers with similar semantics such as [Modifier.scrollable], so that nested scrolling can work normally + * - You should use it with [rememberOverscrollFlingBehavior] + * @Author: cormor + * @Email: cangtiansuo@gmail.com + * @param isVertical is vertical, or horizontal ? + * @param nestedScrollToParent Whether to dispatch nested scroll events to parent + * @param scrollEasing U can refer to [DefaultParabolaScrollEasing], The incoming values are the currently existing overscroll Offset + * and the new offset from the gesture. + * modify it to cooperate with [springStiff] to customize the sliding damping effect. + * The current default easing comes from iOS, you don't need to modify it! + * @param springStiff springStiff for overscroll effect,For better user experience, the new value is not recommended to be higher than[Spring.StiffnessMediumLow] + * @param springDamp springDamp for overscroll effect,generally do not need to set + */ +@Suppress("NAME_SHADOWING") +fun Modifier.overScrollOutOfBound( + isVertical: Boolean = true, + nestedScrollToParent: Boolean = true, + scrollEasing: ((currentOffset: Float, newOffset: Float) -> Float)?, + springStiff: Float = OutBoundSpringStiff, + springDamp: Float = OutBoundSpringDamp, +): Modifier = composed { + val nestedScrollToParent by rememberUpdatedState(nestedScrollToParent) + val scrollEasing by rememberUpdatedState(scrollEasing ?: DefaultParabolaScrollEasing) + val springStiff by rememberUpdatedState(springStiff) + val springDamp by rememberUpdatedState(springDamp) + val isVertical by rememberUpdatedState(isVertical) + val dispatcher = remember { NestedScrollDispatcher() } + var offset by remember { mutableFloatStateOf(0f) } + + val nestedConnection = remember { + object : NestedScrollConnection { + /** + * If the offset is less than this value, we consider the animation to end. + */ + val visibilityThreshold = 0.5f + lateinit var lastFlingAnimator: Animatable + + override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { + // Found fling behavior in the wrong direction. + if (source != NestedScrollSource.Drag) { + return dispatcher.dispatchPreScroll(available, source) + } + if (::lastFlingAnimator.isInitialized && lastFlingAnimator.isRunning) { + dispatcher.coroutineScope.launch { + lastFlingAnimator.stop() + } + } + val realAvailable = when { + nestedScrollToParent -> available - dispatcher.dispatchPreScroll(available, source) + else -> available + } + val realOffset = if (isVertical) realAvailable.y else realAvailable.x + + val isSameDirection = sign(realOffset) == sign(offset) + if (abs(offset) <= visibilityThreshold || isSameDirection) { + return available - realAvailable + } + val offsetAtLast = scrollEasing(offset, realOffset) + // sign changed, coerce to start scrolling and exit + return if (sign(offset) != sign(offsetAtLast)) { + offset = 0f + if (isVertical) { + Offset(x = available.x - realAvailable.x, y = available.y - realAvailable.y + realOffset) + } else { + Offset(x = available.x - realAvailable.x + realOffset, y = available.y - realAvailable.y) + } + } else { + offset = offsetAtLast + if (isVertical) { + Offset(x = available.x - realAvailable.x, y = available.y) + } else { + Offset(x = available.x, y = available.y - realAvailable.y) + } + } + } + + override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset { + // Found fling behavior in the wrong direction. + if (source != NestedScrollSource.Drag) { + return dispatcher.dispatchPreScroll(available, source) + } + val realAvailable = when { + nestedScrollToParent -> available - dispatcher.dispatchPostScroll(consumed, available, source) + else -> available + } + offset = scrollEasing(offset, if (isVertical) realAvailable.y else realAvailable.x) + return if (isVertical) { + Offset(x = available.x - realAvailable.x, y = available.y) + } else { + Offset(x = available.x, y = available.y - realAvailable.y) + } + } + + override suspend fun onPreFling(available: Velocity): Velocity { + if (::lastFlingAnimator.isInitialized && lastFlingAnimator.isRunning) { + lastFlingAnimator.stop() + } + val parentConsumed = when { + nestedScrollToParent -> dispatcher.dispatchPreFling(available) + else -> Velocity.Zero + } + val realAvailable = available - parentConsumed + var leftVelocity = if (isVertical) realAvailable.y else realAvailable.x + + if (abs(offset) >= visibilityThreshold && sign(leftVelocity) != sign(offset)) { + lastFlingAnimator = Animatable(offset).apply { + when { + leftVelocity < 0 -> updateBounds(lowerBound = 0f) + leftVelocity > 0 -> updateBounds(upperBound = 0f) + } + } + leftVelocity = lastFlingAnimator.animateTo(0f, spring(springDamp, springStiff, visibilityThreshold), leftVelocity) { + offset = scrollEasing(offset, value - offset) + }.endState.velocity + } + return if (isVertical) { + Velocity(parentConsumed.x, y = available.y - leftVelocity) + } else { + Velocity(available.x - leftVelocity, y = parentConsumed.y) + } + } + + override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity { + val realAvailable = when { + nestedScrollToParent -> available - dispatcher.dispatchPostFling(consumed, available) + else -> available + } + + lastFlingAnimator = Animatable(offset) + lastFlingAnimator.animateTo(0f, spring(springDamp, springStiff, visibilityThreshold), if (isVertical) realAvailable.y else realAvailable.x) { + offset = scrollEasing(offset, value - offset) + } + return if (isVertical) { + Velocity(x = available.x - realAvailable.x, y = available.y) + } else { + Velocity(x = available.x, y = available.y - realAvailable.y) + } + } + } + } + + this + .clipToBounds() + .nestedScroll(nestedConnection, dispatcher) + .graphicsLayer { + if (isVertical) translationY = if (abs(offset) > SCROLL_THRESHOLD) offset else 0f + else translationX = if (abs(offset) > SCROLL_THRESHOLD) offset else 0f + } +} + +/** + * You should use it with [overScrollVertical] + * @param decaySpec You can use instead [rememberSplineBasedDecay] + * @param getScrollState Pass in your [ScrollableState], for [LazyColumn]/[LazyRow] , it's [LazyListState] + */ +@Composable +fun rememberOverscrollFlingBehavior( + decaySpec: DecayAnimationSpec = exponentialDecay(), + getScrollState: () -> ScrollableState, +): FlingBehavior = remember(decaySpec, getScrollState) { + object : FlingBehavior { + /** + * - We should check it every frame of fling + * - Should stop fling when returning true and return the remaining speed immediately. + * - Without this detection, scrollBy() will continue to consume velocity, + * which will cause a velocity error in nestedScroll. + */ + private val Float.canNotBeConsumed: Boolean // this is Velocity + get() { + val state = getScrollState() + return !(this < 0 && state.canScrollBackward || this > 0 && state.canScrollForward) + } + + override suspend fun ScrollScope.performFling(initialVelocity: Float): Float { + if (initialVelocity.canNotBeConsumed) { + return initialVelocity + } + return if (abs(initialVelocity) > 1f) { + var velocityLeft = initialVelocity + var lastValue = 0f + AnimationState( + initialValue = 0f, + initialVelocity = initialVelocity, + ).animateDecay(decaySpec) { + val delta = value - lastValue + val consumed = scrollBy(delta) + lastValue = value + velocityLeft = this.velocity + // avoid rounding errors and stop if anything is unconsumed + if (abs(delta - consumed) > 0.5f || velocityLeft.canNotBeConsumed) { + cancelAnimation() + } + } + velocityLeft + } else { + initialVelocity + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/pilot51/voicenotify/ui/Page.kt b/app/src/main/java/com/pilot51/voicenotify/ui/Page.kt new file mode 100644 index 00000000..1cee643c --- /dev/null +++ b/app/src/main/java/com/pilot51/voicenotify/ui/Page.kt @@ -0,0 +1,39 @@ +package com.pilot51.voicenotify.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import com.pilot51.voicenotify.ui.theme.VoiceNotifyTheme + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun Page( + scrollBehavior: TopAppBarScrollBehavior, + topBar: @Composable () -> Unit = {}, + content: @Composable () -> Unit +) { + Scaffold( + modifier = Modifier + .fillMaxSize() + .background(VoiceNotifyTheme.colors.background) + .nestedScroll(scrollBehavior.nestedScrollConnection), + topBar = { + topBar() + } + ) { innerPadding -> + Surface( + modifier = Modifier.padding(innerPadding) + .background(VoiceNotifyTheme.colorScheme.background) + ) { + content() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/pilot51/voicenotify/ui/SearchBar.kt b/app/src/main/java/com/pilot51/voicenotify/ui/SearchBar.kt new file mode 100644 index 00000000..aecadafa --- /dev/null +++ b/app/src/main/java/com/pilot51/voicenotify/ui/SearchBar.kt @@ -0,0 +1,98 @@ +package com.pilot51.voicenotify.ui + + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Search +import androidx.compose.material3.Icon +import androidx.compose.material3.Surface +import androidx.compose.material3.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.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.pilot51.voicenotify.AppTheme +import com.pilot51.voicenotify.R +import com.pilot51.voicenotify.ui.theme.VoiceNotifyTheme + +@Composable +fun SearchBar( + modifier: Modifier = Modifier, + text: String, + placeholderText: String, + onValueChange: (String) -> Unit, +) { + val view = LocalView.current + + Surface( + modifier = modifier, + shape = RoundedCornerShape(50.dp), + color = VoiceNotifyTheme.colorScheme.surfaceVariant + ) { + Row( + modifier = Modifier.background( + color = VoiceNotifyTheme.colorScheme.surfaceVariant, + shape = RoundedCornerShape(50.dp)), + verticalAlignment = Alignment.CenterVertically) { + TextField( + value = text, + onValueChange = onValueChange, + leadingIcon = { + Icon( + imageVector = Icons.Outlined.Search, + contentDescription = null, + tint = VoiceNotifyTheme.colorScheme.onSurfaceVariant + ) + }, + placeholder = { Text(text = placeholderText) }, + modifier = Modifier.weight(1f).height(40.dp), + contentDescription = stringResource(id = R.string.test), + singleLine = true + ) + } + } +} + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +@Composable +private fun SearchBarPreviewEmpty() { + var text by remember { mutableStateOf("") } + AppTheme { + Surface { + SearchBar( + text = text, + placeholderText = "test", + ) { text = it } + } + + } +} + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +@Composable +private fun SearchBarPreview() { + var text by remember { mutableStateOf("some thing") } + AppTheme { + Surface { + SearchBar( + text = text, + placeholderText = "test", + ) { text = it } + } + + } +} + diff --git a/app/src/main/java/com/pilot51/voicenotify/ui/Switch.kt b/app/src/main/java/com/pilot51/voicenotify/ui/Switch.kt new file mode 100644 index 00000000..eb3aed31 --- /dev/null +++ b/app/src/main/java/com/pilot51/voicenotify/ui/Switch.kt @@ -0,0 +1,67 @@ +package com.pilot51.voicenotify.ui +import android.content.res.Configuration +import androidx.compose.foundation.focusable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Switch as OSwitch +import androidx.compose.material3.SwitchDefaults +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.scale +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.pilot51.voicenotify.AppTheme +import com.pilot51.voicenotify.ui.theme.VoiceNotifyTheme + + +@Composable +fun Switch( + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, + shape: Shape = CircleShape +) { + OSwitch( + checked = checked, + onCheckedChange = onCheckedChange, + colors = SwitchDefaults.colors( + checkedThumbColor = VoiceNotifyTheme.colors.colorOnCustom, + uncheckedThumbColor = VoiceNotifyTheme.colors.colorThumbOffCustom, + checkedTrackColor = VoiceNotifyTheme.colors.colorPrimary, + uncheckedTrackColor = VoiceNotifyTheme.colors.colorOffCustom, + checkedBorderColor = Color.Transparent, + uncheckedBorderColor = Color.Transparent + ), + modifier = (modifier?: Modifier).scale(scale = 0.75f).focusable(false), + ) +} + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +@Composable +private fun SwitchPreview() { + AppTheme { + var isChecked by remember { mutableStateOf(false) } + Switch( + checked = isChecked, + onCheckedChange = { isChecked = it }, + modifier = Modifier.padding(16.dp) + ) + } +} + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +@Composable +private fun PreviewSwitchChecked() { + AppTheme { + var isChecked by remember { mutableStateOf(true) } + Switch( + checked = isChecked, + onCheckedChange = { isChecked = it }, + modifier = Modifier.padding(16.dp) + ) + } +} diff --git a/app/src/main/java/com/pilot51/voicenotify/ui/TextField.kt b/app/src/main/java/com/pilot51/voicenotify/ui/TextField.kt new file mode 100644 index 00000000..6c2be80b --- /dev/null +++ b/app/src/main/java/com/pilot51/voicenotify/ui/TextField.kt @@ -0,0 +1,280 @@ +package com.pilot51.voicenotify.ui + + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Clear +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextField as OTextField +import androidx.compose.material3.TextFieldColors +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.unit.dp +import com.pilot51.voicenotify.ui.theme.VoiceNotifyTheme +import kotlinx.coroutines.delay + +/** + * @param contentDescription Text label of the `TextField` for the accessibility service + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TextField( + value: String, + onValueChange: (String) -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + readOnly: Boolean = false, + textStyle: TextStyle = LocalTextStyle.current, + contentDescription: String? = null, + label: @Composable (() -> Unit)? = null, + placeholder: @Composable (() -> Unit)? = null, + leadingIcon: @Composable (() -> Unit)? = null, + trailingIcon: @Composable (() -> Unit)? = null, + clearIcon: @Composable (() -> Unit)? = { + Icon( + imageVector = Icons.Default.Clear, + modifier = Modifier.padding(0.dp) + .clickable( + onClick = { onValueChange("") }, + ), + contentDescription = "Clear text" + ) + }, + prefix: @Composable (() -> Unit)? = null, + suffix: @Composable (() -> Unit)? = null, + supportingText: @Composable (() -> Unit)? = null, + isError: Boolean = false, + visualTransformation: VisualTransformation = VisualTransformation.None, + keyboardOptions: KeyboardOptions = KeyboardOptions.Default, + keyboardActions: KeyboardActions = KeyboardActions.Default, + singleLine: Boolean = false, + maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE, + minLines: Int = 1, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + shape: Shape = TextFieldDefaults.shape, + colors: TextFieldColors = TextFieldDefaults.colors().copy( + focusedContainerColor = VoiceNotifyTheme.colors.textFieldContainer, + unfocusedContainerColor = VoiceNotifyTheme.colors.textFieldContainer, + disabledContainerColor = Color.Transparent, + focusedIndicatorColor = Color.Transparent, + disabledIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + focusedTextColor = VoiceNotifyTheme.colors.textInputColor, + unfocusedTextColor = VoiceNotifyTheme.colors.textInputColor, +// focusedLabelColor = VoiceNotifyTheme.colors.textFieldContainer, + ) +) { + BasicTextField( + value = value, + onValueChange = onValueChange, + modifier = modifier.semantics { + if (contentDescription != null) { + this.contentDescription = contentDescription + } + }, + enabled = enabled, + readOnly = readOnly, + textStyle = textStyle, + keyboardOptions = keyboardOptions, + keyboardActions = keyboardActions, + visualTransformation = visualTransformation, + interactionSource = interactionSource, + singleLine = singleLine, + decorationBox = @Composable { innerTextField -> + TextFieldDefaults.DecorationBox( + value = value, + visualTransformation = visualTransformation, + innerTextField = innerTextField, + placeholder = placeholder, + label = label, + leadingIcon = leadingIcon, + trailingIcon = { + if (value.isNotEmpty()) { + if (clearIcon != null) { + clearIcon() + } + } + trailingIcon?.invoke() + }, + prefix = prefix, + suffix = suffix, + supportingText = supportingText, + singleLine = singleLine, + enabled = enabled, + isError = isError, + interactionSource = interactionSource, + colors = colors, + contentPadding = PaddingValues(0.dp, 0.dp, 0.dp, 0.dp), + ) + } + ) +} + + + +@Composable +fun AutoFocusTextField( + value: String, + onValueChange: (String) -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + readOnly: Boolean = false, + textStyle: TextStyle = LocalTextStyle.current, + contentDescription: String? = null, + label: @Composable (() -> Unit)? = null, + placeholder: @Composable (() -> Unit)? = null, + leadingIcon: @Composable (() -> Unit)? = null, + trailingIcon: @Composable (() -> Unit)? = null, + prefix: @Composable (() -> Unit)? = null, + suffix: @Composable (() -> Unit)? = null, + supportingText: @Composable (() -> Unit)? = null, + isError: Boolean = false, + visualTransformation: VisualTransformation = VisualTransformation.None, + singleLine: Boolean = false, + maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE, + minLines: Int = 1, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + shape: Shape = TextFieldDefaults.shape, + colors: TextFieldColors = TextFieldDefaults.colors( + unfocusedTextColor = VoiceNotifyTheme.colorScheme.onSurfaceVariant, + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent, + disabledContainerColor = Color.Transparent, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + ), + focusRequester: FocusRequester = remember { FocusRequester() }, +) { + val focusManager = LocalFocusManager.current + + LaunchedEffect(Unit) { + delay(200) + focusRequester.requestFocus() + } + + OTextField( + value = value, + onValueChange = onValueChange, + modifier = modifier + .then(Modifier.semantics { + if (contentDescription != null) { + this.contentDescription = contentDescription + } + }) + .focusRequester(focusRequester = focusRequester), + enabled = enabled, + readOnly = readOnly, + textStyle = textStyle, + label = label, + placeholder = placeholder, + leadingIcon = leadingIcon, + trailingIcon = trailingIcon, + prefix = prefix, + suffix = suffix, + supportingText = supportingText, + isError = isError, + visualTransformation = visualTransformation, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { + focusManager.clearFocus() + }), + singleLine = singleLine, + maxLines = maxLines, + minLines = minLines, + interactionSource = interactionSource, + shape = shape, + colors = colors + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TextField2( + value: TextFieldValue, + onValueChange: (TextFieldValue) -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + readOnly: Boolean = false, + textStyle: TextStyle = LocalTextStyle.current, + label: @Composable (() -> Unit)? = null, + placeholder: @Composable (() -> Unit)? = null, + leadingIcon: @Composable (() -> Unit)? = null, + trailingIcon: @Composable (() -> Unit)? = null, + prefix: @Composable (() -> Unit)? = null, + suffix: @Composable (() -> Unit)? = null, + supportingText: @Composable (() -> Unit)? = null, + isError: Boolean = false, + visualTransformation: VisualTransformation = VisualTransformation.None, + keyboardOptions: KeyboardOptions = KeyboardOptions.Default, + keyboardActions: KeyboardActions = KeyboardActions.Default, + singleLine: Boolean = false, + maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE, + minLines: Int = 1, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + shape: Shape = TextFieldDefaults.shape, + colors: TextFieldColors = TextFieldDefaults.colors( + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent, + disabledContainerColor = Color.Transparent, + ) +) { + OTextField( + value, + onValueChange, + modifier, + enabled, + readOnly, + textStyle, + label, + placeholder, + leadingIcon, + trailingIcon, + prefix, + suffix, + supportingText, + isError, + visualTransformation, + keyboardOptions, + keyboardActions, + singleLine, + maxLines, + minLines, + interactionSource, + shape, + colors + ) +} + +@Composable +fun AdjacentLabel(modifier: Modifier = Modifier, text: String) { + Text( + text = text, + modifier = modifier.padding(bottom = 12.dp, start = 4.dp), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/pilot51/voicenotify/ui/TopAppBar.kt b/app/src/main/java/com/pilot51/voicenotify/ui/TopAppBar.kt new file mode 100644 index 00000000..c62f6d9a --- /dev/null +++ b/app/src/main/java/com/pilot51/voicenotify/ui/TopAppBar.kt @@ -0,0 +1,72 @@ +package com.pilot51.voicenotify.ui + +import android.graphics.Path +import android.view.animation.PathInterpolator +import androidx.compose.foundation.layout.RowScope +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarColors +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.pilot51.voicenotify.ui.theme.VoiceNotifyTheme + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun LargeTopAppBar( + title: @Composable () -> Unit, + modifier: Modifier = Modifier, + navigationIcon: @Composable () -> Unit = {}, + actions: @Composable RowScope.() -> Unit = {}, + scrollBehavior: TopAppBarScrollBehavior? = null, + colors: TopAppBarColors = TopAppBarDefaults.topAppBarColors() +) { + androidx.compose.material3.LargeTopAppBar( + modifier = modifier, + title = title, + navigationIcon = navigationIcon, + actions = actions, + scrollBehavior = scrollBehavior, + colors = colors + ) + +} + +private val path = Path().apply { + moveTo(0f,0f) + lineTo(0.7f, 0.1f) + cubicTo(0.7f, 0.1f, .95F, .5F, 1F, 1F) + moveTo(1f,1f) +} + +val fraction: (Float) -> Float = { PathInterpolator(path).getInterpolation(it) } + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SmallTopAppBar( + modifier: Modifier = Modifier, + titleText: String = "", + scrollBehavior: TopAppBarScrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(), + title: @Composable () -> Unit = { + Text( + text = titleText, + color = VoiceNotifyTheme.colorScheme.onSurface.copy(alpha = fraction(scrollBehavior.state.overlappedFraction)), + maxLines = 1 + ) + }, + navigationIcon: @Composable () -> Unit = {}, + actions: @Composable RowScope.() -> Unit = {}, + colors: TopAppBarColors = TopAppBarDefaults.topAppBarColors() +) { + TopAppBar( + modifier = modifier, + title = title, + navigationIcon = navigationIcon, + actions = actions, + scrollBehavior = scrollBehavior, + colors = colors + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/pilot51/voicenotify/ui/View.kt b/app/src/main/java/com/pilot51/voicenotify/ui/View.kt new file mode 100644 index 00000000..f41a0719 --- /dev/null +++ b/app/src/main/java/com/pilot51/voicenotify/ui/View.kt @@ -0,0 +1,85 @@ +package com.pilot51.voicenotify.ui + +import android.app.Activity +import android.content.Context +import android.content.ContextWrapper +import android.content.Intent +import android.content.res.Resources +import android.os.Process.killProcess +import android.os.Process.myPid +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.sp +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.IntSize +import androidx.fragment.app.FragmentActivity +import com.pilot51.voicenotify.BuildConfig +import com.pilot51.voicenotify.MainActivity +//import com.google.firebase.crashlytics.FirebaseCrashlytics +import java.util.* +import kotlin.concurrent.timerTask +import kotlin.system.exitProcess + + +fun Int.dipToPixels() = (Resources.getSystem().displayMetrics.density * this).toInt() + +val Int.nonScaledSp + @Composable + get() = (this / LocalDensity.current.fontScale).sp + +fun Context.getActivity(): FragmentActivity? = when (this) { + is AppCompatActivity -> this + is FragmentActivity -> this + is ContextWrapper -> baseContext.getActivity() + else -> null +} + +fun androidx.compose.ui.graphics.Color.toAGColor() = toArgb().run { + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O){ + android.graphics.Color.argb(alpha, red, green, blue) + } else { + android.graphics.Color.argb( + (alpha * 255).toInt(), + (red * 255).toInt(), + (green * 255).toInt(), + (blue * 255).toInt() + ) + } +} + +val Float.toPx get() = this * Resources.getSystem().displayMetrics.density +val Float.toDp get() = this / Resources.getSystem().displayMetrics.density + +val Int.toPx get() = (this * Resources.getSystem().displayMetrics.density).toInt() +val Int.toDp get() = (this / Resources.getSystem().displayMetrics.density).toInt() + + + + +fun IntSize.toDp(density: Density): DpSize = with(density) { DpSize(width = width.toDp(), height = height.toDp()) } + +//fun Activity.handleUncaughtException() { +// val crashedKey = "isCrashed" +// if (intent.getBooleanExtra(crashedKey, false)) return +// Thread.setDefaultUncaughtExceptionHandler { _, throwable -> +// if (BuildConfig.DEBUG) throw throwable +// +// FirebaseCrashlytics.getInstance().recordException(throwable) +// +// val intent = Intent(this, MainActivity::class.java).apply { +// putExtra(crashedKey, true) +// addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK) +// } +// startActivity(intent) +// finish() +// killProcess(myPid()) +// exitProcess(2) +// } +//} + +fun withDelay(delay: Long, block: () -> Unit) { + Timer().schedule(timerTask { block() } , delay) +} \ No newline at end of file diff --git a/app/src/main/java/com/pilot51/voicenotify/ui/theme/Color.kt b/app/src/main/java/com/pilot51/voicenotify/ui/theme/Color.kt new file mode 100644 index 00000000..9b5ccdf6 --- /dev/null +++ b/app/src/main/java/com/pilot51/voicenotify/ui/theme/Color.kt @@ -0,0 +1,75 @@ +package com.pilot51.voicenotify.ui.theme + +import androidx.compose.ui.graphics.Color +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.staticCompositionLocalOf + + +@Immutable +data class ExtendedColors( + val colorPrimary: Color, + val neutralSurface: Color, + val colorOnCustom: Color, + val colorOffCustom: Color, + var colorThumbOffCustom: Color = Color.Unspecified, + val colorOnCustomVariant: Color, + val colorSurface1: Color, + val colorSurface2: Color, + val colorSurface3: Color, + val colorSurface4: Color, + val colorSurface5: Color, + val colorTransparent1: Color, + val colorTransparent2: Color, + val colorTransparent3: Color, + val colorTransparent4: Color, + val colorTransparent5: Color, + val colorNeutral: Color, + val colorNeutralVariant: Color, + val colorTransparentInverse1: Color, + val colorTransparentInverse2: Color, + val colorTransparentInverse3: Color, + val colorTransparentInverse4: Color, + val colorTransparentInverse5: Color, + val colorNeutralInverse: Color, + val colorNeutralVariantInverse: Color, + val background: Color, + val boxItem: Color, + var divider: Color = Color.Unspecified, + var textFieldContainer: Color = Color.Unspecified, + var textInputColor: Color = Color.Unspecified, + var placeholder: Color = Color.Unspecified +) + +val LocalExtendedColors = staticCompositionLocalOf { + ExtendedColors( + colorPrimary = Color.Unspecified, + neutralSurface = Color.Unspecified, + colorOnCustom = Color.Unspecified, + colorOffCustom = Color.Unspecified, + colorThumbOffCustom = Color.Unspecified, + colorOnCustomVariant = Color.Unspecified, + colorSurface1 = Color.Unspecified, + colorSurface2 = Color.Unspecified, + colorSurface3 = Color.Unspecified, + colorSurface4 = Color.Unspecified, + colorSurface5 = Color.Unspecified, + colorTransparent1 = Color.Unspecified, + colorTransparent2 = Color.Unspecified, + colorTransparent3 = Color.Unspecified, + colorTransparent4 = Color.Unspecified, + colorTransparent5 = Color.Unspecified, + colorNeutral = Color.Unspecified, + colorNeutralVariant = Color.Unspecified, + colorTransparentInverse1 = Color.Unspecified, + colorTransparentInverse2 = Color.Unspecified, + colorTransparentInverse3 = Color.Unspecified, + colorTransparentInverse4 = Color.Unspecified, + colorTransparentInverse5 = Color.Unspecified, + colorNeutralInverse = Color.Unspecified, + colorNeutralVariantInverse = Color.Unspecified, + background = Color.Unspecified, + boxItem = Color.Unspecified, + divider = Color.Unspecified, + textFieldContainer = Color.Unspecified + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/pilot51/voicenotify/ui/theme/Theme.kt b/app/src/main/java/com/pilot51/voicenotify/ui/theme/Theme.kt new file mode 100644 index 00000000..3e12d2be --- /dev/null +++ b/app/src/main/java/com/pilot51/voicenotify/ui/theme/Theme.kt @@ -0,0 +1,160 @@ +package com.pilot51.voicenotify.ui.theme + +import android.app.Activity +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + + + +val ClearBlue = Color(0xFF286EFF) + + + +private val LightColorScheme = lightColorScheme( + primary = ClearBlue, + secondary = Color(0xFF2A54A5), + tertiary = Color(0xFF2A54A5), + surface = Color(0xFFf2f3f8), + onSurface = Color(0xFF1D1B20), + primaryContainer = Color(0xFF1E4696), + surfaceVariant = Color(0xFFf2f3f8), + background = Color(0xFFf2f3f8), + surfaceContainerHigh = Color(0xFFf2f2f2), + + +) + + +private val DarkColorScheme = darkColorScheme( + primary = ClearBlue, + secondary = Color(0xFF2A54A5), + tertiary = Color(0xFF2A54A5), + // background color + surface = Color(0xFF010001), + onSurface = Color(0xFFE0E0E0), + primaryContainer = Color(0xFF1E4696), + surfaceVariant = Color(0xFF121212), + background = Color(0xFF010001), + surfaceContainerHigh = Color(0xFF2b2b2b) +) + + +private val lightExtendedColors = ExtendedColors( + colorPrimary = ClearBlue, + neutralSurface = Color(0x99FFFFFF), + colorOnCustom = Color(0xFFFFFFFF), + colorOffCustom = Color(0xFFE5E4E5), + colorThumbOffCustom = Color(0xFF7A767E), + colorOnCustomVariant = Color(0xB3FFFFFF), + colorSurface1 = Color(0xFFF2F5F9), + colorSurface2 = Color(0xFFEDF0F6), + colorSurface3 = Color(0xFFE8ECF4), + colorSurface4 = Color(0xFFE6EAF3), + colorSurface5 = Color(0xFFE3E7F1), + colorTransparent1 = Color(0x14FFFFFF), + colorTransparent2 = Color(0x29FFFFFF), + colorTransparent3 = Color(0x8FFFFFFF), + colorTransparent4 = Color(0xB8FFFFFF), + colorTransparent5 = Color(0xF5FFFFFF), + colorNeutral = Color(0xFFFFFFFF), + colorNeutralVariant = Color(0xB8FFFFFF), + colorTransparentInverse1 = Color(0x0A000000), + colorTransparentInverse2 = Color(0x14000000), + colorTransparentInverse3 = Color(0x66000000), + colorTransparentInverse4 = Color(0xB8000000), + colorTransparentInverse5 = Color(0xE0000000), + colorNeutralInverse = Color(0xFF121212), + colorNeutralVariantInverse = Color(0xFF5C5C5C), + background = Color(0xFFf2f3f8), + boxItem = Color(0xFFFFFFFF), + divider = Color(0xFFE8E8E8), + textFieldContainer = Color(0xFFE6E7E9), + textInputColor = Color(0xFF1D1B20), + placeholder = Color(0xFFA3A3A3) +) + +private val darkExtendedColors = ExtendedColors( + colorPrimary = ClearBlue, + neutralSurface = Color(0x14FFFFFF), + colorOnCustom = Color(0xFFFFFFFF), + colorOffCustom = Color(0xFFE5E4E5), + colorThumbOffCustom = Color(0xFF7A767E), + colorOnCustomVariant = Color(0xB3FFFFFF), + colorSurface1 = Color(0xFF23242A), + colorSurface2 = Color(0xFF272A31), + colorSurface3 = Color(0xFF2C2F37), + colorSurface4 = Color(0xFF2E3039), + colorSurface5 = Color(0xFF31343E), + colorTransparent1 = Color(0x0AFFFFFF), + colorTransparent2 = Color(0x1FFFFFFF), + colorTransparent3 = Color(0x29FFFFFF), + colorTransparent4 = Color(0x7AFFFFFF), + colorTransparent5 = Color(0xB8FFFFFF), + colorNeutral = Color(0xFF121212), + colorNeutralVariant = Color(0xFF5C5C5C), + colorTransparentInverse1 = Color(0x0A000000), + colorTransparentInverse2 = Color(0x14000000), + colorTransparentInverse3 = Color(0x29000000), + colorTransparentInverse4 = Color(0xB8000000), + colorTransparentInverse5 = Color(0xF5000000), + colorNeutralInverse = Color(0xE0FFFFFF), + colorNeutralVariantInverse = Color(0xA3FFFFFF), + background = Color(0xFF010001), + boxItem = Color(0xFF202022), + divider = Color(0xFF323232), + textFieldContainer = Color(0xFF1A1A1A), + textInputColor = Color(0xFFE0E0E0), + placeholder = Color(0xFFA3A3A3) +) + +@Composable +fun VoiceNotifyTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + // Dynamic color is available on Android 12+ + dynamicColor: Boolean = true, + content: @Composable () -> Unit +) { + val colorScheme = when { +// dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { +// val context = LocalContext.current +// if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) +// } + darkTheme -> DarkColorScheme + else -> LightColorScheme + } + val extendedColors = if (darkTheme) darkExtendedColors else lightExtendedColors + + CompositionLocalProvider( + LocalExtendedColors provides extendedColors + ) { + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content + ) + } +} + + +object VoiceNotifyTheme { + val colors: ExtendedColors + @Composable + get() = LocalExtendedColors.current + val colorScheme: ColorScheme + @Composable + get() = MaterialTheme.colorScheme + val typography: Typography + @Composable + get() = MaterialTheme.typography +} + + diff --git a/app/src/main/java/com/pilot51/voicenotify/ui/theme/Type.kt b/app/src/main/java/com/pilot51/voicenotify/ui/theme/Type.kt new file mode 100644 index 00000000..03a7d99d --- /dev/null +++ b/app/src/main/java/com/pilot51/voicenotify/ui/theme/Type.kt @@ -0,0 +1,34 @@ +package com.pilot51.voicenotify.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +// Set of Material typography styles to start with +val Typography = Typography( + bodyLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp + ) + /* Other default text styles to override + titleLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 22.sp, + lineHeight = 28.sp, + letterSpacing = 0.sp + ), + labelSmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp + ) + */ +) \ No newline at end of file diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml new file mode 100644 index 00000000..dfc8d1ba --- /dev/null +++ b/app/src/main/res/values-zh/strings.xml @@ -0,0 +1,223 @@ + + + + 语音通知 + + + 返回 + %s 覆盖设置 + + + 语音通知服务正在运行 + 语音通知服务已暂停 + + + 需要电话通话权限以防止语音通知在通话期间播报。 + 需要发送通知权限以允许语音通知发送测试通知。 + 语音通知服务已禁用 + 点击暂停语音通知。\n长按打开安卓通知访问设置以禁用服务。 + 点击打开安卓通知访问设置以启用服务。 + 点击激活语音通知。\n长按打开安卓通知访问设置以禁用服务。 + 应用列表 + 列出可以被忽略的已安装应用 + 文字转语音 + 配置文字转语音行为 + 暂停/降低媒体音量 + 在播报时请求其他媒体暂停/降低音量 + 摇动静音 + 调整摇动静音的灵敏度阈值 + 较低的值更灵敏。\n\n默认值:%1$d\n留空或0表示禁用。\n\n实时摇动值:%2$d\n峰值(5秒内):%3$d + + 媒体 + 通知 + 语音 + 铃声 + 闹钟 + + 要求包含文本 + 仅播报消息中包含特定文本的通知 + 忽略文本 + 忽略消息中包含特定文本的通知 + 这将匹配发送给TTS的消息,在TTS消息格式化和文本替换之后,包括标点符号。\n\n用换行符分隔各个条目。\n消息只需包含任一条目即可匹配,不需要包含所有条目。\n不区分大小写。 + 忽略空白通知 + 没有消息内容的通知将不会被播报 + 没有消息内容的通知将被播报为"来自[应用名称]的通知。" + 忽略群组消息 + 包含多条消息的通知将不会被播报 + 包含多条消息的通知将被播报 + 忽略重复通知 + 在设定时间内忽略后续相同的通知 + 设置在上一条通知后必须经过的秒数,之后才能播报重复通知。\n被忽略的重复通知会重置计数,不同的通知会清除计数。\n留空 = 无限。 + 设备状态 + 选择在某些设备状态下是否播报 + 在这些设备状态下播报 + + 屏幕关闭 + 屏幕开启 + 未连接耳机 + 已连接耳机 + 静音/振动 + + 安静时间开始 + 在此时间之后不播报。\n与结束时间相同则禁用。 + 安静时间结束 + 在此时间之前不播报。\n与开始时间相同则禁用。 + 测试 + 发送一条通知(延迟5秒)以测试当前设置 + 语音通知在应用列表中被忽略。\n测试将运行,但通知不会被播报。 + 用于测试通知 + 这是滚动消息 + 子文本 + 内容标题 + 这是内容消息。 + 内容信息 + 大内容标题 + 这是大内容摘要 + 这是大内容文本。 + 这是一行文本。\n这是另一行文本。 + 通知日志 + 列出最近%d条通知。\n显示时间、应用名称、消息和忽略原因。 + 通知详情 + 启用 + 滚动消息 + 子文本 + 标题 + 内容文本 + 内容信息文本 + 大内容摘要 + 大内容标题 + 大内容文本 + 文本行 + 忽略原因 + 中断原因 + 元数据 + ID:%d + 类别:%s + 进度:%1$d / %2$d + 进度:不确定 + 忽略 %s? + 取消忽略 %s? + + 备份 / 恢复 + 备份或恢复所有语音通知设置 + 备份设置 + 恢复设置 + 帮助 & 支持 + 评分 & 评论,发送邮件给开发者,社区聊天,翻译,源代码,问题追踪,隐私政策 + 评分 & 评论 + 发送邮件给开发者 + 一般反馈 + Discord + Matrix + 用户聊天,开发者最快回复 + 翻译 + GitHub + 错误报告,功能请求,源代码 + 隐私政策 + 语音通知不会收集或传输设备外的数据。然而,它确实使用了可能传输数据的第三方应用或服务,如下面"第三方应用和服务"中所述。 + +在不太可能发生的情况下,如果开发者收到敏感或个人信息,未经信息所有者的明确同意,不会出售、分享、复制或使用这些信息。如果没有得到同意,这些信息将在可能的情况下被删除。 + +语音通知的目的是播报通知,因此播报的通知可能会被附近的人或麦克风听到。建议配置语音通知和设备以防止不必要的通知播报。使用风险自负。 + +语音通知接收的通知仅保存在内存中以在通知日志中显示(最多最近20条),不会写入存储。这可以防止其他应用访问数据,特别是在设备已root的情况下。因此,如果语音通知进程被终止,通知日志将被清除。 + +已安装的应用会被读取并列出,以允许用户选择哪些应用的通知会被播报。应用标题和包名存储在语音通知的内部私有数据存储中的数据库里,用于保存选择并作为缓存以提高加载应用列表的性能。 + +第三方应用和服务: +- 文字转语音引擎 - 语音通知将要播报的通知文本传递给默认的文字转语音引擎,这超出了语音通知的直接控制范围。请阅读TTS引擎的隐私政策以了解它如何使用数据。 +- Google Play服务可能会向开发者发送匿名的崩溃和基本分析数据,以帮助改进应用。 +- 消息应用 - "联系开发者"选项会打开用户选择或默认的消息应用,如电子邮件应用,并预填写包含语音通知版本、Android版本、Android构建号和设备名称的消息。对于电子邮件应用,"收件人"字段设置为开发者的地址(pilota51@gmail.com)。通过发送消息,用户理解并同意他们的姓名和联系信息将根据消息应用的决定与收件人共享。 +- "帮助 & 支持"下的其他选项会打开相关的外部应用或网站(Play Store、Weblate.org、GitHub.com),只向语音通知页面发送硬编码的URL。 + +以下是语音通知使用的权限及其需要的原因: +- 蓝牙 - 需要检测蓝牙耳机是否连接。 +- 振动 - 在手机处于振动模式时需要用于测试功能。 +- 修改音频设置 - 需要用于改进有线耳机检测。 +- 读取电话状态 - 需要在电话通话变为活动状态时中断TTS。 + 错误:无法找到已安装的Google Play商店。 + 错误:无法找到已安装的电子邮件应用。 + 错误:无法找到已安装的浏览器应用。 + 移除覆盖设置 + 移除 %2$s 的 %1$s 覆盖设置? + + + 关闭 + 忽略所有 + 不忽略任何 + 您确定要%s所有应用吗? + 忽略 + 过滤器 + 移除所有应用覆盖设置 + 移除 %s 的所有覆盖设置? + + + TTS设置 + 打开Android文字转语音设置 + 无法找到TTS设置!如果它存在于您的设备上(应该存在),请联系开发者。 + TTS消息 + 自定义播报通知的哪些部分 + #A = 应用标题\n#T = 滚动消息\n#S = 子文本\n#C =内容标题\n#M = 内容消息\n#I = 内容信息\n#H = 大内容标题\n#Y = 大内容摘要\n#B = 大内容文本\n#L = 文本行\n不区分大小写\n\n默认:\n%1$s\n\n旧默认(v1.1.0 - v1.3.0):\n#A. #C. #M.\n\n旧默认(v1.0.x):\n#A: #T + TTS文本替换 + 替换要播报的文本,例如修正发音 + 替换要播报的文本,允许您自定义文字转语音如何发音或出于任何其他原因替换文本。\n\n要替换的文本不区分大小写,并在TTS消息中设置的格式之后应用,包括标点符号。 + 重复!不会被保存。 + 要替换的文本 + 替换文本 + 移除 + 错误 + 最大消息长度 + 要播报的最大消息长度。更长的消息将被截断。 + 要播报的最大消息长度\n更长的消息将被截断。 + TTS音频流 + 选择文字转语音播放的音频流 + TTS延迟 + 在通知后延迟一定秒数再进行TTS + 通知后等待多少秒再播报。 + TTS重复 + 持续重复TTS通知直到屏幕打开 + 通知会按照定义的时间间隔重复,直到屏幕打开。屏幕打开时创建的通知不会重复。\n\n值以分钟为单位。留空或0表示禁用。 + + %s 已被忽略 + %s 未被忽略 + + + 来自 %s 的通知。 + + + 错误:无法初始化TTS!状态码 %d + + + 已忽略的应用 + 已忽略的文本 + 缺少所需文本 + 空消息 + 在{0,choice,-1#无限秒|1#{0}秒|1<{0}秒}内相同的消息 + 安静时间 + 静音或振动模式 + 通话中 + 屏幕关闭 + 屏幕开启 + 未连接耳机 + 已连接耳机 + 通过摇动静音 + 通过小部件暂停语音通知 + 服务已停止 + TTS失败。已尝试重启和重试。\n如果重试成功,此文本应为黄色,否则为红色。\n如果通知无法播报,请尝试重启语音通知服务。 + 消息在队列中或播报时TTS重启。已重新排队。 + TTS因未知原因中断 + 消息超过TTS长度限制 + \ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 00000000..d0f64153 --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,13 @@ + + + + + \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index f5e5874b..b9f4202e 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -8,7 +8,7 @@ buildscript { google() } dependencies { - classpath("com.android.tools.build:gradle:8.4.0") + classpath("com.android.tools.build:gradle:8.4.1") classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.24") } }