diff --git a/app/build.gradle b/app/build.gradle index 658288fbd..495520a0f 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -152,6 +152,9 @@ dependencies { implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0' implementation 'androidx.work:work-runtime:2.10.5' implementation 'com.google.android.material:material:1.13.0' + + // Vanitech + implementation 'com.vanniktech:emoji-google:0.21.0' // Database implementation "androidx.room:room-runtime:${roomVersion}" diff --git a/app/src/main/java/it/niedermann/owncloud/notes/accountswitcher/AccountSwitcherDialog.java b/app/src/main/java/it/niedermann/owncloud/notes/accountswitcher/AccountSwitcherDialog.java index c11b8d311..546fba440 100644 --- a/app/src/main/java/it/niedermann/owncloud/notes/accountswitcher/AccountSwitcherDialog.java +++ b/app/src/main/java/it/niedermann/owncloud/notes/accountswitcher/AccountSwitcherDialog.java @@ -14,14 +14,23 @@ import android.graphics.drawable.LayerDrawable; import android.net.Uri; import android.os.Bundle; +import android.view.View; import androidx.annotation.NonNull; import androidx.fragment.app.DialogFragment; import com.google.android.material.dialog.MaterialAlertDialogBuilder; +import com.owncloud.android.lib.resources.users.Status; +import com.owncloud.android.lib.resources.users.StatusType; + +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; import it.niedermann.owncloud.notes.NotesApplication; import it.niedermann.owncloud.notes.R; +import it.niedermann.owncloud.notes.accountswitcher.adapter.accountSwitcher.AccountSwitcherAdapter; +import it.niedermann.owncloud.notes.accountswitcher.bottomSheet.AccountSwitcherBottomSheetTag; +import it.niedermann.owncloud.notes.accountswitcher.repository.UserStatusRepository; import it.niedermann.owncloud.notes.branding.BrandedDialogFragment; import it.niedermann.owncloud.notes.branding.BrandingUtil; import it.niedermann.owncloud.notes.databinding.DialogAccountSwitcherBinding; @@ -29,6 +38,10 @@ import it.niedermann.owncloud.notes.persistence.NotesRepository; import it.niedermann.owncloud.notes.persistence.entity.Account; import it.niedermann.owncloud.notes.share.helper.AvatarLoader; +import it.niedermann.owncloud.notes.shared.util.DisplayUtils; +import it.niedermann.owncloud.notes.util.ActivityExtensionsKt; +import it.niedermann.owncloud.notes.util.StatusTypeExtensionsKt; +import kotlin.Unit; /** * Displays all available {@link Account} entries and provides basic operations for them, like adding or switching @@ -41,6 +54,9 @@ public class AccountSwitcherDialog extends BrandedDialogFragment { private DialogAccountSwitcherBinding binding; private AccountSwitcherListener accountSwitcherListener; private long currentAccountId; + private UserStatusRepository repository; + private Status currentStatus; + private final ExecutorService executor = Executors.newSingleThreadExecutor(); @Override public void onAttach(@NonNull Context context) { @@ -60,6 +76,38 @@ public void onAttach(@NonNull Context context) { } repo = NotesRepository.getInstance(requireContext()); + initRepositoryAndFetchCurrentStatus(); + } + + private void initRepositoryAndFetchCurrentStatus() { + ActivityExtensionsKt.ssoAccount(requireActivity(), account -> { + if (account != null) { + repository = new UserStatusRepository(requireContext(), account); + } else { + DisplayUtils.showSnackMessage(requireView(), R.string.account_switch_dialog_status_fetching_error_message); + } + executor.execute(() -> { + currentStatus = repository.fetchUserStatus(); + requireActivity().runOnUiThread(() -> { + final var message = currentStatus.getMessage(); + if (message != null) { + binding.accountStatus.setVisibility(View.VISIBLE); + binding.accountStatus.setText(message); + } + + final var emoji = currentStatus.getIcon(); + if (emoji != null) { + binding.accountStatusEmoji.setVisibility(View.VISIBLE); + binding.accountStatusEmoji.setText(emoji); + } else { + final var status = currentStatus.getStatus(); + binding.accountStatusIcon.setVisibility(View.VISIBLE); + binding.accountStatusIcon.setImageResource(StatusTypeExtensionsKt.getImageResource(status)); + } + }); + }); + return Unit.INSTANCE; + }); } @NonNull @@ -76,6 +124,14 @@ public Dialog onCreateDialog(Bundle savedInstanceState) { AvatarLoader.INSTANCE.load(requireContext(), binding.currentAccountItemAvatar, currentLocalAccount); binding.accountLayout.setOnClickListener((v) -> dismiss()); + binding.onlineStatus.setOnClickListener(v -> { + showBottomSheetDialog(AccountSwitcherBottomSheetTag.ONLINE_STATUS); + }); + + binding.statusMessage.setOnClickListener(v -> { + showBottomSheetDialog(AccountSwitcherBottomSheetTag.MESSAGE_STATUS); + }); + final var adapter = new AccountSwitcherAdapter((localAccount -> { accountSwitcherListener.onAccountChosen(localAccount); dismiss(); @@ -112,6 +168,17 @@ public Dialog onCreateDialog(Bundle savedInstanceState) { return builder.create(); } + private void showBottomSheetDialog(@NonNull AccountSwitcherBottomSheetTag tag) { + if (repository == null || currentStatus == null) { + DisplayUtils.showSnackMessage(requireView(), R.string.account_switch_dialog_status_fetching_error_message); + return; + } + + final var fragment = tag.fragment(repository, currentStatus); + fragment.show(requireActivity().getSupportFragmentManager(), tag.name()); + dismiss(); + } + public static DialogFragment newInstance(long currentAccountId) { final var dialog = new AccountSwitcherDialog(); diff --git a/app/src/main/java/it/niedermann/owncloud/notes/accountswitcher/AccountSwitcherAdapter.java b/app/src/main/java/it/niedermann/owncloud/notes/accountswitcher/adapter/accountSwitcher/AccountSwitcherAdapter.java similarity index 95% rename from app/src/main/java/it/niedermann/owncloud/notes/accountswitcher/AccountSwitcherAdapter.java rename to app/src/main/java/it/niedermann/owncloud/notes/accountswitcher/adapter/accountSwitcher/AccountSwitcherAdapter.java index 660c22154..a2af6a748 100644 --- a/app/src/main/java/it/niedermann/owncloud/notes/accountswitcher/AccountSwitcherAdapter.java +++ b/app/src/main/java/it/niedermann/owncloud/notes/accountswitcher/adapter/accountSwitcher/AccountSwitcherAdapter.java @@ -5,7 +5,7 @@ * SPDX-FileCopyrightText: 2020 Stefan Niedermann * SPDX-License-Identifier: GPL-3.0-or-later */ -package it.niedermann.owncloud.notes.accountswitcher; +package it.niedermann.owncloud.notes.accountswitcher.adapter.accountSwitcher; import android.view.LayoutInflater; import android.view.ViewGroup; diff --git a/app/src/main/java/it/niedermann/owncloud/notes/accountswitcher/AccountSwitcherViewHolder.java b/app/src/main/java/it/niedermann/owncloud/notes/accountswitcher/adapter/accountSwitcher/AccountSwitcherViewHolder.java similarity index 94% rename from app/src/main/java/it/niedermann/owncloud/notes/accountswitcher/AccountSwitcherViewHolder.java rename to app/src/main/java/it/niedermann/owncloud/notes/accountswitcher/adapter/accountSwitcher/AccountSwitcherViewHolder.java index d128e07ef..bb4878fc3 100644 --- a/app/src/main/java/it/niedermann/owncloud/notes/accountswitcher/AccountSwitcherViewHolder.java +++ b/app/src/main/java/it/niedermann/owncloud/notes/accountswitcher/adapter/accountSwitcher/AccountSwitcherViewHolder.java @@ -5,7 +5,7 @@ * SPDX-FileCopyrightText: 2020-2021 Stefan Niedermann * SPDX-License-Identifier: GPL-3.0-or-later */ -package it.niedermann.owncloud.notes.accountswitcher; +package it.niedermann.owncloud.notes.accountswitcher.adapter.accountSwitcher; import android.net.Uri; import android.view.View; diff --git a/app/src/main/java/it/niedermann/owncloud/notes/accountswitcher/adapter/predefinedStatus/PredefinedStatusClickListener.kt b/app/src/main/java/it/niedermann/owncloud/notes/accountswitcher/adapter/predefinedStatus/PredefinedStatusClickListener.kt new file mode 100644 index 000000000..2cba19c32 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/accountswitcher/adapter/predefinedStatus/PredefinedStatusClickListener.kt @@ -0,0 +1,13 @@ +/* + * Nextcloud Notes - Android Client + * + * SPDX-FileCopyrightText: 2022 Tim KrΓΌger + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package it.niedermann.owncloud.notes.accountswitcher.adapter.predefinedStatus + +import android.content.Context +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import com.owncloud.android.lib.resources.users.PredefinedStatus +import it.niedermann.owncloud.notes.databinding.PredefinedStatusBinding + +class PredefinedStatusListAdapter(private val clickListener: PredefinedStatusClickListener, val context: Context) : + RecyclerView.Adapter() { + internal var list: List = emptyList() + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PredefinedStatusViewHolder { + val itemBinding = PredefinedStatusBinding.inflate(LayoutInflater.from(parent.context), parent, false) + return PredefinedStatusViewHolder(itemBinding) + } + + override fun onBindViewHolder(holder: PredefinedStatusViewHolder, position: Int) { + holder.bind(list[position], clickListener, context) + } + + override fun getItemCount(): Int = list.size +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/accountswitcher/adapter/predefinedStatus/PredefinedStatusViewHolder.kt b/app/src/main/java/it/niedermann/owncloud/notes/accountswitcher/adapter/predefinedStatus/PredefinedStatusViewHolder.kt new file mode 100644 index 000000000..c6774bf05 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/accountswitcher/adapter/predefinedStatus/PredefinedStatusViewHolder.kt @@ -0,0 +1,44 @@ +/* + * Nextcloud Notes - Android Client + * + * SPDX-FileCopyrightText: 2020 Tobias Kaminsky + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package it.niedermann.owncloud.notes.accountswitcher.adapter.predefinedStatus + +import android.content.Context +import androidx.recyclerview.widget.RecyclerView +import com.owncloud.android.lib.resources.users.PredefinedStatus +import it.niedermann.owncloud.notes.R +import it.niedermann.owncloud.notes.databinding.PredefinedStatusBinding +import it.niedermann.owncloud.notes.shared.util.DisplayUtils + +private const val ONE_SECOND_IN_MILLIS = 1000 + +class PredefinedStatusViewHolder(private val binding: PredefinedStatusBinding) : RecyclerView.ViewHolder(binding.root) { + + fun bind(status: PredefinedStatus, clickListener: PredefinedStatusClickListener, context: Context) { + binding.root.setOnClickListener { clickListener.onClick(status) } + binding.icon.text = status.icon + binding.name.text = status.message + + if (status.clearAt == null) { + binding.clearAt.text = context.getString(R.string.dontClear) + } else { + val clearAt = status.clearAt + if (clearAt?.type == "period") { + binding.clearAt.text = DisplayUtils.getRelativeTimestamp( + context, + System.currentTimeMillis() + clearAt.time.toInt() * ONE_SECOND_IN_MILLIS, + true + ) + } else { + // end-of + if (clearAt?.time == "day") { + binding.clearAt.text = context.getString(R.string.today) + } + } + } + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/accountswitcher/bottomSheet/AccountSwitcherBottomSheetTag.kt b/app/src/main/java/it/niedermann/owncloud/notes/accountswitcher/bottomSheet/AccountSwitcherBottomSheetTag.kt new file mode 100644 index 000000000..08f8ba62e --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/accountswitcher/bottomSheet/AccountSwitcherBottomSheetTag.kt @@ -0,0 +1,31 @@ +/* + * Nextcloud Notes - Android Client + * + * SPDX-FileCopyrightText: 2015-2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package it.niedermann.owncloud.notes.accountswitcher.bottomSheet + +import com.owncloud.android.lib.resources.users.Status +import it.niedermann.owncloud.notes.accountswitcher.repository.UserStatusRepository +import it.niedermann.owncloud.notes.branding.BrandedBottomSheetDialogFragment + +enum class AccountSwitcherBottomSheetTag(tag: String) { + ONLINE_STATUS("fragment_set_status"), + MESSAGE_STATUS("fragment_set_status_message"); + + fun fragment( + repository: UserStatusRepository, + currentStatus: Status + ): BrandedBottomSheetDialogFragment { + return when (this) { + ONLINE_STATUS -> { + SetOnlineStatusBottomSheet(repository, currentStatus) + } + + MESSAGE_STATUS -> { + SetStatusMessageBottomSheet(repository, currentStatus) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/it/niedermann/owncloud/notes/accountswitcher/bottomSheet/SetOnlineStatusBottomSheet.kt b/app/src/main/java/it/niedermann/owncloud/notes/accountswitcher/bottomSheet/SetOnlineStatusBottomSheet.kt new file mode 100644 index 000000000..5e443a104 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/accountswitcher/bottomSheet/SetOnlineStatusBottomSheet.kt @@ -0,0 +1,178 @@ +/* + * Nextcloud Notes - Android Client + * + * SPDX-FileCopyrightText: 2025 Sowjanya Kota + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package it.niedermann.owncloud.notes.accountswitcher.bottomSheet + +import android.annotation.SuppressLint +import android.os.Bundle +import android.util.Log +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ImageView +import android.widget.TextView +import androidx.core.content.ContextCompat +import androidx.lifecycle.lifecycleScope +import com.google.android.material.card.MaterialCardView +import com.nextcloud.android.common.ui.theme.utils.ColorRole +import com.owncloud.android.lib.resources.users.Status +import com.owncloud.android.lib.resources.users.StatusType +import it.niedermann.owncloud.notes.R +import it.niedermann.owncloud.notes.accountswitcher.repository.UserStatusRepository +import it.niedermann.owncloud.notes.branding.BrandedBottomSheetDialogFragment +import it.niedermann.owncloud.notes.branding.BrandingUtil +import it.niedermann.owncloud.notes.databinding.SetOnlineStatusBottomSheetBinding +import it.niedermann.owncloud.notes.shared.util.DisplayUtils +import it.niedermann.owncloud.notes.shared.util.FilesSpecificViewThemeUtils +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class SetOnlineStatusBottomSheet( + private val repository: UserStatusRepository, + private val currentStatus: Status +) : + BrandedBottomSheetDialogFragment(R.layout.set_online_status_bottom_sheet) { + + companion object { + private val TAG = SetOnlineStatusBottomSheet::class.simpleName + } + + private lateinit var binding: SetOnlineStatusBottomSheetBinding + private var cardViews: Triple? = null + + @SuppressLint("DefaultLocale") + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + visualizeStatus(currentStatus.status) + initRepository() + setupStatusClickListeners() + } + + private fun setupStatusClickListeners() { + val statusMap = mapOf( + binding.onlineStatus to StatusType.ONLINE, + binding.awayStatus to StatusType.AWAY, + binding.busyStatus to StatusType.BUSY, + binding.dndStatus to StatusType.DND, + binding.invisibleStatus to StatusType.INVISIBLE + ) + + statusMap.forEach { (view, statusType) -> + view.setOnClickListener { setStatus(statusType) } + } + } + + private fun initRepository() { + lifecycleScope.launch(Dispatchers.IO) { + val capabilities = repository.getCapabilities() + if (capabilities.isUserStatusSupportsBusy) { + binding.busyStatus.visibility = View.VISIBLE + } else { + binding.busyStatus.visibility = View.GONE + } + } + } + + private fun setStatus(statusType: StatusType) { + lifecycleScope.launch(Dispatchers.IO) { + val result = repository.setStatusType(statusType) + withContext(Dispatchers.Main) { + if (result) { + dismiss() + } else { + showErrorSnackbar() + } + } + } + } + + private fun showErrorSnackbar() { + DisplayUtils.showSnackMessage(view, R.string.status_set_fail_message) + clearTopStatus() + } + + private fun visualizeStatus(statusType: StatusType) { + clearTopStatus() + cardViews = when (statusType) { + StatusType.ONLINE -> Triple( + binding.onlineStatus, + binding.onlineHeadline, + binding.onlineIcon + ) + + StatusType.AWAY -> Triple(binding.awayStatus, binding.awayHeadline, binding.awayIcon) + StatusType.BUSY -> Triple(binding.busyStatus, binding.busyHeadline, binding.busyIcon) + StatusType.DND -> Triple(binding.dndStatus, binding.dndHeadline, binding.dndIcon) + StatusType.INVISIBLE -> Triple( + binding.invisibleStatus, + binding.invisibleHeadline, + binding.invisibleIcon + ) + + else -> { + Log.d(TAG, "unknown status") + return + } + } + cardViews?.first?.isChecked = true + } + + private fun clearTopStatus() { + context?.let { ctx -> + binding.run { + val headlines = listOf( + onlineHeadline, + awayHeadline, + busyHeadline, + dndHeadline, + invisibleHeadline + ) + val color = ContextCompat.getColor( + ctx, + com.nextcloud.android.common.ui.R.color.high_emphasis_text + ) + headlines.forEach { it.setTextColor(color) } + listOf(awayIcon, dndIcon, invisibleIcon).forEach { it.imageTintList = null } + listOf( + onlineStatus, + awayStatus, + busyStatus, + dndStatus, + invisibleStatus + ).forEach { it.isChecked = false } + } + } + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + binding = SetOnlineStatusBottomSheetBinding.inflate(layoutInflater, container, false) + return binding.root + } + + override fun applyBrand(color: Int) { + BrandingUtil.of(color, requireContext()).run { + platform.themeDialog(binding.root) + + cardViews?.let { + platform.colorTextView(it.second, ColorRole.ON_SECONDARY_CONTAINER) + } + } + + FilesSpecificViewThemeUtils.run { + themeStatusCardView(binding.onlineStatus, color) + themeStatusCardView(binding.awayStatus, color) + themeStatusCardView(binding.busyStatus, color) + themeStatusCardView(binding.dndStatus, color) + themeStatusCardView(binding.invisibleStatus, color) + } + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/accountswitcher/bottomSheet/SetStatusMessageBottomSheet.kt b/app/src/main/java/it/niedermann/owncloud/notes/accountswitcher/bottomSheet/SetStatusMessageBottomSheet.kt new file mode 100644 index 000000000..2ef18c5d0 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/accountswitcher/bottomSheet/SetStatusMessageBottomSheet.kt @@ -0,0 +1,340 @@ +/* + * Nextcloud Notes - Android Client + * + * SPDX-FileCopyrightText: 2022-2023 Marcel Hibbe + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH + * SPDX-FileCopyrightText: 2020 Tobias Kaminsky + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package it.niedermann.owncloud.notes.accountswitcher.bottomSheet + +import android.annotation.SuppressLint +import android.content.Context +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.inputmethod.InputMethodManager +import android.widget.AdapterView +import android.widget.AdapterView.OnItemSelectedListener +import android.widget.ArrayAdapter +import androidx.lifecycle.lifecycleScope +import androidx.recyclerview.widget.LinearLayoutManager +import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.lib.resources.users.ClearAt +import com.owncloud.android.lib.resources.users.PredefinedStatus +import com.owncloud.android.lib.resources.users.Status +import com.vanniktech.emoji.EmojiManager +import com.vanniktech.emoji.EmojiPopup +import com.vanniktech.emoji.google.GoogleEmojiProvider +import com.vanniktech.emoji.installDisableKeyboardInput +import com.vanniktech.emoji.installForceSingleEmoji +import it.niedermann.owncloud.notes.R +import it.niedermann.owncloud.notes.accountswitcher.adapter.predefinedStatus.PredefinedStatusClickListener +import it.niedermann.owncloud.notes.accountswitcher.adapter.predefinedStatus.PredefinedStatusListAdapter +import it.niedermann.owncloud.notes.accountswitcher.repository.UserStatusRepository +import it.niedermann.owncloud.notes.branding.BrandedBottomSheetDialogFragment +import it.niedermann.owncloud.notes.branding.BrandingUtil +import it.niedermann.owncloud.notes.databinding.SetStatusMessageBottomSheetBinding +import it.niedermann.owncloud.notes.shared.util.DisplayUtils +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.util.Calendar +import java.util.Locale + +private const val POS_DONT_CLEAR = 0 +private const val POS_HALF_AN_HOUR = 1 +private const val POS_AN_HOUR = 2 +private const val POS_FOUR_HOURS = 3 +private const val POS_TODAY = 4 +private const val POS_END_OF_WEEK = 5 + +private const val ONE_SECOND_IN_MILLIS = 1000 +private const val ONE_MINUTE_IN_SECONDS = 60 +private const val THIRTY_MINUTES = 30 +private const val FOUR_HOURS = 4 +private const val LAST_HOUR_OF_DAY = 23 +private const val LAST_MINUTE_OF_HOUR = 59 +private const val LAST_SECOND_OF_MINUTE = 59 + +private const val CLEAR_AT_TYPE_PERIOD = "period" +private const val CLEAR_AT_TYPE_END_OF = "end-of" + +class SetStatusMessageBottomSheet( + private val repository: UserStatusRepository, + private val currentStatus: Status +) : + BrandedBottomSheetDialogFragment(R.layout.set_status_message_bottom_sheet), + PredefinedStatusClickListener { + companion object { + private const val TAG = "SetStatusMessageBottomSheet" + } + + private lateinit var binding: SetStatusMessageBottomSheetBinding + + private lateinit var adapter: PredefinedStatusListAdapter + private var selectedPredefinedMessageId: String? = null + private var clearAt: Long? = -1 + private lateinit var popup: EmojiPopup + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + EmojiManager.install(GoogleEmojiProvider()) + } + + private fun initRepository() { + lifecycleScope.launch(Dispatchers.IO) { + val predefinedStatus = repository.fetchPredefinedStatuses() + withContext(Dispatchers.Main) { + initPredefinedStatusAdapter(predefinedStatus) + } + } + } + + private fun initPredefinedStatusAdapter(predefinedStatus: ArrayList) { + adapter = PredefinedStatusListAdapter(this, requireContext()) + Log_OC.d(TAG, "PredefinedStatusListAdapter initialized") + adapter.list = predefinedStatus + binding.predefinedStatusList.adapter = adapter + binding.predefinedStatusList.layoutManager = LinearLayoutManager(context) + } + + @SuppressLint("DefaultLocale") + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + initRepository() + updateCurrentStatusViews(currentStatus) + binding.clearStatus.setOnClickListener { clearStatus() } + binding.setStatus.setOnClickListener { setStatusMessage() } + binding.emoji.setOnClickListener { popup.show() } + + popup = EmojiPopup(view, binding.emoji, onEmojiClickListener = { _ -> + popup.dismiss() + binding.emoji.clearFocus() + val imm: InputMethodManager = context?.getSystemService(Context.INPUT_METHOD_SERVICE) as + InputMethodManager + imm.hideSoftInputFromWindow(binding.emoji.windowToken, 0) + }) + binding.emoji.installForceSingleEmoji() + binding.emoji.installDisableKeyboardInput(popup) + + clearStatusAdapter() + } + + private fun clearStatusAdapter() { + val adapter = + ArrayAdapter(requireContext(), android.R.layout.simple_spinner_item).apply { + setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item) + add(getString(R.string.dontClear)) + add(getString(R.string.thirtyMinutes)) + add(getString(R.string.oneHour)) + add(getString(R.string.fourHours)) + add(getString(R.string.today)) + add(getString(R.string.thisWeek)) + } + + binding.clearStatusAfterSpinner.apply { + this.adapter = adapter + onItemSelectedListener = object : OnItemSelectedListener { + override fun onItemSelected( + parent: AdapterView<*>, + view: View?, + position: Int, + id: Long + ) { + setClearStatusAfterValue(position) + } + + override fun onNothingSelected(parent: AdapterView<*>?) = Unit + } + } + } + + override fun applyBrand(color: Int) { + BrandingUtil.of(color, requireContext()).run { + platform.themeDialog(binding.root) + material.run { + colorMaterialButtonPrimaryBorderless(binding.clearStatus) + colorMaterialButtonPrimaryTonal(binding.setStatus) + colorTextInputLayout(binding.customStatusInputContainer) + } + } + } + + private fun updateCurrentStatusViews(it: Status) { + if (it.icon.isNullOrBlank()) { + binding.emoji.setText("πŸ˜€") + } else { + binding.emoji.setText(it.icon) + } + + binding.customStatusInput.text?.clear() + binding.customStatusInput.setText(it.message) + + if (it.clearAt > 0) { + binding.clearStatusAfterSpinner.visibility = View.GONE + binding.remainingClearTime.apply { + binding.clearStatusMessageTextView.text = getString(R.string.clear) + visibility = View.VISIBLE + text = DisplayUtils.getRelativeTimestamp( + context, + it.clearAt * ONE_SECOND_IN_MILLIS, + true + ) + .toString() + .replaceFirstChar { it.lowercase(Locale.getDefault()) } + setOnClickListener { + visibility = View.GONE + binding.clearStatusAfterSpinner.visibility = View.VISIBLE + binding.clearStatusMessageTextView.text = getString(R.string.clear_status_after) + } + } + } + } + + private fun setClearStatusAfterValue(item: Int) { + clearAt = when (item) { + POS_DONT_CLEAR -> null // don't clear + POS_HALF_AN_HOUR -> { + // 30 minutes + System.currentTimeMillis() / ONE_SECOND_IN_MILLIS + THIRTY_MINUTES * ONE_MINUTE_IN_SECONDS + } + + POS_AN_HOUR -> { + // one hour + System.currentTimeMillis() / ONE_SECOND_IN_MILLIS + ONE_MINUTE_IN_SECONDS * ONE_MINUTE_IN_SECONDS + } + + POS_FOUR_HOURS -> { + // four hours + System.currentTimeMillis() / ONE_SECOND_IN_MILLIS + + FOUR_HOURS * ONE_MINUTE_IN_SECONDS * ONE_MINUTE_IN_SECONDS + } + + POS_TODAY -> { + // today + val date = getLastSecondOfToday() + dateToSeconds(date) + } + + POS_END_OF_WEEK -> { + // end of week + val date = getLastSecondOfToday() + while (date.get(Calendar.DAY_OF_WEEK) != Calendar.SUNDAY) { + date.add(Calendar.DAY_OF_YEAR, 1) + } + dateToSeconds(date) + } + + else -> clearAt + } + } + + private fun clearAtToUnixTime(clearAt: ClearAt?): Long = when { + clearAt?.type == CLEAR_AT_TYPE_PERIOD -> { + System.currentTimeMillis() / ONE_SECOND_IN_MILLIS + clearAt.time.toLong() + } + + clearAt?.type == CLEAR_AT_TYPE_END_OF && clearAt.time == "day" -> { + val date = getLastSecondOfToday() + dateToSeconds(date) + } + + else -> -1 + } + + private fun getLastSecondOfToday(): Calendar { + val date = Calendar.getInstance().apply { + set(Calendar.HOUR_OF_DAY, LAST_HOUR_OF_DAY) + set(Calendar.MINUTE, LAST_MINUTE_OF_HOUR) + set(Calendar.SECOND, LAST_SECOND_OF_MINUTE) + } + return date + } + + private fun dateToSeconds(date: Calendar) = date.timeInMillis / ONE_SECOND_IN_MILLIS + + private fun clearStatus() { + lifecycleScope.launch(Dispatchers.IO) { + val result = repository.clearStatus() + dismiss(result) + } + } + + private fun setStatusMessage() { + if (selectedPredefinedMessageId != null) { + lifecycleScope.launch(Dispatchers.IO) { + val result = repository.setPredefinedStatus(selectedPredefinedMessageId!!, clearAt) + dismiss(result) + } + } else { + lifecycleScope.launch(Dispatchers.IO) { + val result = repository.setCustomStatus( + binding.customStatusInput.text.toString(), + binding.emoji.text.toString(), + clearAt + ) + dismiss(result) + } + } + } + + private suspend fun dismiss(boolean: Boolean?) = withContext(Dispatchers.Main) { + if (boolean == true) { + dismiss() + } else { + val message = view?.resources?.getString(R.string.error_setting_status_message) + DisplayUtils.showSnackMessage(view, message) + } + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + binding = SetStatusMessageBottomSheetBinding.inflate(layoutInflater, container, false) + return binding.root + } + + override fun onClick(predefinedStatus: PredefinedStatus) { + selectedPredefinedMessageId = predefinedStatus.id + clearAt = clearAtToUnixTime(predefinedStatus.clearAt) + binding.emoji.setText(predefinedStatus.icon) + binding.customStatusInput.text?.clear() + binding.customStatusInput.text?.append(predefinedStatus.message) + + binding.remainingClearTime.visibility = View.GONE + binding.clearStatusAfterSpinner.visibility = View.VISIBLE + binding.clearStatusMessageTextView.text = getString(R.string.clear_status_after) + + val clearAt = predefinedStatus.clearAt + if (clearAt == null) { + binding.clearStatusAfterSpinner.setSelection(0) + } else { + when (clearAt.type) { + CLEAR_AT_TYPE_PERIOD -> updateClearAtViewsForPeriod(clearAt) + CLEAR_AT_TYPE_END_OF -> updateClearAtViewsForEndOf(clearAt) + } + } + setClearStatusAfterValue(binding.clearStatusAfterSpinner.selectedItemPosition) + } + + private fun updateClearAtViewsForPeriod(clearAt: ClearAt) { + when (clearAt.time) { + "1800" -> binding.clearStatusAfterSpinner.setSelection(POS_HALF_AN_HOUR) + "3600" -> binding.clearStatusAfterSpinner.setSelection(POS_AN_HOUR) + "14400" -> binding.clearStatusAfterSpinner.setSelection(POS_FOUR_HOURS) + else -> binding.clearStatusAfterSpinner.setSelection(POS_DONT_CLEAR) + } + } + + private fun updateClearAtViewsForEndOf(clearAt: ClearAt) { + when (clearAt.time) { + "day" -> binding.clearStatusAfterSpinner.setSelection(POS_TODAY) + "week" -> binding.clearStatusAfterSpinner.setSelection(POS_END_OF_WEEK) + else -> binding.clearStatusAfterSpinner.setSelection(POS_DONT_CLEAR) + } + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/accountswitcher/repository/UserStatusRepository.kt b/app/src/main/java/it/niedermann/owncloud/notes/accountswitcher/repository/UserStatusRepository.kt new file mode 100644 index 000000000..bc816dd78 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/accountswitcher/repository/UserStatusRepository.kt @@ -0,0 +1,151 @@ +/* + * Nextcloud Notes - Android Client + * + * SPDX-FileCopyrightText: 2015-2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package it.niedermann.owncloud.notes.accountswitcher.repository + +import android.content.Context +import com.nextcloud.android.sso.model.SingleSignOnAccount +import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.lib.resources.users.PredefinedStatus +import com.owncloud.android.lib.resources.users.Status +import com.owncloud.android.lib.resources.users.StatusType +import it.niedermann.owncloud.notes.persistence.ApiProvider +import it.niedermann.owncloud.notes.persistence.NotesRepository +import it.niedermann.owncloud.notes.shared.model.Capabilities +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +class UserStatusRepository( + private val context: Context, + private val ssoAccount: SingleSignOnAccount +) { + companion object { + private const val TAG = "UserStatusRepository" + } + + private val notesRepository: NotesRepository by lazy { NotesRepository.getInstance(context) } + private val api by lazy { ApiProvider.getInstance().getUserStatusAPI(context, ssoAccount) } + + fun getCapabilities(): Capabilities = notesRepository.capabilities + + suspend fun fetchPredefinedStatuses(): ArrayList = withContext(Dispatchers.IO) { + try { + val response = api.fetchPredefinedStatuses().execute() + if (response.isSuccessful) { + Log_OC.d(TAG, "βœ… fetching predefined statuses successfully completed") + response.body()?.ocs?.data ?: arrayListOf() + } else { + Log_OC.e(TAG, "❌ fetching predefined statuses failed") + arrayListOf() + } + } catch (t: Throwable) { + Log_OC.e(TAG, "❌ fetching predefined statuses failed", t) + arrayListOf() + } + } + + suspend fun clearStatus(): Boolean = withContext(Dispatchers.IO) { + try { + val call = api.clearStatusMessage() + val response = call.execute() + if (response.isSuccessful) { + Log_OC.d(TAG, "βœ… clearing status successfully completed") + true + } else { + Log_OC.e(TAG, "❌ clearing status failed") + false + } + } catch (t: Throwable) { + Log_OC.e(TAG, "❌ clearing status failed", t) + false + } + } + + suspend fun setPredefinedStatus(messageId: String, clearAt: Long? = null): Boolean = + withContext(Dispatchers.IO) { + try { + val body = mutableMapOf("messageId" to messageId) + clearAt?.let { body["clearAt"] = it.toString() } + val call = api.setPredefinedStatusMessage(body) + val response = call.execute() + if (response.isSuccessful) { + Log_OC.d(TAG, "βœ… predefined status successfully set") + true + } else { + Log_OC.e(TAG, "❌ setting predefined status failed") + false + } + } catch (t: Throwable) { + Log_OC.e(TAG, "❌ setting predefined status failed", t) + false + } + } + + suspend fun setCustomStatus( + message: String, + statusIcon: String, + clearAt: Long? = null + ): Boolean = withContext(Dispatchers.IO) { + try { + val body = mutableMapOf( + "message" to message, + "statusIcon" to statusIcon + ) + clearAt?.let { body["clearAt"] = it.toString() } + + val call = api.setUserDefinedStatusMessage(body) + val response = call.execute() + if (response.isSuccessful) { + Log_OC.d(TAG, "βœ… setting custom status successfully completed") + true + } else { + Log_OC.e(TAG, "❌ setting custom status failed") + false + } + } catch (t: Throwable) { + Log_OC.e(TAG, "❌setting custom status failed", t) + false + } + } + + fun fetchUserStatus(): Status? { + val offlineStatus = Status(StatusType.OFFLINE, "", "", -1) + return try { + val call = api.fetchUserStatus() + val response = call.execute() + if (response.isSuccessful) { + Log_OC.d(TAG, "βœ… fetching user status successfully completed") + response.body()?.ocs?.data ?: offlineStatus + } else { + Log_OC.e(TAG, "❌ fetching user status failed") + offlineStatus + } + + } catch (t: Throwable) { + Log_OC.e(TAG, "❌ fetching user status failed $t") + offlineStatus + } + } + + suspend fun setStatusType(statusType: StatusType): Boolean = withContext(Dispatchers.IO) { + try { + val body = mutableMapOf( + "statusType" to statusType.string, + ) + val response = api.setStatusType(body).execute() + if (response.isSuccessful) { + Log_OC.d(TAG, "βœ… status type successfully set") + true + } else { + Log_OC.e(TAG, "❌ setting status type failed") + false + } + } catch (t: Throwable) { + Log_OC.e(TAG, "❌ setting status type failed", t) + false + } + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/branding/BrandedBottomSheetDialogFragment.kt b/app/src/main/java/it/niedermann/owncloud/notes/branding/BrandedBottomSheetDialogFragment.kt new file mode 100644 index 000000000..21e16e2a7 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/branding/BrandedBottomSheetDialogFragment.kt @@ -0,0 +1,21 @@ +/* + * Nextcloud Notes - Android Client + * + * SPDX-FileCopyrightText: 2015-2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package it.niedermann.owncloud.notes.branding + +import androidx.annotation.ColorInt +import com.google.android.material.bottomsheet.BottomSheetDialogFragment + +abstract class BrandedBottomSheetDialogFragment(contentLayoutId: Int) : + BottomSheetDialogFragment(contentLayoutId), Branded { + + override fun onStart() { + super.onStart() + + @ColorInt val color = BrandingUtil.readBrandMainColor(requireContext()) + applyBrand(color) + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/persistence/ApiProvider.java b/app/src/main/java/it/niedermann/owncloud/notes/persistence/ApiProvider.java index e183b4c18..8143a9a9e 100644 --- a/app/src/main/java/it/niedermann/owncloud/notes/persistence/ApiProvider.java +++ b/app/src/main/java/it/niedermann/owncloud/notes/persistence/ApiProvider.java @@ -31,9 +31,9 @@ import it.niedermann.owncloud.notes.persistence.sync.NotesAPI; import it.niedermann.owncloud.notes.persistence.sync.OcsAPI; import it.niedermann.owncloud.notes.persistence.sync.ShareAPI; +import it.niedermann.owncloud.notes.persistence.sync.UserStatusAPI; import it.niedermann.owncloud.notes.shared.model.ApiVersion; import it.niedermann.owncloud.notes.shared.model.Capabilities; -import okhttp3.ResponseBody; import retrofit2.NextcloudRetrofitApiBuilder; import retrofit2.Retrofit; @@ -49,12 +49,14 @@ public class ApiProvider { private static final ApiProvider INSTANCE = new ApiProvider(); private static final String API_ENDPOINT_OCS = "/ocs/v2.php/cloud/"; + private static final String API_USER_STATUS = "/ocs/v2.php/apps/user_status/api/v1/"; private static final String API_ENDPOINT_FILES ="/ocs/v2.php/apps/files/api/v1/"; private static final String API_ENDPOINT_FILES_SHARING ="/ocs/v2.php/apps/files_sharing/api/v1/"; private static final Map API_CACHE = new ConcurrentHashMap<>(); private static final Map API_CACHE_OCS = new ConcurrentHashMap<>(); + private static final Map API_CACHE_USER_STATUS = new ConcurrentHashMap<>(); private static final Map API_CACHE_NOTES = new ConcurrentHashMap<>(); private static final Map API_CACHE_FILES = new ConcurrentHashMap<>(); private static final Map API_CACHE_FILES_SHARING = new ConcurrentHashMap<>(); @@ -80,6 +82,15 @@ public synchronized OcsAPI getOcsAPI(@NonNull Context context, @NonNull SingleSi return ocsAPI; } + public synchronized UserStatusAPI getUserStatusAPI(@NonNull Context context, @NonNull SingleSignOnAccount ssoAccount) { + if (API_CACHE_USER_STATUS.containsKey(ssoAccount.name)) { + return API_CACHE_USER_STATUS.get(ssoAccount.name); + } + final var result = new NextcloudRetrofitApiBuilder(getNextcloudAPI(context, ssoAccount), API_USER_STATUS).create(UserStatusAPI.class); + API_CACHE_USER_STATUS.put(ssoAccount.name, result); + return result; + } + /** * In case the {@param preferredApiVersion} changes, call {@link #invalidateAPICache(SingleSignOnAccount)} or {@link #invalidateAPICache()} to make sure that this call returns a {@link NotesAPI} that uses the correct compatibility layer. */ @@ -118,7 +129,6 @@ private synchronized NextcloudAPI getNextcloudAPI(@NonNull Context context, @Non final var nextcloudAPI = new NextcloudAPI(context.getApplicationContext(), ssoAccount, new GsonBuilder() .setStrictness(Strictness.LENIENT) - .excludeFieldsWithoutExposeAnnotation() .registerTypeHierarchyAdapter(Calendar.class, (JsonSerializer) (src, typeOfSrc, ctx) -> new JsonPrimitive(src.getTimeInMillis() / 1_000)) .registerTypeHierarchyAdapter(Calendar.class, (JsonDeserializer) (src, typeOfSrc, ctx) -> { final var calendar = Calendar.getInstance(); @@ -151,6 +161,8 @@ public synchronized void invalidateAPICache(@NonNull SingleSignOnAccount ssoAcco } API_CACHE_NOTES.remove(ssoAccount.name); API_CACHE_OCS.remove(ssoAccount.name); + API_CACHE_USER_STATUS.remove(ssoAccount.name); + API_CACHE_FILES_SHARING.remove(ssoAccount.name); } /** @@ -169,5 +181,7 @@ public synchronized void invalidateAPICache() { } API_CACHE_NOTES.clear(); API_CACHE_OCS.clear(); + API_CACHE_USER_STATUS.clear(); + API_CACHE_FILES_SHARING.clear(); } } diff --git a/app/src/main/java/it/niedermann/owncloud/notes/persistence/NotesDatabase.java b/app/src/main/java/it/niedermann/owncloud/notes/persistence/NotesDatabase.java index b1fae2161..6fd0b6cb4 100644 --- a/app/src/main/java/it/niedermann/owncloud/notes/persistence/NotesDatabase.java +++ b/app/src/main/java/it/niedermann/owncloud/notes/persistence/NotesDatabase.java @@ -58,8 +58,11 @@ NotesListWidgetData.class, ShareEntity.class, Capabilities.class - }, version = 26, - autoMigrations = { @AutoMigration(from = 25, to = 26) } + }, version = 27, + autoMigrations = { + @AutoMigration(from = 25, to = 26), + @AutoMigration(from = 26, to = 27), + } ) @TypeConverters({Converters.class}) public abstract class NotesDatabase extends RoomDatabase { @@ -77,9 +80,9 @@ public static NotesDatabase getInstance(@NonNull Context context) { private static NotesDatabase create(final Context context) { return Room.databaseBuilder( - context, - NotesDatabase.class, - NOTES_DB_NAME) + context, + NotesDatabase.class, + NOTES_DB_NAME) .addMigrations( new Migration_9_10(), // v2.0.0 new Migration_10_11(context), diff --git a/app/src/main/java/it/niedermann/owncloud/notes/persistence/sync/CapabilitiesDeserializer.java b/app/src/main/java/it/niedermann/owncloud/notes/persistence/sync/CapabilitiesDeserializer.java index 7e158363d..e814d07be 100644 --- a/app/src/main/java/it/niedermann/owncloud/notes/persistence/sync/CapabilitiesDeserializer.java +++ b/app/src/main/java/it/niedermann/owncloud/notes/persistence/sync/CapabilitiesDeserializer.java @@ -32,6 +32,7 @@ public class CapabilitiesDeserializer implements JsonDeserializer { private static final String CAPABILITIES = "capabilities"; + private static final String CAPABILITIES_USER_STATUS = "user_status"; private static final String CAPABILITIES_NOTES = "notes"; private static final String CAPABILITIES_NOTES_API_VERSION = "api_version"; private static final String CAPABILITIES_THEMING = "theming"; @@ -42,6 +43,7 @@ public class CapabilitiesDeserializer implements JsonDeserializer private static final String CAPABILITIES_FILES_DIRECT_EDITING_SUPPORTS_FILE_ID = "supportsFileId"; private static final String CAPABILITIES_FILES_SHARING = "files_sharing"; private static final String VERSION = "version"; + private static final String CAPABILITIES_SUPPORTS_BUSY = "supports_busy"; @Override public Capabilities deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException { @@ -110,6 +112,16 @@ public Capabilities deserialize(JsonElement json, Type typeOfT, JsonDeserializat } } response.setDirectEditingAvailable(hasDirectEditingCapability(capabilities)); + + if (capabilities.has(CAPABILITIES_USER_STATUS)) { + final var userStatus = capabilities.getAsJsonObject(CAPABILITIES_USER_STATUS); + if (userStatus.has(CAPABILITIES_SUPPORTS_BUSY)) { + final var userStatusSupportsBusy = userStatus.getAsJsonPrimitive(CAPABILITIES_SUPPORTS_BUSY); + if (userStatusSupportsBusy != null) { + response.setUserStatusSupportsBusy(userStatusSupportsBusy.getAsBoolean()); + } + } + } } return response; } diff --git a/app/src/main/java/it/niedermann/owncloud/notes/persistence/sync/UserStatusAPI.kt b/app/src/main/java/it/niedermann/owncloud/notes/persistence/sync/UserStatusAPI.kt new file mode 100644 index 000000000..c408c8283 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/persistence/sync/UserStatusAPI.kt @@ -0,0 +1,40 @@ +/* + * Nextcloud Notes - Android Client + * + * SPDX-FileCopyrightText: 2015-2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package it.niedermann.owncloud.notes.persistence.sync + +import com.owncloud.android.lib.resources.users.PredefinedStatus +import com.owncloud.android.lib.resources.users.Status +import it.niedermann.owncloud.notes.shared.model.OcsResponse +import retrofit2.Call +import retrofit2.http.Body +import retrofit2.http.DELETE +import retrofit2.http.GET +import retrofit2.http.Headers +import retrofit2.http.PUT + +interface UserStatusAPI { + @GET("user_status?format=json") + fun fetchUserStatus(): Call> + + @DELETE("user_status/message?format=json") + fun clearStatusMessage(): Call>> + + @GET("predefined_statuses?format=json") + fun fetchPredefinedStatuses(): Call>> + + @PUT("user_status/message/predefined?format=json") + @Headers("Content-Type: application/json") + fun setPredefinedStatusMessage(@Body body: Map): Call> + + @PUT("user_status/message/custom?format=json") + @Headers("Content-Type: application/json") + fun setUserDefinedStatusMessage(@Body body: Map): Call> + + @PUT("user_status/status?format=json") + @Headers("Content-Type: application/json") + fun setStatusType(@Body body: Map): Call> +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/shared/model/Capabilities.java b/app/src/main/java/it/niedermann/owncloud/notes/shared/model/Capabilities.java index e7f1f98d2..d4f29d364 100644 --- a/app/src/main/java/it/niedermann/owncloud/notes/shared/model/Capabilities.java +++ b/app/src/main/java/it/niedermann/owncloud/notes/shared/model/Capabilities.java @@ -45,6 +45,7 @@ public class Capabilities implements Serializable { private boolean askForOptionalPassword; private boolean isReSharingAllowed; private int defaultPermission = OCShare.NO_PERMISSION; + private Boolean userStatusSupportsBusy = null; public boolean isReSharingAllowed() { return isReSharingAllowed; @@ -146,7 +147,6 @@ public void setTextColor(@ColorInt int textColor) { this.textColor = textColor; } - public boolean isDirectEditingAvailable() { return directEditingAvailable; } @@ -155,6 +155,14 @@ public void setDirectEditingAvailable(boolean directEditingAvailable) { this.directEditingAvailable = directEditingAvailable; } + public boolean isUserStatusSupportsBusy() { + return userStatusSupportsBusy != null && userStatusSupportsBusy; + } + + public void setUserStatusSupportsBusy(boolean value) { + userStatusSupportsBusy = value; + } + @Override public String toString() { return "Capabilities{" + @@ -169,6 +177,7 @@ public String toString() { ", textColor=" + textColor + ", eTag='" + eTag + '\'' + ", hasDirectEditing=" + directEditingAvailable + + ", userStatusSupportsBusy=" + userStatusSupportsBusy + '}'; } } diff --git a/app/src/main/java/it/niedermann/owncloud/notes/shared/util/DisplayUtils.java b/app/src/main/java/it/niedermann/owncloud/notes/shared/util/DisplayUtils.java index 1790add08..47b332c51 100644 --- a/app/src/main/java/it/niedermann/owncloud/notes/shared/util/DisplayUtils.java +++ b/app/src/main/java/it/niedermann/owncloud/notes/shared/util/DisplayUtils.java @@ -19,6 +19,7 @@ import android.net.Uri; import android.os.Build; import android.text.TextUtils; +import android.text.format.DateUtils; import android.util.TypedValue; import android.view.View; import android.view.WindowInsets; @@ -29,7 +30,9 @@ import com.google.android.material.snackbar.Snackbar; +import java.text.DateFormat; import java.util.Collection; +import java.util.Date; import java.util.List; import java.util.Locale; import java.util.Map; @@ -43,6 +46,8 @@ public class DisplayUtils { + private static final int DATE_TIME_PARTS_SIZE = 2; + private static final Map> SPECIAL_CATEGORY_REPLACEMENTS = Map.of( R.drawable.selector_music, singletonList(R.string.category_music), R.drawable.selector_movies, asList(R.string.category_movies, R.string.category_movie), @@ -189,4 +194,51 @@ public static Snackbar showSnackMessage(View view, String message) { return snackbar; } + public static CharSequence getRelativeTimestamp(Context context, long modificationTimestamp, boolean showFuture) { + return getRelativeDateTimeString(context, + modificationTimestamp, + DateUtils.SECOND_IN_MILLIS, + DateUtils.WEEK_IN_MILLIS, + 0, + showFuture); + } + + public static CharSequence getRelativeDateTimeString(Context c, + long time, + long minResolution, + long transitionResolution, + int flags, + boolean showFuture) { + + + // in Future + if (!showFuture && time > System.currentTimeMillis()) { + return DisplayUtils.unixTimeToHumanReadable(time); + } + // < 60 seconds -> seconds ago + long diff = System.currentTimeMillis() - time; + if (diff > 0 && diff < 60 * 1000 && minResolution == DateUtils.SECOND_IN_MILLIS) { + return c.getString(R.string.file_list_seconds_ago); + } else { + CharSequence dateString = DateUtils.getRelativeDateTimeString(c, time, minResolution, transitionResolution, flags); + + String[] parts = dateString.toString().split(","); + if (parts.length == DATE_TIME_PARTS_SIZE) { + if (parts[1].contains(":") && !parts[0].contains(":")) { + return parts[0]; + } else if (parts[0].contains(":") && !parts[1].contains(":")) { + return parts[1]; + } + } + // dateString contains unexpected format. fallback: use relative date time string from android api as is. + return dateString.toString(); + } + } + + public static String unixTimeToHumanReadable(long milliseconds) { + Date date = new Date(milliseconds); + DateFormat df = DateFormat.getDateTimeInstance(); + return df.format(date); + } + } diff --git a/app/src/main/java/it/niedermann/owncloud/notes/shared/util/FilesSpecificViewThemeUtils.kt b/app/src/main/java/it/niedermann/owncloud/notes/shared/util/FilesSpecificViewThemeUtils.kt index 92978cc18..c2c18d571 100644 --- a/app/src/main/java/it/niedermann/owncloud/notes/shared/util/FilesSpecificViewThemeUtils.kt +++ b/app/src/main/java/it/niedermann/owncloud/notes/shared/util/FilesSpecificViewThemeUtils.kt @@ -7,6 +7,8 @@ package it.niedermann.owncloud.notes.shared.util import android.content.Context +import android.content.res.ColorStateList +import android.view.View import android.widget.ImageView import androidx.annotation.DrawableRes import androidx.annotation.Px @@ -14,8 +16,13 @@ import androidx.core.content.ContextCompat import androidx.core.content.res.ResourcesCompat import androidx.core.graphics.BlendModeColorFilterCompat import androidx.core.graphics.BlendModeCompat +import com.google.android.material.card.MaterialCardView +import com.nextcloud.android.common.ui.theme.MaterialSchemes +import com.nextcloud.android.common.ui.util.PlatformThemeUtil +import com.nextcloud.android.common.ui.util.extensions.toColorScheme import com.owncloud.android.lib.common.utils.Log_OC import com.owncloud.android.lib.resources.shares.ShareType +import dynamiccolor.DynamicScheme import it.niedermann.owncloud.notes.R import it.niedermann.owncloud.notes.branding.BrandingUtil @@ -31,6 +38,48 @@ object FilesSpecificViewThemeUtils { const val LARGE = 8 } + private fun getSchemeInternal(context: Context, color: Int): DynamicScheme { + val scheme = MaterialSchemes.Companion.fromColor(color) + return when { + PlatformThemeUtil.isDarkMode(context) -> scheme.darkScheme + else -> scheme.lightScheme + } + } + + private fun withScheme( + view: View, + color: Int, + block: (DynamicScheme) -> R + ): R = block(getSchemeInternal(view.context, color)) + + fun themeStatusCardView(cardView: MaterialCardView, color: Int) { + withScheme(cardView, color) { scheme -> + cardView.backgroundTintList = + ColorStateList( + arrayOf( + intArrayOf(android.R.attr.state_checked), + intArrayOf(-android.R.attr.state_checked) + ), + intArrayOf( + scheme.surfaceContainerHighest, + scheme.surface + ) + ) + cardView.setStrokeColor( + ColorStateList( + arrayOf( + intArrayOf(android.R.attr.state_checked), + intArrayOf(-android.R.attr.state_checked) + ), + intArrayOf( + scheme.onSecondaryContainer, + scheme.outlineVariant + ) + ) + ) + } + } + fun createAvatar(type: ShareType?, avatar: ImageView, context: Context) { fun createAvatarBase(@DrawableRes icon: Int, padding: Int = AvatarPadding.SMALL) { avatar.setImageResource(icon) diff --git a/app/src/main/java/it/niedermann/owncloud/notes/util/ActivityExtensions.kt b/app/src/main/java/it/niedermann/owncloud/notes/util/ActivityExtensions.kt new file mode 100644 index 000000000..57e0cb361 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/util/ActivityExtensions.kt @@ -0,0 +1,44 @@ +/* + * Nextcloud Notes - Android Client + * + * SPDX-FileCopyrightText: 2015-2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package it.niedermann.owncloud.notes.util + +import androidx.core.app.ComponentActivity +import androidx.lifecycle.lifecycleScope +import com.nextcloud.android.sso.helper.SingleAccountHelper +import com.nextcloud.android.sso.model.SingleSignOnAccount +import com.owncloud.android.lib.common.utils.Log_OC +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** + * Retrieves the currently active Single Sign-On (SSO) account associated with this [ComponentActivity]. + * + * This function runs asynchronously using a coroutine: + * - The SSO account lookup is performed on the **IO dispatcher** (background thread). + * - Once the result is available, the [onCompleted] callback is invoked on the **main thread**. + * + * If fetching the account fails for any reason (e.g., no account found, SSO error, etc.), + * the callback will receive `null` and an error will be logged. + * + * @param onCompleted A callback that receives the retrieved [SingleSignOnAccount], + * or `null` if no valid account was found. + */ +fun ComponentActivity.ssoAccount(onCompleted: (SingleSignOnAccount?) -> Unit) { + lifecycleScope.launch(Dispatchers.IO) { + val result = try { + val account = SingleAccountHelper.getCurrentSingleSignOnAccount(this@ssoAccount) + account + } catch (t: Throwable) { + Log_OC.e("ComponentActivityExtension", "cant get sso account: $t") + null + } + withContext(Dispatchers.Main) { + onCompleted(result) + } + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/util/StatusTypeExtensions.kt b/app/src/main/java/it/niedermann/owncloud/notes/util/StatusTypeExtensions.kt new file mode 100644 index 000000000..760890deb --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/util/StatusTypeExtensions.kt @@ -0,0 +1,20 @@ +/* + * Nextcloud Notes - Android Client + * + * SPDX-FileCopyrightText: 2015-2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package it.niedermann.owncloud.notes.util + +import com.owncloud.android.lib.resources.users.StatusType +import it.niedermann.owncloud.notes.R + +val StatusType.imageResource: Int + get() = when (this) { + StatusType.ONLINE -> R.drawable.ic_user_status_online + StatusType.OFFLINE -> R.drawable.ic_user_status_busy + StatusType.DND -> R.drawable.ic_user_status_dnd + StatusType.AWAY -> R.drawable.ic_user_status_away + StatusType.INVISIBLE -> R.drawable.ic_user_status_invisible + StatusType.BUSY -> R.drawable.ic_user_status_busy + } diff --git a/app/src/main/res/drawable/borderless_btn.xml b/app/src/main/res/drawable/borderless_btn.xml new file mode 100644 index 000000000..9f2ccb856 --- /dev/null +++ b/app/src/main/res/drawable/borderless_btn.xml @@ -0,0 +1,14 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/chat_bubble.xml b/app/src/main/res/drawable/chat_bubble.xml new file mode 100644 index 000000000..23e3c9c37 --- /dev/null +++ b/app/src/main/res/drawable/chat_bubble.xml @@ -0,0 +1,16 @@ + + + + diff --git a/app/src/main/res/drawable/ic_check_circle.xml b/app/src/main/res/drawable/ic_check_circle.xml new file mode 100644 index 000000000..1c7459e3b --- /dev/null +++ b/app/src/main/res/drawable/ic_check_circle.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_user_status_away.xml b/app/src/main/res/drawable/ic_user_status_away.xml new file mode 100644 index 000000000..68fdf4102 --- /dev/null +++ b/app/src/main/res/drawable/ic_user_status_away.xml @@ -0,0 +1,15 @@ + + + + diff --git a/app/src/main/res/drawable/ic_user_status_busy.xml b/app/src/main/res/drawable/ic_user_status_busy.xml new file mode 100644 index 000000000..bdf9611a8 --- /dev/null +++ b/app/src/main/res/drawable/ic_user_status_busy.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_user_status_dnd.xml b/app/src/main/res/drawable/ic_user_status_dnd.xml new file mode 100644 index 000000000..b9be2e6e7 --- /dev/null +++ b/app/src/main/res/drawable/ic_user_status_dnd.xml @@ -0,0 +1,15 @@ + + + + diff --git a/app/src/main/res/drawable/ic_user_status_invisible.xml b/app/src/main/res/drawable/ic_user_status_invisible.xml new file mode 100644 index 000000000..d79901b69 --- /dev/null +++ b/app/src/main/res/drawable/ic_user_status_invisible.xml @@ -0,0 +1,15 @@ + + + + diff --git a/app/src/main/res/drawable/ic_user_status_online.xml b/app/src/main/res/drawable/ic_user_status_online.xml new file mode 100644 index 000000000..df952c4de --- /dev/null +++ b/app/src/main/res/drawable/ic_user_status_online.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/app/src/main/res/layout/dialog_account_switcher.xml b/app/src/main/res/layout/dialog_account_switcher.xml index 76b3a9a6d..beb9add50 100644 --- a/app/src/main/res/layout/dialog_account_switcher.xml +++ b/app/src/main/res/layout/dialog_account_switcher.xml @@ -22,15 +22,38 @@ android:padding="@dimen/spacer_2x" android:paddingHorizontal="@dimen/spacer_1x"> - + android:layout_marginStart="@dimen/spacer_activity_sides"> + + + + + + + + + @@ -70,6 +103,57 @@ app:srcCompat="@drawable/check" /> + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/set_online_status_bottom_sheet.xml b/app/src/main/res/layout/set_online_status_bottom_sheet.xml new file mode 100644 index 000000000..c69746df6 --- /dev/null +++ b/app/src/main/res/layout/set_online_status_bottom_sheet.xml @@ -0,0 +1,390 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/set_status_message_bottom_sheet.xml b/app/src/main/res/layout/set_status_message_bottom_sheet.xml new file mode 100644 index 000000000..9e5c863b8 --- /dev/null +++ b/app/src/main/res/layout/set_status_message_bottom_sheet.xml @@ -0,0 +1,148 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values-night/colors.xml b/app/src/main/res/values-night/colors.xml index 34d35a722..fd7704512 100644 --- a/app/src/main/res/values-night/colors.xml +++ b/app/src/main/res/values-night/colors.xml @@ -40,4 +40,6 @@ #ffffff #1E1E1E + @android:color/white + #000000 \ No newline at end of file diff --git a/app/src/main/res/values-v31/dimens.xml b/app/src/main/res/values-v31/dimens.xml index 559394ce3..a6cdbf9fe 100644 --- a/app/src/main/res/values-v31/dimens.xml +++ b/app/src/main/res/values-v31/dimens.xml @@ -15,7 +15,7 @@ 24dp 40dp - 36dp + 40dp 0dp diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index c0c740047..b09134d24 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -49,4 +49,12 @@ #222222 #ededed + #666666 + #00000000 + #D6D7D7 + #000000 + #ffffff + #2D7B41 + #DB0606 + @color/high_emphasis_text diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml index 943dce10e..b8596f742 100644 --- a/app/src/main/res/values/dimens.xml +++ b/app/src/main/res/values/dimens.xml @@ -25,7 +25,8 @@ 180dp 100dp - 36dp + 40dp + 16dp 196dip 48dp @@ -51,6 +52,8 @@ 16sp 14sp + 14sp + 12sp 16sp @@ -72,4 +75,6 @@ 48dp 56dp + 60dp + 24dp diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 919f5fe49..07140003f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -509,4 +509,34 @@ Switch to plain editing Back Mozilla/5.0 (Android) %1$s-android/%2$s + Online status + Status message + πŸ˜ƒ + What is your status? + πŸ“† + In a meeting + an hour + β€” + Clear status after + Clear + Set message + Don\'t clear + Today + 30 minutes + 1 hour + 4 hours + This week + Error setting status message! + seconds ago + Online status + Online + Do not disturb + Away + Invisible + Busy + Mute all notifications + Appear offline + Failed to set status! + Failed to fetch status, please try again. + Online diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index b84323cdc..66c2c59d8 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -124,4 +124,31 @@ tools:ignore="ResourceCycle"> @layout/preference_switch + + + + + + + + \ No newline at end of file