Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Unverified Commit c0878344 authored by Arnau Mora's avatar Arnau Mora Committed by GitHub
Browse files

Rewrite TasksFragment to Compose (#481)



* Added `CardWithImage`

Signed-off-by: default avatarArnau Mora <arnyminerz@proton.me>

* Added `RadioWithSwitch`

Signed-off-by: default avatarArnau Mora <arnyminerz@proton.me>

* Migrating to Compose

Signed-off-by: default avatarArnau Mora <arnyminerz@proton.me>

* Added observers

Signed-off-by: default avatarArnau Mora <arnyminerz@proton.me>

* Fixed functions signature

Signed-off-by: default avatarArnau Mora <arnyminerz@proton.me>

* Added kdoc

Signed-off-by: default avatarArnau Mora <arnyminerz@proton.me>

* Removed layout

Signed-off-by: default avatarArnau Mora <arnyminerz@proton.me>

* Color for disabled

Signed-off-by: default avatarArnau Mora <arnyminerz@proton.me>

* Added "don't show" behaviour

Signed-off-by: default avatarArnau Mora <arnyminerz@proton.me>

* Added all tasks providers

Signed-off-by: default avatarArnau Mora <arnyminerz@proton.me>

* Moved checkbox to correct location

Signed-off-by: default avatarArnau Mora <arnyminerz@proton.me>

* Fixed don't need behaviour

Signed-off-by: default avatarArnau Mora <arnyminerz@proton.me>

* Added theme

Signed-off-by: default avatarArnau Mora <arnyminerz@proton.me>

* Added todo

Signed-off-by: default avatarArnau Mora <arnyminerz@proton.me>

* Added support for link annotations

Signed-off-by: default avatarArnau Mora <arnyminerz@proton.me>

* Added support for annotated strings and urls

Signed-off-by: default avatarArnau Mora <arnyminerz@proton.me>

* Added tests for HTML annotation

Signed-off-by: default avatarArnau Mora <arnyminerz@proton.me>

* Extracted `linkStyle`

Signed-off-by: default avatarArnau Mora <arnyminerz@proton.me>

* Removed observers for requested

Signed-off-by: default avatarArnau Mora <arnyminerz@proton.me>

* Removed more observers

Signed-off-by: default avatarArnau Mora <arnyminerz@proton.me>

* Added multiple links test

Signed-off-by: default avatarArnau Mora <arnyminerz@proton.me>

* Moved `installApp` to `TasksCard`

Signed-off-by: default avatarArnau Mora <arnyminerz@proton.me>

* Moved all model calls to composable

Signed-off-by: default avatarArnau Mora <arnyminerz@proton.me>

* Removed preview since not usable

Signed-off-by: default avatarArnau Mora <arnyminerz@proton.me>

* Got rid of TasksFragment

Signed-off-by: default avatarArnau Mora <arnyminerz@proton.me>

* Fixed import

Signed-off-by: default avatarArnau Mora <arnyminerz@proton.me>

* Switched link color to orange

Signed-off-by: default avatarArnau Mora <arnyminerz@proton.me>

* Added missing copyright information

Signed-off-by: default avatarArnau Mora <arnyminerz@proton.me>

* Use HtmlCompat and existing Spanned.toAnnotatedString

* Added default content

Signed-off-by: default avatarArnau Mora Gras <arnyminerz@proton.me>

* Renamed image content description

Signed-off-by: default avatarArnau Mora Gras <arnyminerz@proton.me>

* Got rid of empty content

Signed-off-by: default avatarArnau Mora Gras <arnyminerz@proton.me>

* Made summary of RadioWithSwitch composable

Signed-off-by: default avatarArnau Mora Gras <arnyminerz@proton.me>

* Added missing entry point annotation

Signed-off-by: default avatarArnau Mora Gras <arnyminerz@proton.me>

* Added click handling for tasks org

Signed-off-by: default avatarArnau Mora Gras <arnyminerz@proton.me>

* Got rid of the preview provider

Signed-off-by: default avatarArnau Mora Gras <arnyminerz@proton.me>

* Minor changes

---------

Signed-off-by: default avatarArnau Mora <arnyminerz@proton.me>
Signed-off-by: default avatarArnau Mora Gras <arnyminerz@proton.me>
Co-authored-by: default avatarRicki Hirner <hirner@bitfire.at>
parent da5b765b
Loading
Loading
Loading
Loading
+1 −1
Original line number Diff line number Diff line
@@ -285,7 +285,7 @@ class AppSettingsActivity: AppCompatActivity() {
        private fun resetHints() {
            settings.remove(BatteryOptimizationsFragment.Model.HINT_BATTERY_OPTIMIZATIONS)
            settings.remove(BatteryOptimizationsFragment.Model.HINT_AUTOSTART_PERMISSION)
            settings.remove(TasksFragment.Model.HINT_OPENTASKS_NOT_INSTALLED)
            settings.remove(TasksModel.HINT_OPENTASKS_NOT_INSTALLED)
            settings.remove(OpenSourceFragment.Model.SETTING_NEXT_DONATION_POPUP)
            Snackbar.make(requireView(), R.string.app_settings_reset_hints_success, Snackbar.LENGTH_LONG).show()
        }
+10 −6
Original line number Diff line number Diff line
@@ -5,19 +5,23 @@
package at.bitfire.davdroid.ui

import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import com.google.accompanist.themeadapter.material.MdcTheme
import dagger.hilt.android.AndroidEntryPoint

@AndroidEntryPoint
class TasksActivity: AppCompatActivity() {
    val model: TasksModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        if (savedInstanceState == null)
            supportFragmentManager.beginTransaction()
                    .add(android.R.id.content, TasksFragment())
                    .commit()
        setContent {
            MdcTheme {
                TasksCard(model)
            }
        }
    }

}
+260 −146
Original line number Diff line number Diff line
@@ -9,99 +9,55 @@ import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.annotation.AnyThread
import androidx.databinding.ObservableBoolean
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.compose.foundation.clickable
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.foundation.rememberScrollState
import androidx.compose.foundation.text.ClickableText
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.Checkbox
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Scaffold
import androidx.compose.material.SnackbarDuration
import androidx.compose.material.SnackbarHost
import androidx.compose.material.SnackbarHostState
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.ExperimentalTextApi
import androidx.compose.ui.unit.dp
import androidx.core.text.HtmlCompat
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.Observer
import androidx.lifecycle.viewmodel.compose.viewModel
import at.bitfire.davdroid.BuildConfig
import at.bitfire.davdroid.PackageChangedReceiver
import at.bitfire.davdroid.R
import at.bitfire.davdroid.databinding.ActivityTasksBinding
import at.bitfire.davdroid.resource.TaskUtils
import at.bitfire.davdroid.settings.SettingsManager
import at.bitfire.davdroid.ui.UiUtils.toAnnotatedString
import at.bitfire.davdroid.ui.widget.CardWithImage
import at.bitfire.davdroid.ui.widget.RadioWithSwitch
import at.bitfire.ical4android.TaskProvider.ProviderName
import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.launch
import javax.inject.Inject

@AndroidEntryPoint
class TasksFragment: Fragment() {

    private var _binding: ActivityTasksBinding? = null
    private val binding get() = _binding!!
    val model by viewModels<Model>()


    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
        _binding = ActivityTasksBinding.inflate(inflater, container, false)
        binding.lifecycleOwner = viewLifecycleOwner
        binding.model = model

        model.openTasksRequested.observe(viewLifecycleOwner) { shallBeInstalled ->
            if (shallBeInstalled && model.openTasksInstalled.value == false) {
                // uncheck switch for the moment (until the app is installed)
                model.openTasksRequested.value = false
                installApp(ProviderName.OpenTasks.packageName)
            }
        }
        model.openTasksSelected.observe(viewLifecycleOwner) { selected ->
            if (selected && model.currentProvider.value != ProviderName.OpenTasks)
                model.selectPreferredProvider(ProviderName.OpenTasks)
        }

        model.tasksOrgRequested.observe(viewLifecycleOwner) { shallBeInstalled ->
            if (shallBeInstalled && model.tasksOrgInstalled.value == false) {
                model.tasksOrgRequested.value = false
                installApp(ProviderName.TasksOrg.packageName)
            }
        }
        model.tasksOrgSelected.observe(viewLifecycleOwner) { selected ->
            if (selected && model.currentProvider.value != ProviderName.TasksOrg)
                model.selectPreferredProvider(ProviderName.TasksOrg)
        }


        model.jtxRequested.observe(viewLifecycleOwner) { shallBeInstalled ->
            if (shallBeInstalled && model.jtxInstalled.value == false) {
                model.jtxRequested.value = false
                installApp(ProviderName.JtxBoard.packageName)
            }
        }
        model.jtxSelected.observe(viewLifecycleOwner) { selected ->
            if (selected && model.currentProvider.value != ProviderName.JtxBoard)
                model.selectPreferredProvider(ProviderName.JtxBoard)
        }

        binding.infoLeaveUnchecked.text = getString(R.string.intro_leave_unchecked, getString(R.string.app_settings_reset_hints))

        return binding.root
    }

    override fun onDestroyView() {
        super.onDestroyView()
        _binding = null
    }

    private fun installApp(packageName: String) {
        val uri = Uri.parse("market://details?id=$packageName&referrer=" +
                Uri.encode("utm_source=" + BuildConfig.APPLICATION_ID))
        val intent = Intent(Intent.ACTION_VIEW, uri)
        if (intent.resolveActivity(requireActivity().packageManager) != null)
            startActivity(intent)
        else
            Snackbar.make(binding.frame, R.string.intro_tasks_no_app_store, Snackbar.LENGTH_LONG).show()
    }


@HiltViewModel
    class Model @Inject constructor(
class TasksModel @Inject constructor(
    application: Application,
    val settings: SettingsManager
) : AndroidViewModel(application), SettingsManager.OnChangeListener {
@@ -117,8 +73,6 @@ class TasksFragment: Fragment() {

    }

        val context: Context get() = getApplication()

    val currentProvider = MutableLiveData<ProviderName>()
    val openTasksInstalled = MutableLiveData<Boolean>()
    val openTasksRequested = MutableLiveData<Boolean>()
@@ -129,36 +83,39 @@ class TasksFragment: Fragment() {
    val jtxInstalled = MutableLiveData<Boolean>()
    val jtxRequested = MutableLiveData<Boolean>()
    val jtxSelected = MutableLiveData<Boolean>()
        val tasksWatcher = object: PackageChangedReceiver(context) {

    private val tasksWatcher = object: PackageChangedReceiver(application) {
        override fun onReceive(context: Context?, intent: Intent?) {
            checkInstalled()
        }
    }

        val dontShow = object: ObservableBoolean() {
            override fun get() = settings.getBooleanOrNull(HINT_OPENTASKS_NOT_INSTALLED) == false
            override fun set(dontShowAgain: Boolean) {
                if (dontShowAgain)
    val dontShow = MutableLiveData(
    settings.getBooleanOrNull(HINT_OPENTASKS_NOT_INSTALLED) == false
    )

    private val dontShowObserver = Observer<Boolean> { value ->
        if (value)
            settings.putBoolean(HINT_OPENTASKS_NOT_INSTALLED, false)
        else
            settings.remove(HINT_OPENTASKS_NOT_INSTALLED)
                notifyChange()
            }
    }

    init {
        checkInstalled()
        settings.addOnChangeListener(this)
        dontShow.observeForever(dontShowObserver)
    }

    override fun onCleared() {
        settings.removeOnChangeListener(this)
        tasksWatcher.close()
        dontShow.removeObserver(dontShowObserver)
    }

    @AnyThread
    fun checkInstalled() {
            val taskProvider = TaskUtils.currentProvider(context)
        val taskProvider = TaskUtils.currentProvider(getApplication())
        currentProvider.postValue(taskProvider)

        val openTasks = isInstalled(ProviderName.OpenTasks.packageName)
@@ -179,14 +136,15 @@ class TasksFragment: Fragment() {

    private fun isInstalled(packageName: String): Boolean =
        try {
                    context.packageManager.getPackageInfo(packageName, 0)
            getApplication<Application>().packageManager.getPackageInfo(packageName, 0)
            true
        } catch (e: PackageManager.NameNotFoundException) {
            false
        }

    fun selectPreferredProvider(provider: ProviderName) {
            TaskUtils.setPreferredProvider(context, provider)
        // Changes preferred task app setting, so onSettingsChanged() will be called
        TaskUtils.setPreferredProvider(getApplication(), provider)
    }


@@ -196,4 +154,160 @@ class TasksFragment: Fragment() {

}

@OptIn(ExperimentalTextApi::class)
@Composable
fun TasksCard(
    model: TasksModel = viewModel()
) {
    val context = LocalContext.current
    val coroutineScope = rememberCoroutineScope()

    val snackbarHostState = remember { SnackbarHostState() }

    val jtxInstalled by model.jtxInstalled.observeAsState(initial = false)
    val jtxSelected by model.jtxSelected.observeAsState(initial = false)
    val jtxRequested by model.jtxRequested.observeAsState(initial = false)

    val tasksOrgInstalled by model.tasksOrgInstalled.observeAsState(initial = false)
    val tasksOrgSelected by model.tasksOrgSelected.observeAsState(initial = false)
    val tasksOrgRequested by model.tasksOrgRequested.observeAsState(initial = false)

    val openTasksInstalled by model.openTasksInstalled.observeAsState(initial = false)
    val openTasksSelected by model.openTasksSelected.observeAsState(initial = false)
    val openTasksRequested by model.openTasksRequested.observeAsState(initial = false)

    val dontShow by model.dontShow.observeAsState(initial = false)

    fun installApp(packageName: String) {
        val uri = Uri.parse("market://details?id=$packageName&referrer=" +
            Uri.encode("utm_source=" + BuildConfig.APPLICATION_ID))
        val intent = Intent(Intent.ACTION_VIEW, uri)
        if (intent.resolveActivity(context.packageManager) != null)
            context.startActivity(intent)
        else
            coroutineScope.launch {
                snackbarHostState.showSnackbar(
                    message = context.getString(R.string.intro_tasks_no_app_store),
                    duration = SnackbarDuration.Long
                )
            }
    }

    fun onProviderSelected(provider: ProviderName) {
        if (model.currentProvider.value != provider)
            model.selectPreferredProvider(provider)
    }

    Scaffold(
        snackbarHost = { SnackbarHost(snackbarHostState) }
    ) { paddingValues ->
        Column(
            modifier = Modifier
                .fillMaxHeight()
                .padding(paddingValues)
                .verticalScroll(rememberScrollState())
        ) {
            CardWithImage(
                image = painterResource(R.drawable.intro_tasks),
                title = stringResource(R.string.intro_tasks_title),
                message = stringResource(R.string.intro_tasks_text1),
                modifier = Modifier
                    .padding(horizontal = 16.dp)
                    .padding(top = 16.dp)
            ) {
                RadioWithSwitch(
                    title = stringResource(R.string.intro_tasks_jtx),
                    summary = {
                        Text(stringResource(R.string.intro_tasks_jtx_info))
                    },
                    isSelected = jtxSelected,
                    isToggled = jtxRequested,
                    enabled = jtxInstalled,
                    onSelected = { onProviderSelected(ProviderName.JtxBoard) },
                    onToggled = { toggled ->
                        if (toggled) installApp(ProviderName.JtxBoard.packageName)
                    },
                    modifier = Modifier
                        .fillMaxWidth()
                        .padding(vertical = 12.dp)
                )

                RadioWithSwitch(
                    title = stringResource(R.string.intro_tasks_tasks_org),
                    summary = {
                        val summary = HtmlCompat.fromHtml(
                            stringResource(R.string.intro_tasks_tasks_org_info),
                            HtmlCompat.FROM_HTML_MODE_COMPACT
                        ).toAnnotatedString()

                        ClickableText(
                            text = summary,
                            onClick = { index ->
                                // Get the tapped position, and check if there's any link
                                summary.getUrlAnnotations(index, index).firstOrNull()?.item?.url?.let { url ->
                                    UiUtils.launchUri(context, Uri.parse(url))
                                }
                            }
                        )
                    },
                    isSelected = tasksOrgSelected,
                    isToggled = tasksOrgRequested,
                    enabled = tasksOrgInstalled,
                    onSelected = { onProviderSelected(ProviderName.TasksOrg) },
                    onToggled = { toggled ->
                        if (toggled) installApp(ProviderName.TasksOrg.packageName)
                    },
                    modifier = Modifier
                        .fillMaxWidth()
                        .padding(vertical = 12.dp)
                )

                RadioWithSwitch(
                    title = stringResource(R.string.intro_tasks_opentasks),
                    summary = {
                        Text(stringResource(R.string.intro_tasks_opentasks_info))
                    },
                    isSelected = openTasksSelected,
                    isToggled = openTasksRequested,
                    enabled = openTasksInstalled,
                    onSelected = { onProviderSelected(ProviderName.OpenTasks) },
                    onToggled = { toggled ->
                        if (toggled) installApp(ProviderName.OpenTasks.packageName)
                    },
                    modifier = Modifier
                        .fillMaxWidth()
                        .padding(vertical = 12.dp)
                )

                Row(
                    verticalAlignment = Alignment.CenterVertically,
                    modifier = Modifier
                        .fillMaxWidth()
                        .padding(vertical = 12.dp)
                ) {
                    Checkbox(
                        checked = dontShow,
                        onCheckedChange = { model.dontShow.value = it }
                    )
                    Text(
                        text = stringResource(R.string.intro_tasks_dont_show),
                        modifier = Modifier
                            .fillMaxWidth()
                            .clickable { model.dontShow.value = !dontShow }
                    )
                }
            }

            Text(
                text = stringResource(
                    R.string.intro_leave_unchecked,
                    stringResource(R.string.app_settings_reset_hints)
                ),
                style = MaterialTheme.typography.caption,
                modifier = Modifier
                    .fillMaxWidth()
                    .padding(horizontal = 12.dp, vertical = 8.dp)
            )
        }
    }
}
 No newline at end of file
+21 −8
Original line number Diff line number Diff line
@@ -9,19 +9,32 @@ import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.ViewCompositionStrategy
import androidx.fragment.app.Fragment
import at.bitfire.davdroid.App
import at.bitfire.davdroid.BuildConfig
import at.bitfire.davdroid.R
import androidx.fragment.app.viewModels
import at.bitfire.davdroid.resource.TaskUtils
import at.bitfire.davdroid.settings.SettingsManager
import at.bitfire.davdroid.ui.TasksFragment
import at.bitfire.davdroid.ui.TasksCard
import at.bitfire.davdroid.ui.TasksModel
import com.google.accompanist.themeadapter.material.MdcTheme
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject

@AndroidEntryPoint
class TasksIntroFragment : Fragment() {

    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View =
        inflater.inflate(R.layout.intro_tasks, container, false)
    val model: TasksModel by viewModels()

    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
        return ComposeView(requireContext()).apply {
            setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
            setContent {
                MdcTheme {
                    TasksCard(model)
                }
            }
        }
    }


    class Factory @Inject constructor(
@@ -29,7 +42,7 @@ class TasksIntroFragment : Fragment() {
    ): IntroFragmentFactory {

        override fun getOrder(context: Context): Int {
            return if (!TaskUtils.isAvailable(context) && settingsManager.getBooleanOrNull(TasksFragment.Model.HINT_OPENTASKS_NOT_INSTALLED) != false)
            return if (!TaskUtils.isAvailable(context) && settingsManager.getBooleanOrNull(TasksModel.HINT_OPENTASKS_NOT_INSTALLED) != false)
                10
            else
                IntroFragmentFactory.DONT_SHOW
+76 −0
Original line number Diff line number Diff line
/***************************************************************************************************
 * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
 **************************************************************************************************/

package at.bitfire.davdroid.ui.widget

import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.Card
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import at.bitfire.davdroid.R

@Composable
fun CardWithImage(
    image: Painter,
    title: String,
    message: String,
    modifier: Modifier = Modifier,
    imageContentDescription: String? = null,
    content: @Composable ColumnScope.() -> Unit = {}
) {
    Card(modifier) {
        Column(
            modifier = Modifier.fillMaxWidth()
        ) {
            Image(
                painter = image,
                contentDescription = imageContentDescription,
                modifier = Modifier.fillMaxWidth()
            )

            Column(
                modifier = Modifier
                    .fillMaxWidth()
                    .padding(horizontal = 16.dp)
            ) {
                Text(
                    text = title,
                    modifier = Modifier
                        .fillMaxWidth()
                        .padding(top = 12.dp),
                    style = MaterialTheme.typography.h6
                )
                Text(
                    text = message,
                    modifier = Modifier
                        .fillMaxWidth()
                        .padding(vertical = 12.dp),
                    style = MaterialTheme.typography.body1
                )

                content()
            }
        }
    }
}

@Preview
@Composable
fun CardWithImagePreview() {
    CardWithImage(
        image = painterResource(R.drawable.intro_tasks),
        title = "Demo card",
        message = "This is the message to be displayed under the title, but before the content."
    )
}
Loading