Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Taskerplugin #431

Merged
merged 8 commits into from
Oct 24, 2022
Merged
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
-----

* New Features:
* Added Tasker integration with "Play Filter" and "Control Playback" actions.
([#415](https://github.com/Automattic/pocket-casts-android/pull/431)).
* Fixed background color for screens using the compose theme
([#432](https://github.com/Automattic/pocket-casts-android/pull/432)).
* Bug Fixes:
Expand Down
1 change: 1 addition & 0 deletions app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ dependencies {
implementation project(':modules:features:filters')
implementation project(':modules:features:navigation')
implementation project(':modules:features:account')
implementation project(':modules:features:taskerplugin')
implementation project(':modules:features:endofyear')
}

Expand Down
21 changes: 21 additions & 0 deletions modules/features/taskerplugin/build.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
apply from: "../../modules.gradle"

android {
namespace 'au.com.shiftyjelly.pocketcasts.taskerplugin'
buildFeatures {
viewBinding true
dataBinding = true
compose true
}
}

dependencies {
implementation project(':modules:services:localization')
implementation project(':modules:services:ui')
implementation project(':modules:services:compose')
implementation project(':modules:services:repositories')
implementation project(':modules:services:model')
implementation project(':modules:services:views')
implementation project(':modules:services:images')
implementation 'com.joaomgcd:taskerpluginlibrary:0.4.3'
}
25 changes: 25 additions & 0 deletions modules/features/taskerplugin/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http:https://schemas.android.com/apk/res/android">

<application>

<activity
android:name=".playplaylist.config.ActivityConfigPlayPlaylist"
android:exported="true"
android:icon="@mipmap/ic_launcher"
android:label="Pocket Casts Play Filter">
<intent-filter>
<action android:name="com.twofortyfouram.locale.intent.action.EDIT_SETTING" />
</intent-filter>
</activity>
<activity
android:name=".controlplayback.config.ActivityConfigControlPlayback"
android:exported="true"
android:icon="@mipmap/ic_launcher"
android:label="Pocket Casts Control Playback">
<intent-filter>
<action android:name="com.twofortyfouram.locale.intent.action.EDIT_SETTING" />
</intent-filter>
</activity>
</application>
</manifest>
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package au.com.shiftyjelly.pocketcasts.taskerplugin.base

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.runtime.Composable
import au.com.shiftyjelly.pocketcasts.compose.AppThemeWithBackground
import au.com.shiftyjelly.pocketcasts.taskerplugin.base.hilt.appTheme

abstract class ActivityConfigBase<TViewModel : ViewModelBase<*, *>> : ComponentActivity() {
protected abstract val viewModel: TViewModel
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
viewModel.onCreate({ finish() }, { intent }, { code, data -> setResult(code, data) })
setContent {
AppThemeWithBackground(themeType = appTheme.activeTheme) {
Content()
}
}
}
@Composable
protected abstract fun Content()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
package au.com.shiftyjelly.pocketcasts.taskerplugin.base

import androidx.annotation.StringRes
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredSizeIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.Button
import androidx.compose.material.DropdownMenu
import androidx.compose.material.DropdownMenuItem
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.PopupProperties
import au.com.shiftyjelly.pocketcasts.compose.AppTheme
import au.com.shiftyjelly.pocketcasts.compose.theme
import au.com.shiftyjelly.pocketcasts.localization.R
import au.com.shiftyjelly.pocketcasts.ui.theme.Theme

class TaskerInputFieldState<T>(val content: Content<T>) {
data class Content<T> constructor(
val value: String?,
@StringRes val labelResId: Int,
val onTextChange: (String) -> Unit,
val taskerVariables: List<String>,
val possibleItems: List<T>? = null,
val itemToString: (T?) -> String = { it?.toString() ?: "" },
val itemContent: @Composable (T) -> Unit = { Text(text = itemToString(it)) }
)
}

private enum class TaskerInputFieldSelectMode { Variable, ItemList }

@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun <T> ComposableTaskerInputField(content: TaskerInputFieldState.Content<T>) {
var selectionMode by remember { mutableStateOf(null as TaskerInputFieldSelectMode?) }
val keyboardController = LocalSoftwareKeyboardController.current

/**
* @param selection if null, just hide dropdown and don't signal text change
*/
fun finishSelecting(selection: String? = null) {
selectionMode = null
keyboardController?.hide()
selection?.let { content.onTextChange(it) }
}
Box {

Row {
val possibleItems = content.possibleItems

val hasSuggestedItems = !possibleItems.isNullOrEmpty()
val hasTaskerVariables = content.taskerVariables.isNotEmpty()
OutlinedTextField(
joaomgcd marked this conversation as resolved.
Show resolved Hide resolved
modifier = Modifier.weight(1f),
value = content.value ?: "",
label = { Text(text = stringResource(id = content.labelResId)) },
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { finishSelecting() }),
onValueChange = {
content.onTextChange(it)
},
trailingIcon = if (!hasSuggestedItems && !hasTaskerVariables) null else {
{
Row {
if (hasTaskerVariables) {
IconButton(onClick = { selectionMode = TaskerInputFieldSelectMode.Variable }) {
Icon(
painter = painterResource(au.com.shiftyjelly.pocketcasts.taskerplugin.R.drawable.label_outline),
contentDescription = stringResource(R.string.tasker_variables),
tint = MaterialTheme.theme.colors.primaryIcon01,
modifier = Modifier.padding(end = 16.dp, start = 16.dp)
)
}
}
if (hasSuggestedItems) {
IconButton(onClick = { selectionMode = TaskerInputFieldSelectMode.ItemList }) {
Icon(
painter = painterResource(au.com.shiftyjelly.pocketcasts.images.R.drawable.ic_search),
contentDescription = stringResource(R.string.search),
tint = MaterialTheme.theme.colors.primaryIcon01,
modifier = Modifier.padding(end = 16.dp, start = 16.dp)
)
}
}
}
}
}
)
val dropdownMaxHeight = screenSize.height / 6 * 2 //at most dropdown can be 2/6 of the screen size so it doesn't draw over its parent
if (hasTaskerVariables) {
DropdownMenu(
modifier = Modifier.requiredSizeIn(maxHeight = dropdownMaxHeight),
expanded = selectionMode == TaskerInputFieldSelectMode.Variable,
onDismissRequest = { finishSelecting() },
properties = PopupProperties(focusable = false)
) {
content.taskerVariables.forEach {
DropdownMenuItem(onClick = {
finishSelecting(it)
}) {
Text(it)
}
}
}
}
if (possibleItems != null && possibleItems.isNotEmpty()) {
DropdownMenu(
modifier = Modifier.requiredSizeIn(maxHeight = dropdownMaxHeight),
expanded = selectionMode == TaskerInputFieldSelectMode.ItemList,
onDismissRequest = { finishSelecting() },
properties = PopupProperties(focusable = false)
) {
possibleItems.forEach {
DropdownMenuItem(onClick = {
finishSelecting(content.itemToString(it))
}) {
content.itemContent(it)
}
}
}
}
}
}
}

@Preview(showBackground = true)
@Composable
private fun ComposableTaskerInputFieldPreview() {
AppTheme(Theme.ThemeType.CLASSIC_LIGHT) {
ComposableTaskerInputField(
TaskerInputFieldState.Content(
"some value", R.string.archive, {},
listOf("%test"),
listOf("Hi", "Hello")
)
)
}
}

@Composable
fun ComposableTaskerInputFieldList(
fieldContents: List<TaskerInputFieldState.Content<*>>,
onFinish: () -> Unit
) {
Box(modifier = Modifier.fillMaxHeight()) {
LazyColumn {
fieldContents.forEach { content ->
item {
ComposableTaskerInputField(content)
}
}
}
Button(onClick = onFinish, modifier = Modifier.align(Alignment.BottomEnd)) {
Text(stringResource(R.string.ok))
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package au.com.shiftyjelly.pocketcasts.taskerplugin.base

import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp

val String?.nullIfEmpty get() = if (isNullOrEmpty()) null else this
fun <T> tryOrNull(handleError: ((Throwable) -> T?)? = null, block: () -> T?): T? = try {
block()
} catch (t: Throwable) {
handleError?.invoke(t)
}

val screenSize
@Composable
get() :DpSize {
val configuration = LocalConfiguration.current

val screenHeight = configuration.screenHeightDp.dp
val screenWidth = configuration.screenWidthDp.dp
return DpSize(width = screenWidth, height = screenHeight)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package au.com.shiftyjelly.pocketcasts.taskerplugin.base

import android.app.Application
import android.content.Intent
import androidx.lifecycle.AndroidViewModel
import au.com.shiftyjelly.pocketcasts.taskerplugin.controlplayback.InputControlPlayback
import com.joaomgcd.taskerpluginlibrary.action.TaskerPluginRunnerActionNoOutput
import com.joaomgcd.taskerpluginlibrary.config.TaskerPluginConfig
import com.joaomgcd.taskerpluginlibrary.config.TaskerPluginConfigHelperNoOutput
import com.joaomgcd.taskerpluginlibrary.input.TaskerInput
import kotlinx.coroutines.flow.MutableStateFlow

abstract class ViewModelBase<TInput : Any, THelper : TaskerPluginConfigHelperNoOutput<TInput, out TaskerPluginRunnerActionNoOutput<TInput>>>(application: Application) : AndroidViewModel(application), TaskerPluginConfig<TInput> {
override val context get() = getApplication<Application>()
abstract val helperClass: Class<THelper>
private val taskerHelper by lazy { helperClass.getConstructor(TaskerPluginConfig::class.java).newInstance(this) }
protected var input: TInput? = null

private var taskerInput
get() = TaskerInput(input ?: taskerHelper.inputClass.newInstance())
set(value) {
input = value.regular
}

override val inputForTasker: TaskerInput<TInput>
get() = taskerInput

fun getDescription(command: InputControlPlayback.PlaybackCommand) = command.getDescription(context)
override fun assignFromInput(input: TaskerInput<TInput>) {
taskerInput = input
}

fun onCreate(finishFunc: (() -> Unit), getIntentFunc: (() -> Intent?), setResultFunc: ((Int, Intent) -> Unit)) {
this.finishFunc = finishFunc
this.getIntentFunc = getIntentFunc
this.setResultFunc = setResultFunc
taskerHelper.onCreate()
}

private var finishFunc: (() -> Unit)? = null
override fun finish() = finishFunc?.invoke() ?: Unit

private var getIntentFunc: (() -> Intent?)? = null
override fun getIntent() = getIntentFunc?.invoke()

private var setResultFunc: ((Int, Intent) -> Unit)? = null
override fun setResult(resultCode: Int, data: Intent) = setResultFunc?.invoke(resultCode, data) ?: Unit

fun finishForTasker() = taskerHelper.finishForTasker()

val taskerVariables by lazy { taskerHelper.relevantVariables.distinct().sortedBy { it } }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package au.com.shiftyjelly.pocketcasts.taskerplugin.base.hilt

import android.content.Context
import au.com.shiftyjelly.pocketcasts.repositories.playback.PlaybackManager
import au.com.shiftyjelly.pocketcasts.repositories.podcast.EpisodeManager
import au.com.shiftyjelly.pocketcasts.repositories.podcast.PlaylistManager
import au.com.shiftyjelly.pocketcasts.ui.theme.Theme
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.components.SingletonComponent

@InstallIn(SingletonComponent::class)
@EntryPoint
interface ThemeEntryPoint {
fun getTheme(): Theme
}
@InstallIn(SingletonComponent::class)
@EntryPoint
interface PlaybackManagerEntryPoint {
fun getPlaybackManager(): PlaybackManager
}

@InstallIn(SingletonComponent::class)
@EntryPoint
interface PlaylistManagerEntryPoint {
fun getPlaylistManager(): PlaylistManager
}

@InstallIn(SingletonComponent::class)
@EntryPoint
interface EpisodeManagerEntryPoint {
fun getEpisodeManager(): EpisodeManager
}

val Context.appTheme get() = EntryPointAccessors.fromApplication(applicationContext, ThemeEntryPoint::class.java).getTheme()
val Context.playbackManager get() = EntryPointAccessors.fromApplication(applicationContext, PlaybackManagerEntryPoint::class.java).getPlaybackManager()
val Context.playlistManager get() = EntryPointAccessors.fromApplication(applicationContext, PlaylistManagerEntryPoint::class.java).getPlaylistManager()
val Context.episodeManager get() = EntryPointAccessors.fromApplication(applicationContext, EpisodeManagerEntryPoint::class.java).getEpisodeManager()
Loading