From 2e669812b1abce1aea2f4e459ba7f99454ebbb4f Mon Sep 17 00:00:00 2001 From: Arnau Mora Date: Fri, 8 Mar 2024 16:29:16 +0100 Subject: [PATCH] Rewrite `WebdavMountsActivity` to Compose (#607) * Migrated to Compose Signed-off-by: Arnau Mora Gras * Text hides when there are mounts Signed-off-by: Arnau Mora Gras * Fixed todo Signed-off-by: Arnau Mora Gras * Migrated to Compose Signed-off-by: Arnau Mora Gras * Text hides when there are mounts Signed-off-by: Arnau Mora Gras * Fixed todo Signed-off-by: Arnau Mora Gras * Removed vertical scroll Signed-off-by: Arnau Mora Gras * Added action for ClickableText Signed-off-by: Arnau Mora Gras * Fixed indentation Signed-off-by: Arnau Mora Gras * Changed layout Signed-off-by: Arnau Mora Gras * Removed overflow preferences Signed-off-by: Arnau Mora Gras * Fixed padding Signed-off-by: Arnau Mora Gras * Changed link color Signed-off-by: Arnau Mora Gras * Fixed preview Signed-off-by: Arnau Mora Gras * Fixed back arrow Signed-off-by: Arnau Mora Gras * Require explicit Context for helpUrl to make it work in Compose preview --------- Signed-off-by: Arnau Mora Gras Co-authored-by: Ricki Hirner --- app/src/main/AndroidManifest.xml | 4 +- .../kotlin/at/bitfire/davdroid/ui/UiUtils.kt | 6 +- .../ui/webdav/WebdavMountsActivity.kt | 466 ++++++++++++------ .../res/layout/activity_webdav_mounts.xml | 56 --- .../main/res/layout/webdav_mounts_item.xml | 87 ---- .../main/res/menu/activity_webdav_mounts.xml | 11 - 6 files changed, 329 insertions(+), 301 deletions(-) delete mode 100644 app/src/main/res/layout/activity_webdav_mounts.xml delete mode 100644 app/src/main/res/layout/webdav_mounts_item.xml delete mode 100644 app/src/main/res/menu/activity_webdav_mounts.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 594c1b4c..45b70dca 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -161,9 +161,9 @@ + android:theme="@style/AppTheme.NoActionBar" /> () + private val browser = registerForActivityResult(StartActivityForResult()) { result -> + result.data?.data?.let { uri -> + ShareCompat.IntentBuilder(this) + .setType(DavUtils.MIME_TYPE_ACCEPT_ALL) + .addStream(uri) + .startChooser() + } + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - binding = ActivityWebdavMountsBinding.inflate(layoutInflater) - setContentView(binding.root) + setContent { + MdcTheme { + CompositionLocalProvider( + LocalUriHandler provides SafeAndroidUriHandler(this) + ) { + val mountInfos by model.mountInfos.observeAsState(emptyList()) - binding.webdavMountsSeeManual.text = HtmlCompat.fromHtml(getString(R.string.webdav_add_mount_empty_more_info, helpUrl()), 0) - binding.webdavMountsSeeManual.movementMethod = LinkMovementMethod.getInstance() - - val adapter = MountsAdapter(this, model) - binding.list.adapter = adapter - binding.list.layoutManager = LinearLayoutManager(this) - model.mountInfos.observe(this, { mounts -> - adapter.submitList(ArrayList(mounts)) - - val hasMounts = mounts.isNotEmpty() - binding.list.visibility = if (hasMounts) View.VISIBLE else View.GONE - binding.empty.visibility = if (hasMounts) View.GONE else View.VISIBLE - }) - - val browser = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> - result.data?.data?.let { uri -> - ShareCompat.IntentBuilder(this) - .setType(DavUtils.MIME_TYPE_ACCEPT_ALL) - .addStream(uri) - .startChooser() - } - } - model.browseIntent.observe(this, { intent -> - if (intent != null) - browser.launch(intent) - }) - - binding.add.setOnClickListener { - startActivity(Intent(this, AddWebdavMountActivity::class.java)) - } - - addMenuProvider(object : MenuProvider { - override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { - menuInflater.inflate(R.menu.activity_webdav_mounts, menu) - } - - override fun onMenuItemSelected(menuItem: MenuItem): Boolean { - return when (menuItem.itemId) { - R.id.help -> { - onShowHelp() - true - } - else -> false + WebdavMountsContent(mountInfos) } } - }) + } } - fun onShowHelp() { - UiUtils.launchUri(this, helpUrl()) + + @Composable + fun WebdavMountsContent(mountInfos: List) { + val context = LocalContext.current + val uriHandler = LocalUriHandler.current + + Scaffold( + topBar = { + TopAppBar( + navigationIcon = { + IconButton( + onClick = ::finish + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = null + ) + } + }, + title = { Text(stringResource(R.string.webdav_mounts_title)) }, + actions = { + IconButton( + onClick = { uriHandler.openUri(helpUrl(context).toString()) } + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.Help, + contentDescription = stringResource(R.string.help) + ) + } + } + ) + }, + floatingActionButton = { + FloatingActionButton( + onClick = { startActivity(Intent(this, AddWebdavMountActivity::class.java)) } + ) { + Icon( + imageVector = Icons.Filled.Add, + contentDescription = stringResource(R.string.webdav_add_mount_add) + ) + } + } + ) { paddingValues -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + contentAlignment = Alignment.Center + ) { + if (mountInfos.isEmpty()) { + HintText() + } else { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(16.dp) + .padding(paddingValues) + ) { + items(mountInfos, key = { it.mount.id }, contentType = { "mount" }) { + WebdavMountsItem(it) + } + } + } + } + } } - private fun helpUrl() = - App.homepageUrl(this).buildUpon() - .appendEncodedPath("manual/webdav_mounts.html") - .build() + @Composable + @OptIn(ExperimentalTextApi::class) + fun HintText() { + val context = LocalContext.current + val uriHandler = LocalUriHandler.current + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + ) { + Text( + text = stringResource(R.string.webdav_mounts_empty), + style = MaterialTheme.typography.h6, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp) + ) + + val text = HtmlCompat.fromHtml( + stringResource( + R.string.webdav_add_mount_empty_more_info, + helpUrl(context).toString() + ), + 0 + ).toAnnotatedString() + ClickableText( + text = text, + style = MaterialTheme.typography.body1, + modifier = Modifier.fillMaxWidth(), + onClick = { position -> + text.getUrlAnnotations(position, position + 1) + .firstOrNull() + ?.let { uriHandler.openUri(it.item.url) } + } + ) + } + } + + @Composable + fun WebdavMountsItem(info: MountInfo) { + var showingDialog by remember { mutableStateOf(false) } + if (showingDialog) { + AlertDialog( + onDismissRequest = { showingDialog = false }, + title = { Text(stringResource(R.string.webdav_remove_mount_title)) }, + text = { Text(stringResource(R.string.webdav_remove_mount_text)) }, + confirmButton = { + TextButton( + onClick = { + Logger.log.log(Level.INFO, "User removes mount point", info.mount) + model.remove(info.mount) + } + ) { + Text(stringResource(R.string.dialog_remove)) + } + }, + dismissButton = { + TextButton( + onClick = { showingDialog = false } + ) { + Text(stringResource(R.string.dialog_deny)) + } + } + ) + } + + Card( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp), + backgroundColor = MaterialTheme.colors.onSecondary + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Text( + text = info.mount.name, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 8.dp), + style = MaterialTheme.typography.body1 + ) + Text( + text = info.mount.url.toString(), + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 8.dp), + style = MaterialTheme.typography.caption + ) + + val quotaUsed = info.rootDocument?.quotaUsed + val quotaAvailable = info.rootDocument?.quotaAvailable + if (quotaUsed != null && quotaAvailable != null) { + val quotaTotal = quotaUsed + quotaAvailable + val progress = quotaUsed.toFloat() / quotaTotal + LinearProgressIndicator( + progress = progress, + modifier = Modifier + .fillMaxWidth() + .padding(top = 12.dp) + ) + Text( + text = stringResource( + R.string.webdav_mounts_quota_used_available, + FileUtils.byteCountToDisplaySize(quotaUsed), + FileUtils.byteCountToDisplaySize(quotaAvailable) + ), + modifier = Modifier.fillMaxWidth() + ) + } + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + TextButton( + onClick = { + val authority = getString(R.string.webdav_authority) + val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply { + addCategory(Intent.CATEGORY_OPENABLE) + type = "*/*" + } + val uri = DocumentsContract.buildRootUri(authority, info.mount.id.toString()) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, uri) + } + browser.launch(intent) + } + ) { + Text( + text = stringResource(R.string.webdav_mounts_share_content).uppercase() + ) + } + Spacer(Modifier.weight(1f)) + IconButton( + onClick = { showingDialog = true } + ) { + Icon( + imageVector = Icons.Filled.Delete, + contentDescription = stringResource(R.string.webdav_mounts_unmount) + ) + } + } + } + } + } + + @Preview(showBackground = true, showSystemUi = true) + @Composable + fun WebdavMountsContent_Preview() { + MdcTheme { + WebdavMountsContent(emptyList()) + } + } + + @Preview(showBackground = true) + @Composable + fun WebdavMountsItem_Preview() { + MdcTheme { + WebdavMountsItem( + info = MountInfo( + mount = WebDavMount( + id = 0, + name = "Preview Webdav Mount", + url = HttpUrl.Builder() + .scheme("https") + .host("example.com") + .build() + ), + rootDocument = WebDavDocument( + mountId = 0, + parentId = null, + name = "Root", + quotaAvailable = 1024 * 1024 * 1024, + quotaUsed = 512 * 1024 * 1024 + ) + ) + ) + } + } data class MountInfo( @@ -127,76 +377,6 @@ class WebdavMountsActivity: AppCompatActivity() { val rootDocument: WebDavDocument? ) - class MountsAdapter( - val context: Context, - val model: Model - ): ListAdapter(object: DiffUtil.ItemCallback() { - override fun areItemsTheSame(oldItem: MountInfo, newItem: MountInfo) = - oldItem.mount.id == newItem.mount.id - override fun areContentsTheSame(oldItem: MountInfo, newItem: MountInfo) = - oldItem.mount.name == newItem.mount.name && oldItem.mount.url == newItem.mount.url && - oldItem.rootDocument?.quotaUsed == newItem.rootDocument?.quotaUsed && - oldItem.rootDocument?.quotaAvailable == newItem.rootDocument?.quotaAvailable - }) { - class ViewHolder(val binding: WebdavMountsItemBinding): RecyclerView.ViewHolder(binding.root) - - val authority = context.getString(R.string.webdav_authority) - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { - val inflater = LayoutInflater.from(parent.context) - val binding = WebdavMountsItemBinding.inflate(inflater, parent, false) - - return ViewHolder(binding) - } - - override fun onBindViewHolder(holder: ViewHolder, position: Int) { - val info = getItem(position) - val binding = holder.binding - binding.name.text = info.mount.name - binding.url.text = info.mount.url.toString() - - val quotaUsed = info.rootDocument?.quotaUsed - val quotaAvailable = info.rootDocument?.quotaAvailable - if (quotaUsed != null && quotaAvailable != null) { - val quotaTotal = quotaUsed + quotaAvailable - - binding.quotaProgress.visibility = View.VISIBLE - binding.quotaProgress.progress = (quotaUsed*100 / quotaTotal).toInt() - - binding.quota.visibility = View.VISIBLE - binding.quota.text = context.getString(R.string.webdav_mounts_quota_used_available, - FileUtils.byteCountToDisplaySize(quotaUsed), - FileUtils.byteCountToDisplaySize(quotaAvailable) - ) - } else { - binding.quotaProgress.visibility = View.GONE - binding.quota.visibility = View.GONE - } - - binding.browse.setOnClickListener { - val intent = Intent(Intent.ACTION_OPEN_DOCUMENT) - intent.addCategory(Intent.CATEGORY_OPENABLE) - intent.type = "*/*" - val uri = DocumentsContract.buildRootUri(authority, info.mount.id.toString()) - intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, uri) - model.browseIntent.value = intent - model.browseIntent.value = null - } - - binding.removeMountpoint.setOnClickListener { - AlertDialog.Builder(context) - .setTitle(R.string.webdav_remove_mount_title) - .setMessage(R.string.webdav_remove_mount_text) - .setPositiveButton(R.string.dialog_remove) { _, _ -> - Logger.log.log(Level.INFO, "User removes mount point", info.mount) - model.remove(info.mount) - } - .setNegativeButton(R.string.dialog_deny, null) - .show() - } - } - } - @HiltViewModel class Model @Inject constructor( @@ -241,8 +421,6 @@ class WebdavMountsActivity: AppCompatActivity() { } } - val browseIntent = MutableLiveData() - /** * Removes the mountpoint (deleting connection information) */ diff --git a/app/src/main/res/layout/activity_webdav_mounts.xml b/app/src/main/res/layout/activity_webdav_mounts.xml deleted file mode 100644 index 04e520fa..00000000 --- a/app/src/main/res/layout/activity_webdav_mounts.xml +++ /dev/null @@ -1,56 +0,0 @@ - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/webdav_mounts_item.xml b/app/src/main/res/layout/webdav_mounts_item.xml deleted file mode 100644 index 627b2105..00000000 --- a/app/src/main/res/layout/webdav_mounts_item.xml +++ /dev/null @@ -1,87 +0,0 @@ - - - - - - - - - - - - - - - -