diff --git a/app/build.gradle b/app/build.gradle index 45bfc459c6..85c508ca83 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -75,11 +75,22 @@ android { } } + flavorDimensions = ["pro"] + productFlavors { + free { + dimension "pro" + } + pro { + dimension "pro" + } + } + buildFeatures { dataBinding true viewBinding true aidl true buildConfig true + compose true } compileOptions { @@ -95,6 +106,10 @@ android { kapt { correctErrorTypes = true } + + composeOptions { + kotlinCompilerExtensionVersion "1.5.10" + } } android.sourceSets { @@ -145,6 +160,8 @@ dependencies { implementation "dev.rikka.shizuku:api:$shizuku_version" implementation "dev.rikka.shizuku:provider:$shizuku_version" implementation 'org.lsposed.hiddenapibypass:hiddenapibypass:4.3' + proImplementation "com.android.billingclient:billing:7.1.0" + proImplementation "com.android.billingclient:billing-ktx:7.1.0" // splitties implementation "com.louiscad.splitties:splitties-bitflags:$splitties_version" @@ -179,6 +196,12 @@ dependencies { implementation "androidx.core:core-splashscreen:1.0.1" kapt "androidx.room:room-compiler:$room_version" + // Compose + implementation 'androidx.compose.ui:ui-android:1.7.2' + implementation 'androidx.compose.material3:material3-android:1.3.0' + implementation 'androidx.compose.ui:ui-tooling-preview-android:1.7.2' + debugImplementation 'androidx.compose.ui:ui-tooling:1.7.2' + // debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.6' def junitVersion = "4.13.2" diff --git a/app/src/free/java/io/github/sds100/keymapper/mappings/keymaps/trigger/AdvancedTriggersBottomSheet.kt b/app/src/free/java/io/github/sds100/keymapper/mappings/keymaps/trigger/AdvancedTriggersBottomSheet.kt new file mode 100644 index 0000000000..80a213c4e2 --- /dev/null +++ b/app/src/free/java/io/github/sds100/keymapper/mappings/keymaps/trigger/AdvancedTriggersBottomSheet.kt @@ -0,0 +1,45 @@ +package io.github.sds100.keymapper.mappings.keymaps.trigger + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.SheetState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import io.github.sds100.keymapper.R +import kotlinx.coroutines.launch + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AdvancedTriggersBottomSheet( + modifier: Modifier = Modifier, + onDismissRequest: () -> Unit, + sheetState: SheetState, +) { + val scope = rememberCoroutineScope() + + ModalBottomSheet( + modifier = modifier, + onDismissRequest = onDismissRequest, + sheetState = sheetState, + ) { + Text("I am free build.") + IconButton(onClick = { + scope.launch { + sheetState.hide() + onDismissRequest() + } + }) { + Icon( + Icons.Default.Close, + contentDescription = stringResource(R.string.button_dismiss_advanced_triggers_sheet_content_description), + ) + } + } +} diff --git a/app/src/free/java/io/github/sds100/keymapper/system/accessibility/AccessibilityServiceController.kt b/app/src/free/java/io/github/sds100/keymapper/system/accessibility/AccessibilityServiceController.kt new file mode 100644 index 0000000000..4690a1e34e --- /dev/null +++ b/app/src/free/java/io/github/sds100/keymapper/system/accessibility/AccessibilityServiceController.kt @@ -0,0 +1,48 @@ +package io.github.sds100.keymapper.system.accessibility + +import io.github.sds100.keymapper.actions.PerformActionsUseCase +import io.github.sds100.keymapper.constraints.DetectConstraintsUseCase +import io.github.sds100.keymapper.data.repositories.PreferenceRepository +import io.github.sds100.keymapper.mappings.PauseMappingsUseCase +import io.github.sds100.keymapper.mappings.fingerprintmaps.DetectFingerprintMapsUseCase +import io.github.sds100.keymapper.mappings.keymaps.detection.DetectKeyMapsUseCase +import io.github.sds100.keymapper.reroutekeyevents.RerouteKeyEventsUseCase +import io.github.sds100.keymapper.system.devices.DevicesAdapter +import io.github.sds100.keymapper.system.inputmethod.InputMethodAdapter +import io.github.sds100.keymapper.system.root.SuAdapter +import io.github.sds100.keymapper.util.ServiceEvent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow + +class AccessibilityServiceController( + coroutineScope: CoroutineScope, + accessibilityService: IAccessibilityService, + inputEvents: SharedFlow, + outputEvents: MutableSharedFlow, + detectConstraintsUseCase: DetectConstraintsUseCase, + performActionsUseCase: PerformActionsUseCase, + detectKeyMapsUseCase: DetectKeyMapsUseCase, + detectFingerprintMapsUseCase: DetectFingerprintMapsUseCase, + rerouteKeyEventsUseCase: RerouteKeyEventsUseCase, + pauseMappingsUseCase: PauseMappingsUseCase, + devicesAdapter: DevicesAdapter, + suAdapter: SuAdapter, + inputMethodAdapter: InputMethodAdapter, + settingsRepository: PreferenceRepository, +) : BaseAccessibilityServiceController( + coroutineScope, + accessibilityService, + inputEvents, + outputEvents, + detectConstraintsUseCase, + performActionsUseCase, + detectKeyMapsUseCase, + detectFingerprintMapsUseCase, + rerouteKeyEventsUseCase, + pauseMappingsUseCase, + devicesAdapter, + suAdapter, + inputMethodAdapter, + settingsRepository +) \ No newline at end of file diff --git a/app/src/main/java/io/github/sds100/keymapper/compose/ComposeColors.kt b/app/src/main/java/io/github/sds100/keymapper/compose/ComposeColors.kt new file mode 100644 index 0000000000..d4b067d4b5 --- /dev/null +++ b/app/src/main/java/io/github/sds100/keymapper/compose/ComposeColors.kt @@ -0,0 +1,64 @@ +package io.github.sds100.keymapper.compose + +import androidx.compose.ui.graphics.Color + +object ComposeColors { + val primaryLight = Color(0xFF175DB2) + val onPrimaryLight = Color(0xFFFFFFFF) + val primaryContainerLight = Color(0xFFD6E3FF) + val onPrimaryContainerLight = Color(0xFF001B3F) + val secondaryLight = Color(0xFF005EB5) + val onSecondaryLight = Color(0xFFFFFFFF) + val secondaryContainerLight = Color(0xFF001B3D) + val onSecondaryContainerLight = Color(0xFFD4E3FF) + val tertiaryLight = Color(0xFF0061A3) + val onTertiaryLight = Color(0xFFFFFFFF) + val tertiaryContainerLight = Color(0xFFCFE4FF) + val onTertiaryContainerLight = Color(0xFF001D36) + val errorLight = Color(0xFFBA1B1B) + val onErrorLight = Color(0xFFFFFFFF) + val errorContainerLight = Color(0xFFFFDAD4) + val onErrorContainerLight = Color(0xFF410001) + val backgroundLight = Color(0xFFFDFBFF) + val onBackgroundLight = Color(0xFF1A1B1F) + val surfaceLight = Color(0xFFFDFBFF) + val onSurfaceLight = Color(0xFF1A1B1F) + val surfaceVariantLight = Color(0xFFE0E2EC) + val onSurfaceVariantLight = Color(0xFF44474F) + val outlineLight = Color(0xFF74777F) + val outlineVariantLight = Color(0xFFBFC8CA) + val inverseSurfaceLight = Color(0xFF2F3034) + val inverseOnSurfaceLight = Color(0xFFF2F0F4) + val inversePrimaryLight = Color(0xFFA8C7FF) + val redLight = Color(0xffd32f2f) + val onRedLight = Color(0xFFFFFFFF) + + val primaryDark = Color(0xFFA8C7FF) + val onPrimaryDark = Color(0xFF002F66) + val primaryContainerDark = Color(0xFF004590) + val onPrimaryContainerDark = Color(0xFFD6E3FF) + val secondaryDark = Color(0xFFB1CBD0) + val onSecondaryDark = Color(0xFF003063) + val secondaryContainerDark = Color(0xFF00468A) + val onSecondaryContainerDark = Color(0xFFD4E3FF) + val tertiaryDark = Color(0xFF9BCAFF) + val onTertiaryDark = Color(0xFF003259) + val tertiaryContainerDark = Color(0xFF00497E) + val onTertiaryContainerDark = Color(0xFFCFE4FF) + val errorDark = Color(0xFFFFB4A9) + val onErrorDark = Color(0xFF930006) + val errorContainerDark = Color(0xFF93000A) + val onErrorContainerDark = Color(0xFFFFDAD4) + val backgroundDark = Color(0xFF1A1B1F) + val onBackgroundDark = Color(0xFFE3E2E6) + val surfaceDark = Color(0xFF1A1B1F) + val onSurfaceDark = Color(0xFFE3E2E6) + val surfaceVariantDark = Color(0xFF44474F) + val onSurfaceVariantDark = Color(0xFFC4C6CF) + val outlineDark = Color(0xFF8D9099) + val outlineVariantDark = Color(0xFF3F484A) + val inverseSurfaceDark = Color(0xFFE3E2E6) + val inverseOnSurfaceDark = Color(0xFF1A1B1F) + val redDark = Color(0xffff7961) + val onRedDark = Color(0xFFFFFFFF) +} diff --git a/app/src/main/java/io/github/sds100/keymapper/compose/ComposeCustomColors.kt b/app/src/main/java/io/github/sds100/keymapper/compose/ComposeCustomColors.kt new file mode 100644 index 0000000000..55545a9a3d --- /dev/null +++ b/app/src/main/java/io/github/sds100/keymapper/compose/ComposeCustomColors.kt @@ -0,0 +1,28 @@ +package io.github.sds100.keymapper.compose + +import androidx.compose.runtime.Immutable +import androidx.compose.ui.graphics.Color + +/** + * Stores the custom colors in a palette that changes + * depending on the light/dark theme. A CompositionLocalProvider + * is used in the KeyMapperTheme to provide the correct palette in a similar + * way to how MaterialTheme.current works. + */ +@Immutable +data class ComposeCustomColors( + val red: Color = Color.Unspecified, + val onRed: Color = Color.Unspecified, +) { + companion object { + val LightPalette = ComposeCustomColors( + red = ComposeColors.redLight, + onRed = ComposeColors.onRedLight, + ) + + val DarkPalette = ComposeCustomColors( + red = ComposeColors.redDark, + onRed = ComposeColors.onRedDark, + ) + } +} diff --git a/app/src/main/java/io/github/sds100/keymapper/compose/ComposeTheme.kt b/app/src/main/java/io/github/sds100/keymapper/compose/ComposeTheme.kt new file mode 100755 index 0000000000..380dabf5ad --- /dev/null +++ b/app/src/main/java/io/github/sds100/keymapper/compose/ComposeTheme.kt @@ -0,0 +1,119 @@ +package io.github.sds100.keymapper.compose + +import android.app.Activity +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Typography +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView +import androidx.core.view.WindowCompat + +object ComposeTheme { + val lightScheme = lightColorScheme( + primary = ComposeColors.primaryLight, + onPrimary = ComposeColors.onPrimaryLight, + primaryContainer = ComposeColors.primaryContainerLight, + onPrimaryContainer = ComposeColors.onPrimaryContainerLight, + secondary = ComposeColors.secondaryLight, + onSecondary = ComposeColors.onSecondaryLight, + secondaryContainer = ComposeColors.secondaryContainerLight, + onSecondaryContainer = ComposeColors.onSecondaryContainerLight, + tertiary = ComposeColors.tertiaryLight, + onTertiary = ComposeColors.onTertiaryLight, + tertiaryContainer = ComposeColors.tertiaryContainerLight, + onTertiaryContainer = ComposeColors.onTertiaryContainerLight, + error = ComposeColors.errorLight, + onError = ComposeColors.onErrorLight, + errorContainer = ComposeColors.errorContainerLight, + onErrorContainer = ComposeColors.onErrorContainerLight, + background = ComposeColors.backgroundLight, + onBackground = ComposeColors.onBackgroundLight, + surface = ComposeColors.surfaceLight, + onSurface = ComposeColors.onSurfaceLight, + surfaceVariant = ComposeColors.surfaceVariantLight, + onSurfaceVariant = ComposeColors.onSurfaceVariantLight, + outline = ComposeColors.outlineLight, + outlineVariant = ComposeColors.outlineVariantLight, + inverseSurface = ComposeColors.inverseSurfaceLight, + inverseOnSurface = ComposeColors.inverseOnSurfaceLight, + inversePrimary = ComposeColors.inversePrimaryLight, + ) + + val darkScheme = + darkColorScheme( + primary = ComposeColors.primaryDark, + onPrimary = ComposeColors.onPrimaryDark, + primaryContainer = ComposeColors.primaryContainerDark, + onPrimaryContainer = ComposeColors.onPrimaryContainerDark, + secondary = ComposeColors.secondaryDark, + onSecondary = ComposeColors.onSecondaryDark, + secondaryContainer = ComposeColors.secondaryContainerDark, + onSecondaryContainer = ComposeColors.onSecondaryContainerDark, + tertiary = ComposeColors.tertiaryDark, + onTertiary = ComposeColors.onTertiaryDark, + tertiaryContainer = ComposeColors.tertiaryContainerDark, + onTertiaryContainer = ComposeColors.onTertiaryContainerDark, + error = ComposeColors.errorDark, + onError = ComposeColors.onErrorDark, + errorContainer = ComposeColors.errorContainerDark, + onErrorContainer = ComposeColors.onErrorContainerDark, + background = ComposeColors.backgroundDark, + onBackground = ComposeColors.onBackgroundDark, + surface = ComposeColors.surfaceDark, + onSurface = ComposeColors.onSurfaceDark, + surfaceVariant = ComposeColors.surfaceVariantDark, + onSurfaceVariant = ComposeColors.onSurfaceVariantDark, + outline = ComposeColors.outlineDark, + outlineVariant = ComposeColors.outlineVariantDark, + inverseSurface = ComposeColors.inverseSurfaceDark, + inverseOnSurface = ComposeColors.inverseOnSurfaceDark, + ) +} + +val LocalCustomColorsPalette = staticCompositionLocalOf { ComposeCustomColors() } + +@Composable +fun KeyMapperTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + content: @Composable () -> Unit, +) { + val dynamicColor = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S + val colorScheme = when { + dynamicColor && darkTheme -> dynamicDarkColorScheme(LocalContext.current) + dynamicColor && !darkTheme -> dynamicLightColorScheme(LocalContext.current) + darkTheme -> ComposeTheme.darkScheme + else -> ComposeTheme.lightScheme + } + + val view = LocalView.current + if (!view.isInEditMode) { + SideEffect { + val window = (view.context as Activity).window + window.statusBarColor = colorScheme.surfaceContainer.toArgb() + WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme + } + } + + val customColorsPalette = + if (darkTheme) ComposeCustomColors.DarkPalette else ComposeCustomColors.LightPalette + + CompositionLocalProvider( + LocalCustomColorsPalette provides customColorsPalette, + ) { + MaterialTheme( + colorScheme = colorScheme, + typography = Typography(), + content = content, + ) + } +} diff --git a/app/src/main/java/io/github/sds100/keymapper/home/HomeFragment.kt b/app/src/main/java/io/github/sds100/keymapper/home/HomeFragment.kt index ea54c30689..ead9950f94 100644 --- a/app/src/main/java/io/github/sds100/keymapper/home/HomeFragment.kt +++ b/app/src/main/java/io/github/sds100/keymapper/home/HomeFragment.kt @@ -33,7 +33,6 @@ import io.github.sds100.keymapper.util.Inject import io.github.sds100.keymapper.util.QuickStartGuideTapTarget import io.github.sds100.keymapper.util.launchRepeatOnLifecycle import io.github.sds100.keymapper.util.str -import io.github.sds100.keymapper.util.strArray import io.github.sds100.keymapper.util.ui.TextListItem import io.github.sds100.keymapper.util.ui.setupNavigation import io.github.sds100.keymapper.util.ui.showPopups @@ -130,7 +129,8 @@ class HomeFragment : Fragment() { binding.viewPager.adapter = pagerAdapter TabLayoutMediator(binding.tabLayout, binding.viewPager) { tab, position -> - tab.text = strArray(R.array.home_tab_titles)[position] + val tabId = homeViewModel.tabsState.value.tabs[position] + tab.text = str(HomePagerAdapter.TAB_NAMES[tabId]!!) }.apply { attach() } diff --git a/app/src/main/java/io/github/sds100/keymapper/home/HomePagerAdapter.kt b/app/src/main/java/io/github/sds100/keymapper/home/HomePagerAdapter.kt index 75c15ed552..22b272df85 100644 --- a/app/src/main/java/io/github/sds100/keymapper/home/HomePagerAdapter.kt +++ b/app/src/main/java/io/github/sds100/keymapper/home/HomePagerAdapter.kt @@ -2,6 +2,7 @@ package io.github.sds100.keymapper.home import androidx.fragment.app.Fragment import androidx.viewpager2.adapter.FragmentStateAdapter +import io.github.sds100.keymapper.R import io.github.sds100.keymapper.mappings.fingerprintmaps.FingerprintMapListFragment import io.github.sds100.keymapper.mappings.keymaps.KeyMapListFragment @@ -13,7 +14,14 @@ class HomePagerAdapter( fragment: Fragment, ) : FragmentStateAdapter(fragment) { - private var tabs: Set = emptySet() + companion object { + val TAB_NAMES: Map = mapOf( + HomeTab.KEY_EVENTS to R.string.tab_keyevents, + HomeTab.FINGERPRINT_MAPS to R.string.tab_fingerprint, + ) + } + + private var tabs: List = emptyList() private val tabFragmentsCreators: List<() -> Fragment> get() = tabs.map { tab -> when (tab) { @@ -39,7 +47,7 @@ class HomePagerAdapter( override fun createFragment(position: Int) = tabFragmentsCreators[position].invoke() - fun invalidateFragments(tabs: Set) { + fun invalidateFragments(tabs: List) { if (this.tabs == tabs) return this.tabs = tabs diff --git a/app/src/main/java/io/github/sds100/keymapper/home/HomeViewModel.kt b/app/src/main/java/io/github/sds100/keymapper/home/HomeViewModel.kt index a04c79afd9..b3f6f3d6c1 100644 --- a/app/src/main/java/io/github/sds100/keymapper/home/HomeViewModel.kt +++ b/app/src/main/java/io/github/sds100/keymapper/home/HomeViewModel.kt @@ -144,7 +144,7 @@ class HomeViewModel( if (showFingerprintMaps) { yield(HomeTab.FINGERPRINT_MAPS) } - }.toSet() + }.toList() val showTabs = when { tabs.size == 1 -> false @@ -165,7 +165,7 @@ class HomeViewModel( HomeTabsState( enableViewPagerSwiping = false, showTabs = false, - emptySet(), + emptyList(), ), ) @@ -541,7 +541,7 @@ enum class HomeAppBarState { data class HomeTabsState( val enableViewPagerSwiping: Boolean = true, val showTabs: Boolean = false, - val tabs: Set, + val tabs: List, ) data class HomeErrorListState( diff --git a/app/src/main/java/io/github/sds100/keymapper/mappings/ConfigMappingFragment.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/ConfigMappingFragment.kt index 45c4e660f5..bc909bba47 100644 --- a/app/src/main/java/io/github/sds100/keymapper/mappings/ConfigMappingFragment.kt +++ b/app/src/main/java/io/github/sds100/keymapper/mappings/ConfigMappingFragment.kt @@ -78,6 +78,10 @@ abstract class ConfigMappingFragment : Fragment() { fragmentInfoList.map { it.first.toLong() to it.second.instantiate }, ) + // Don't show the swipe animations for reaching the end of the pager + // if there is only one page. + binding.viewPager.isUserInputEnabled = fragmentInfoList.size > 1 + TabLayoutMediator(binding.tabLayout, binding.viewPager) { tab, position -> val tabTitleRes = fragmentInfoList[position].second.header diff --git a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/ConfigKeyMapTriggerViewModel.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/ConfigKeyMapTriggerViewModel.kt index 194adcbde1..ff661846ad 100644 --- a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/ConfigKeyMapTriggerViewModel.kt +++ b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/ConfigKeyMapTriggerViewModel.kt @@ -2,6 +2,7 @@ package io.github.sds100.keymapper.mappings.keymaps import android.os.Build import android.view.KeyEvent +import androidx.compose.runtime.getValue import io.github.sds100.keymapper.R import io.github.sds100.keymapper.mappings.ClickType import io.github.sds100.keymapper.mappings.keymaps.trigger.KeyMapTrigger @@ -82,16 +83,11 @@ class ConfigKeyMapTriggerViewModel( */ val openEditOptions = _openEditOptions.asSharedFlow() - val recordTriggerButtonText: StateFlow = recordTrigger.state.map { recordTriggerState -> - when (recordTriggerState) { - is RecordTriggerState.CountingDown -> getString( - R.string.button_recording_trigger_countdown, - recordTriggerState.timeLeft, - ) - - RecordTriggerState.Stopped -> getString(R.string.button_record_trigger) - } - }.flowOn(Dispatchers.Default).stateIn(coroutineScope, SharingStarted.Lazily, "") + val recordTriggerState: StateFlow = recordTrigger.state.stateIn( + coroutineScope, + SharingStarted.Lazily, + RecordTriggerState.Stopped, + ) val triggerModeButtonsEnabled: StateFlow = config.mapping.map { state -> when (state) { @@ -136,6 +132,20 @@ class ConfigKeyMapTriggerViewModel( } }.flowOn(Dispatchers.Default).stateIn(coroutineScope, SharingStarted.Eagerly, false) + /** + * Only show the buttons for the trigger mode if keys have been added. The buttons + * shouldn't be shown when no trigger is selected because they aren't relevant + * for advanced triggers. + */ + val triggerModeRadioButtonsVisible: StateFlow = config.mapping + .map { state -> + when (state) { + is State.Data -> state.data.trigger.keys.isNotEmpty() + State.Loading -> false + } + } + .stateIn(coroutineScope, SharingStarted.Eagerly, false) + val doublePressButtonVisible: StateFlow = config.mapping.map { state -> when (state) { is State.Data -> state.data.trigger.keys.size == 1 diff --git a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/RecordTriggerButtonRow.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/RecordTriggerButtonRow.kt new file mode 100644 index 0000000000..c181a35057 --- /dev/null +++ b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/RecordTriggerButtonRow.kt @@ -0,0 +1,184 @@ +package io.github.sds100.keymapper.mappings.keymaps + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Badge +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +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.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import io.github.sds100.keymapper.R +import io.github.sds100.keymapper.compose.KeyMapperTheme +import io.github.sds100.keymapper.compose.LocalCustomColorsPalette +import io.github.sds100.keymapper.mappings.keymaps.trigger.AdvancedTriggersBottomSheet +import io.github.sds100.keymapper.mappings.keymaps.trigger.RecordTriggerState + +/** + * This row of buttons is shown at the bottom of the TriggerFragment. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun RecordTriggerButtonRow( + modifier: Modifier = Modifier, + viewModel: ConfigKeyMapTriggerViewModel, +) { + val recordTriggerState by viewModel.recordTriggerState.collectAsState() + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + var showBottomSheet: Boolean by rememberSaveable { mutableStateOf(false) } + + if (showBottomSheet) { + AdvancedTriggersBottomSheet( + modifier = Modifier.systemBarsPadding(), + onDismissRequest = { + showBottomSheet = false + }, + sheetState = sheetState, + ) + } + + RecordTriggerButtonRow( + modifier = modifier, + onRecordTriggerClick = viewModel::onRecordTriggerButtonClick, + recordTriggerState = recordTriggerState, + onAdvancedTriggersClick = { + showBottomSheet = true + }, + ) +} + +/** + * This row of buttons is shown at the bottom of the TriggerFragment. + */ +@Composable +private fun RecordTriggerButtonRow( + modifier: Modifier = Modifier, + onRecordTriggerClick: () -> Unit = {}, + recordTriggerState: RecordTriggerState, + onAdvancedTriggersClick: () -> Unit = {}, +) { + Row(modifier) { + RecordTriggerButton( + modifier = Modifier + .weight(1f) + .align(Alignment.Bottom), + recordTriggerState, + onClick = onRecordTriggerClick, + ) + + Spacer(modifier = Modifier.width(8.dp)) + + AdvancedTriggersButton( + modifier = Modifier.weight(1f), + isEnabled = recordTriggerState is RecordTriggerState.Stopped, + onClick = onAdvancedTriggersClick, + ) + } +} + +@Composable +private fun RecordTriggerButton( + modifier: Modifier, + state: RecordTriggerState, + onClick: () -> Unit, +) { + val colors = ButtonDefaults.filledTonalButtonColors().copy( + containerColor = LocalCustomColorsPalette.current.red, + contentColor = LocalCustomColorsPalette.current.onRed, + ) + + val text: String = when (state) { + is RecordTriggerState.CountingDown -> + stringResource(R.string.button_recording_trigger_countdown, state.timeLeft) + + RecordTriggerState.Stopped -> + stringResource(R.string.button_record_trigger) + } + + FilledTonalButton( + modifier = modifier, + onClick = onClick, + colors = colors, + ) { + Text(text) + } +} + +@Composable +private fun AdvancedTriggersButton( + modifier: Modifier, + isEnabled: Boolean, + onClick: () -> Unit, +) { + Box(modifier = modifier) { + OutlinedButton( + modifier = Modifier + .fillMaxWidth() + .padding(top = 20.dp), + enabled = isEnabled, + onClick = onClick, + ) { + Text(stringResource(R.string.button_advanced_triggers)) + } + + Badge( + modifier = Modifier + .align(Alignment.TopEnd) + .height(36.dp), + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer, + ) { + Text( + modifier = Modifier.padding(horizontal = 8.dp), + text = stringResource(R.string.button_advanced_triggers_badge), + style = MaterialTheme.typography.labelLarge, + ) + } + } +} + +@Preview(widthDp = 400) +@Composable +private fun PreviewCountingDown() { + KeyMapperTheme { + Surface { + RecordTriggerButtonRow( + modifier = Modifier.fillMaxWidth(), + recordTriggerState = RecordTriggerState.CountingDown(3), + ) + } + } +} + +@Preview(widthDp = 400) +@Composable +private fun PreviewStopped() { + KeyMapperTheme { + Surface { + RecordTriggerButtonRow( + modifier = Modifier.fillMaxWidth(), + recordTriggerState = RecordTriggerState.Stopped, + ) + } + } +} diff --git a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/detection/KeyMapController.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/detection/KeyMapController.kt index 39e497cebd..91e73c651f 100644 --- a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/detection/KeyMapController.kt +++ b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/detection/KeyMapController.kt @@ -57,12 +57,12 @@ class KeyMapController( * rather than the up event. */ fun performActionOnDown(trigger: KeyMapTrigger): Boolean = ( - trigger.keys.size <= 1 && - trigger.keys.getOrNull(0)?.clickType != ClickType.DOUBLE_PRESS && - trigger.mode == TriggerMode.Undefined - ) || + trigger.keys.size <= 1 && + trigger.keys.getOrNull(0)?.clickType != ClickType.DOUBLE_PRESS && + trigger.mode == TriggerMode.Undefined + ) || - trigger.mode is TriggerMode.Parallel + trigger.mode is TriggerMode.Parallel } /** @@ -127,9 +127,9 @@ class KeyMapController( } if (( - keyMap.trigger.mode == TriggerMode.Sequence || - keyMap.trigger.mode == TriggerMode.Undefined - ) && + keyMap.trigger.mode == TriggerMode.Sequence || + keyMap.trigger.mode == TriggerMode.Undefined + ) && key.clickType == ClickType.DOUBLE_PRESS ) { doublePressKeys.add(TriggerKeyLocation(triggerIndex, keyIndex)) @@ -155,9 +155,9 @@ class KeyMapController( if (keyMap.actionList.any { it.data is ActionData.InputKeyEvent && - isModifierKey( - it.data.keyCode, - ) + isModifierKey( + it.data.keyCode, + ) } ) { modifierKeyEventActions = true @@ -165,9 +165,9 @@ class KeyMapController( if (keyMap.actionList.any { it.data is ActionData.InputKeyEvent && - !isModifierKey( - it.data.keyCode, - ) + !isModifierKey( + it.data.keyCode, + ) } ) { notModifierKeyEventActions = true @@ -1473,30 +1473,30 @@ class KeyMapController( TriggerKeyDevice.Any -> this.keyCode == event.keyCode && this.clickType == event.clickType is TriggerKeyDevice.External -> this.keyCode == event.keyCode && - event.descriptor != null && - event.descriptor == this.device.descriptor && - this.clickType == event.clickType + event.descriptor != null && + event.descriptor == this.device.descriptor && + this.clickType == event.clickType TriggerKeyDevice.Internal -> this.keyCode == event.keyCode && - event.descriptor == null && - this.clickType == event.clickType + event.descriptor == null && + this.clickType == event.clickType } private fun TriggerKey.matchesWithOtherKey(otherKey: TriggerKey): Boolean = when (this.device) { TriggerKeyDevice.Any -> this.keyCode == otherKey.keyCode && - this.clickType == otherKey.clickType + this.clickType == otherKey.clickType is TriggerKeyDevice.External -> this.keyCode == otherKey.keyCode && - this.device == otherKey.device && - this.clickType == otherKey.clickType + this.device == otherKey.device && + this.clickType == otherKey.clickType TriggerKeyDevice.Internal -> this.keyCode == otherKey.keyCode && - otherKey.device == TriggerKeyDevice.Internal && - this.clickType == otherKey.clickType + otherKey.device == TriggerKeyDevice.Internal && + this.clickType == otherKey.clickType } private fun longPressDelay(trigger: KeyMapTrigger): Long = diff --git a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/TriggerFragment.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/TriggerFragment.kt index 0b1fd3b40e..3600cf1534 100644 --- a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/TriggerFragment.kt +++ b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/TriggerFragment.kt @@ -3,6 +3,9 @@ package io.github.sds100.keymapper.mappings.keymaps.trigger import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.lifecycle.Lifecycle import androidx.navigation.navGraphViewModels import androidx.recyclerview.widget.ItemTouchHelper @@ -12,15 +15,16 @@ import com.airbnb.epoxy.EpoxyTouchHelper import com.google.android.material.card.MaterialCardView import io.github.sds100.keymapper.R import io.github.sds100.keymapper.TriggerKeyBindingModel_ +import io.github.sds100.keymapper.compose.KeyMapperTheme import io.github.sds100.keymapper.databinding.FragmentTriggerBinding import io.github.sds100.keymapper.fixError import io.github.sds100.keymapper.mappings.keymaps.ConfigKeyMapTriggerViewModel import io.github.sds100.keymapper.mappings.keymaps.ConfigKeyMapViewModel +import io.github.sds100.keymapper.mappings.keymaps.RecordTriggerButtonRow import io.github.sds100.keymapper.triggerKey import io.github.sds100.keymapper.util.FragmentInfo import io.github.sds100.keymapper.util.Inject import io.github.sds100.keymapper.util.State -import io.github.sds100.keymapper.util.color import io.github.sds100.keymapper.util.launchRepeatOnLifecycle import io.github.sds100.keymapper.util.ui.RecyclerViewFragment import kotlinx.coroutines.flow.Flow @@ -53,13 +57,22 @@ class TriggerFragment : RecyclerViewFragment { state.value = getState() @@ -171,8 +181,8 @@ class AccessibilityServiceAdapter( settingsIntent.addFlags( Intent.FLAG_ACTIVITY_NEW_TASK - or Intent.FLAG_ACTIVITY_CLEAR_TASK - or Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS, + or Intent.FLAG_ACTIVITY_CLEAR_TASK + or Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS, ) ctx.startActivity(settingsIntent) diff --git a/app/src/main/java/io/github/sds100/keymapper/system/accessibility/AccessibilityServiceController.kt b/app/src/main/java/io/github/sds100/keymapper/system/accessibility/BaseAccessibilityServiceController.kt similarity index 97% rename from app/src/main/java/io/github/sds100/keymapper/system/accessibility/AccessibilityServiceController.kt rename to app/src/main/java/io/github/sds100/keymapper/system/accessibility/BaseAccessibilityServiceController.kt index 9b3775514b..1e635420a3 100644 --- a/app/src/main/java/io/github/sds100/keymapper/system/accessibility/AccessibilityServiceController.kt +++ b/app/src/main/java/io/github/sds100/keymapper/system/accessibility/BaseAccessibilityServiceController.kt @@ -54,7 +54,7 @@ import timber.log.Timber /** * Created by sds100 on 17/04/2021. */ -class AccessibilityServiceController( +abstract class BaseAccessibilityServiceController( private val coroutineScope: CoroutineScope, private val accessibilityService: IAccessibilityService, private val inputEvents: SharedFlow, @@ -92,7 +92,7 @@ class AccessibilityServiceController( detectConstraintsUseCase, ) - private val keymapDetectionDelegate = KeyMapController( + private val keyMapController = KeyMapController( coroutineScope, detectKeyMapsUseCase, performActionsUseCase, @@ -123,7 +123,7 @@ class AccessibilityServiceController( if (!isPaused.value) { withContext(Dispatchers.Main.immediate) { - keymapDetectionDelegate.onKeyEvent( + keyMapController.onKeyEvent( keyCode, action, metaState = 0, @@ -196,7 +196,7 @@ class AccessibilityServiceController( } pauseMappingsUseCase.isPaused.distinctUntilChanged().onEach { - keymapDetectionDelegate.reset() + keyMapController.reset() fingerprintMapController.reset() triggerKeyMapFromOtherAppsController.reset() }.launchIn(coroutineScope) @@ -324,7 +324,7 @@ class AccessibilityServiceController( try { var consume: Boolean - consume = keymapDetectionDelegate.onKeyEvent( + consume = keyMapController.onKeyEvent( keyCode, action, metaState, @@ -426,7 +426,7 @@ class AccessibilityServiceController( triggerKeyMapFromOtherAppsController.onDetected(uid) } - private fun onEventFromUi(event: ServiceEvent) { + open fun onEventFromUi(event: ServiceEvent) { Timber.d("Service received event from UI: $event") when (event) { is ServiceEvent.StartRecordingTrigger -> @@ -449,7 +449,10 @@ class AccessibilityServiceController( is ServiceEvent.TestAction -> performActionsUseCase.perform(event.action) - is ServiceEvent.Ping -> coroutineScope.launch { outputEvents.emit(ServiceEvent.Pong(event.key)) } + is ServiceEvent.Ping -> coroutineScope.launch { + outputEvents.emit(ServiceEvent.Pong(event.key)) + } + is ServiceEvent.HideKeyboard -> accessibilityService.hideKeyboard() is ServiceEvent.ShowKeyboard -> accessibilityService.showKeyboard() is ServiceEvent.ChangeIme -> accessibilityService.switchIme(event.imeId) diff --git a/app/src/main/res/layout-w1000dp-h400dp-land/fragment_trigger.xml b/app/src/main/res/layout-w1000dp-h400dp-land/fragment_trigger.xml index bcb30aa085..93db4f2df4 100644 --- a/app/src/main/res/layout-w1000dp-h400dp-land/fragment_trigger.xml +++ b/app/src/main/res/layout-w1000dp-h400dp-land/fragment_trigger.xml @@ -134,7 +134,7 @@ android:checkedButton="@{viewModel.checkedTriggerModeRadioButton}" android:gravity="bottom" android:orientation="horizontal" - app:layout_constraintBottom_toTopOf="@+id/buttonRecordKeys" + app:layout_constraintBottom_toTopOf="@+id/composeViewRecordTriggerButtons" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent"> @@ -166,21 +166,19 @@ - diff --git a/app/src/main/res/layout-w600dp-land/fragment_trigger.xml b/app/src/main/res/layout-w600dp-land/fragment_trigger.xml index 0d69de3ffc..3955cbf244 100644 --- a/app/src/main/res/layout-w600dp-land/fragment_trigger.xml +++ b/app/src/main/res/layout-w600dp-land/fragment_trigger.xml @@ -83,11 +83,11 @@ android:layout_height="wrap_content" android:layout_marginStart="@dimen/cardview_padding_left" android:layout_marginEnd="@dimen/cardview_padding_right" - android:onCheckedChanged="@{(radioGroup, checkedId) -> viewModel.onClickTypeRadioButtonCheckedChange(checkedId)}" + android:checkedButton="@{viewModel.checkedClickTypeRadioButton}" android:gravity="bottom" + android:onCheckedChanged="@{(radioGroup, checkedId) -> viewModel.onClickTypeRadioButtonCheckedChange(checkedId)}" android:orientation="horizontal" android:visibility="@{viewModel.clickTypeRadioButtonsVisible ? View.VISIBLE : View.GONE}" - android:checkedButton="@{viewModel.checkedClickTypeRadioButton}" app:layout_constraintBottom_toTopOf="@+id/textViewRadioGroupHeader" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="@+id/recyclerViewError"> @@ -137,7 +137,7 @@ android:checkedButton="@{viewModel.checkedTriggerModeRadioButton}" android:gravity="bottom" android:orientation="horizontal" - app:layout_constraintBottom_toTopOf="@+id/buttonRecordKeys" + app:layout_constraintBottom_toTopOf="@+id/composeViewRecordTriggerButtons" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="@id/recyclerViewError"> @@ -145,18 +145,18 @@ android:id="@+id/radioButtonParallel" android:layout_width="match_parent" android:layout_height="wrap_content" - android:onCheckedChanged="@{(view, isChecked) -> viewModel.onParallelRadioButtonCheckedChange(isChecked)}" android:layout_weight="0.5" android:enabled="@{viewModel.triggerModeButtonsEnabled}" + android:onCheckedChanged="@{(view, isChecked) -> viewModel.onParallelRadioButtonCheckedChange(isChecked)}" android:text="@string/radio_button_parallel" /> - diff --git a/app/src/main/res/layout-w900dp-h600dp/fragment_trigger.xml b/app/src/main/res/layout-w900dp-h600dp/fragment_trigger.xml index 8531fb1849..9877ca3d04 100644 --- a/app/src/main/res/layout-w900dp-h600dp/fragment_trigger.xml +++ b/app/src/main/res/layout-w900dp-h600dp/fragment_trigger.xml @@ -84,7 +84,7 @@ android:onCheckedChanged="@{(radioGroup, checkedId) -> viewModel.onClickTypeRadioButtonCheckedChange(checkedId)}" android:orientation="horizontal" android:visibility="@{viewModel.clickTypeRadioButtonsVisible ? View.VISIBLE : View.GONE}" - app:layout_constraintBottom_toTopOf="@id/buttonRecordKeys" + app:layout_constraintBottom_toTopOf="@id/composeViewRecordTriggerButtons" app:layout_constraintEnd_toStartOf="@id/radioGroupTriggerMode" app:layout_constraintStart_toStartOf="parent"> @@ -131,7 +131,7 @@ android:checkedButton="@{viewModel.checkedTriggerModeRadioButton}" android:gravity="bottom" android:orientation="horizontal" - app:layout_constraintBottom_toTopOf="@+id/buttonRecordKeys" + app:layout_constraintBottom_toTopOf="@+id/composeViewRecordTriggerButtons" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toEndOf="@+id/radioGroupClickType"> @@ -139,18 +139,18 @@ android:id="@+id/radioButtonParallel" android:layout_width="match_parent" android:layout_height="wrap_content" - android:onCheckedChanged="@{(view, isChecked) -> viewModel.onParallelRadioButtonCheckedChange(isChecked)}" android:layout_weight="0.5" android:enabled="@{viewModel.triggerModeButtonsEnabled}" + android:onCheckedChanged="@{(view, isChecked) -> viewModel.onParallelRadioButtonCheckedChange(isChecked)}" android:text="@string/radio_button_parallel" /> @@ -163,21 +163,19 @@ android:visibility="gone" /> - diff --git a/app/src/main/res/layout/fragment_compose_view.xml b/app/src/main/res/layout/fragment_compose_view.xml new file mode 100644 index 0000000000..cf6151272e --- /dev/null +++ b/app/src/main/res/layout/fragment_compose_view.xml @@ -0,0 +1,16 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_trigger.xml b/app/src/main/res/layout/fragment_trigger.xml index bcb30aa085..65d5b2269d 100644 --- a/app/src/main/res/layout/fragment_trigger.xml +++ b/app/src/main/res/layout/fragment_trigger.xml @@ -67,10 +67,10 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_gravity="center" - android:layout_marginStart="16dp" - android:layout_marginTop="16dp" - android:layout_marginEnd="16dp" - android:layout_marginBottom="16dp" + android:layout_marginStart="32dp" + android:layout_marginTop="32dp" + android:layout_marginEnd="32dp" + android:layout_marginBottom="32dp" android:text="@string/triggers_recyclerview_placeholder" /> @@ -121,6 +121,7 @@ android:paddingTop="8dp" android:text="@string/press_dot_dot_dot" android:textAppearance="@style/TextAppearance.Material3.LabelMedium" + android:visibility="@{viewModel.triggerModeRadioButtonsVisible ? View.VISIBLE : View.GONE}" app:layout_constraintBottom_toTopOf="@+id/radioGroupTriggerMode" app:layout_constraintStart_toStartOf="parent" /> @@ -134,7 +135,8 @@ android:checkedButton="@{viewModel.checkedTriggerModeRadioButton}" android:gravity="bottom" android:orientation="horizontal" - app:layout_constraintBottom_toTopOf="@+id/buttonRecordKeys" + android:visibility="@{viewModel.triggerModeRadioButtonsVisible ? View.VISIBLE : View.GONE}" + app:layout_constraintBottom_toTopOf="@+id/composeViewRecordTriggerButtons" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent"> @@ -166,21 +168,19 @@ - diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3faa26d102..20b83ce663 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -10,7 +10,7 @@ Open ¯\\_(ツ)_/¯ ¯\\_(ツ)_/¯\n\nNothing here! - Record a trigger! + The first step is to add some buttons that will trigger the key map.\n\nFirst tap ‘Record trigger’ and then press the buttons that you want to remap. They will appear here.\n\nAlternatively, you can trigger a key map using an ‘advanced trigger’. Add an action! ¯\\_(ツ)_/¯\n\nCreate a key map! ¯\\_(ツ)_/¯\n\nYou haven\'t chosen any actions for this shortcut! @@ -209,11 +209,6 @@ Trigger Key events Fingerprint - - - @string/tab_keyevents - @string/tab_fingerprint - @@ -453,6 +448,9 @@ Add action Record trigger + Advanced triggers + NEW! + Dismiss choosing advanced triggers. Done Save Fix diff --git a/build.gradle b/build.gradle index e40c753794..a891f2f9bb 100644 --- a/build.gradle +++ b/build.gradle @@ -1,6 +1,6 @@ buildscript { - ext.kotlin_version = '1.8.20' + ext.kotlin_version = '1.9.22' repositories { google() diff --git a/settings.gradle b/settings.gradle index 41ccca99a4..a2a582b50a 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,3 +1,2 @@ include ':app' include ':systemstubs' -include ':pro'