diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 3c96bfd193d..5511fee85d8 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -125,6 +125,9 @@ dependencies { // Compose iterative code, layout inspector, etc. debugImplementation(libs.compose.tooling) + // Emoji + implementation(libs.androidx.emoji.picker) + // hilt implementation(libs.hilt.navigationCompose) implementation(libs.hilt.work) diff --git a/app/src/main/kotlin/com/wire/android/ui/edit/ReactionOption.kt b/app/src/main/kotlin/com/wire/android/ui/edit/ReactionOption.kt index af53a81d420..f0499a45a92 100644 --- a/app/src/main/kotlin/com/wire/android/ui/edit/ReactionOption.kt +++ b/app/src/main/kotlin/com/wire/android/ui/edit/ReactionOption.kt @@ -19,9 +19,12 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextStyle @@ -30,6 +33,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.wire.android.R import com.wire.android.ui.common.dimensions +import com.wire.android.ui.emoji.EmojiPickerBottomSheet import com.wire.android.ui.theme.WireTheme import com.wire.android.ui.theme.wireColorScheme import com.wire.android.ui.theme.wireTypography @@ -41,6 +45,7 @@ fun ReactionOption( onReactionClick: (emoji: String) -> Unit, emojiFontSize: TextUnit = 28.sp ) { + var isEmojiPickerVisible by remember { mutableStateOf(false) } CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.secondary) { Column { Row { @@ -76,11 +81,8 @@ fun ReactionOption( } IconButton( onClick = { - // TODO show more emojis + isEmojiPickerVisible = true }, - modifier = Modifier - // TODO remove when all emojis will be available - .alpha(0.1F), ) { Icon( painter = painterResource(id = R.drawable.ic_more_emojis), @@ -90,6 +92,16 @@ fun ReactionOption( } } } + EmojiPickerBottomSheet( + isVisible = isEmojiPickerVisible, + onDismiss = { + isEmojiPickerVisible = false + }, + onEmojiSelected = { + onReactionClick(it) + isEmojiPickerVisible = false + } + ) } @PreviewMultipleThemes diff --git a/app/src/main/kotlin/com/wire/android/ui/emoji/DraggableByHandleBottomSheetBehavior.kt b/app/src/main/kotlin/com/wire/android/ui/emoji/DraggableByHandleBottomSheetBehavior.kt new file mode 100644 index 00000000000..a5b2e3b2e30 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/emoji/DraggableByHandleBottomSheetBehavior.kt @@ -0,0 +1,39 @@ +/* + * Wire + * Copyright (C) 2023 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.ui.emoji + +import android.content.Context +import android.util.AttributeSet +import android.view.MotionEvent +import android.view.View +import androidx.coordinatorlayout.widget.CoordinatorLayout +import com.google.android.material.bottomsheet.BottomSheetBehavior + +class DraggableByHandleBottomSheetBehavior( + context: Context, + attributeSet: AttributeSet +) : BottomSheetBehavior(context, attributeSet) { + var dragHandle: View? = null + + override fun onInterceptTouchEvent(parent: CoordinatorLayout, child: V, event: MotionEvent): Boolean { + dragHandle?.let { + isDraggable = parent.isPointInChildBounds(it, event.x.toInt(), event.y.toInt()) + } + return super.onInterceptTouchEvent(parent, child, event) + } +} diff --git a/app/src/main/kotlin/com/wire/android/ui/emoji/EmojiPicker.kt b/app/src/main/kotlin/com/wire/android/ui/emoji/EmojiPicker.kt new file mode 100644 index 00000000000..39acfb3ed87 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/emoji/EmojiPicker.kt @@ -0,0 +1,59 @@ +/* + * Wire + * Copyright (C) 2023 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.ui.emoji + +import android.widget.LinearLayout +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext +import androidx.emoji2.emojipicker.EmojiPickerView +import com.google.android.material.bottomsheet.BottomSheetDragHandleView + +@Composable +fun EmojiPickerBottomSheet( + isVisible: Boolean, + onDismiss: () -> Unit = {}, + onEmojiSelected: (emoji: String) -> Unit +) { + val context = LocalContext.current + val dialog = remember { + HandleDraggableBottomSheetDialog(context).apply { + setContentView( + LinearLayout(context).apply { + orientation = LinearLayout.VERTICAL + val handle = BottomSheetDragHandleView(context) + getBehavior().dragHandle = handle + addView(handle) + addView( + EmojiPickerView(context).apply { + setOnEmojiPickedListener { emojiViewItem -> + onEmojiSelected(emojiViewItem.emoji) + } + } + ) + } + ) + setOnCancelListener { onDismiss.invoke() } + } + } + if (isVisible) { + dialog.show() + } else { + dialog.hide() + } +} diff --git a/app/src/main/kotlin/com/wire/android/ui/emoji/HandleDraggableBottomSheetDialog.kt b/app/src/main/kotlin/com/wire/android/ui/emoji/HandleDraggableBottomSheetDialog.kt new file mode 100644 index 00000000000..28f81a75c06 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/emoji/HandleDraggableBottomSheetDialog.kt @@ -0,0 +1,286 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.wire.android.ui.emoji + +import android.content.Context +import android.os.Build +import android.os.Build.VERSION_CODES +import android.os.Bundle +import android.util.TypedValue +import android.view.View +import android.view.ViewGroup +import android.view.Window +import android.view.WindowManager +import android.widget.FrameLayout +import androidx.annotation.LayoutRes +import androidx.annotation.StyleRes +import androidx.appcompat.app.AppCompatDialog +import androidx.coordinatorlayout.widget.CoordinatorLayout +import androidx.core.view.AccessibilityDelegateCompat +import androidx.core.view.ViewCompat +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat +import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.google.android.material.bottomsheet.BottomSheetBehavior.BottomSheetCallback +import com.wire.android.R +import com.google.android.material.R as MaterialR + +/** + * Class translated to Kotlin and modified to support custom a [DraggableByHandleBottomSheetBehavior], + * instead of the default [BottomSheetBehavior]. + * Modified parts of the code are wrapped with `## Modified ##` and `## END Modified ##` comments. + * Parts related to edge-to-edge have also been removed, as we don't have it turned on. + * + * Base class for [android.app.Dialog]s styled as a bottom sheet. + * + * Edge to edge window flags are automatically applied if the [android.R.attr.navigationBarColor] is transparent or translucent + * and `enableEdgeToEdge` is true. These can be set in the theme that is passed to the constructor, or will be taken from the + * theme of the context (i.e. your application or activity theme). + * + * In edge to edge mode, padding will be added automatically to the top when sliding under the + * status bar. Padding can be applied automatically to the left, right, or bottom if any of + * `paddingBottomSystemWindowInsets`, `paddingLeftSystemWindowInsets`, or + * `paddingRightSystemWindowInsets` are set to true in the style. + */ +@Suppress("DEPRECATION") +class HandleDraggableBottomSheetDialog : AppCompatDialog { + private var behavior: DraggableByHandleBottomSheetBehavior? = null + + private var container: FrameLayout? = null + private var coordinator: CoordinatorLayout? = null + private var bottomSheet: FrameLayout? = null + + var dismissWithAnimation: Boolean = false + + var cancelable: Boolean = true + private set + private var canceledOnTouchOutside = true + private var canceledOnTouchOutsideSet = false + + constructor(context: Context) : this(context, 0) + + constructor(context: Context, @StyleRes theme: Int) : super(context, getThemeResId(context, theme)) { + // We hide the title bar for any style configuration. Otherwise, there will be a gap + // above the bottom sheet when it is expanded. + supportRequestWindowFeature(Window.FEATURE_NO_TITLE) + } + + override fun setContentView(@LayoutRes layoutResID: Int) { + super.setContentView(wrapInBottomSheet(layoutResID, null, null)!!) + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val window = window + if (window != null) { + if (Build.VERSION.SDK_INT >= VERSION_CODES.LOLLIPOP) { + // The status bar should always be transparent because of the window animation. + window.statusBarColor = 0 + + window.addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS) + if (Build.VERSION.SDK_INT < VERSION_CODES.M) { + // It can be transparent for API 23 and above because we will handle switching the status + // bar icons to light or dark as appropriate. For API 21 and API 22 we just set the + // translucent status bar. + window.addFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS) + } + } + window.setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT) + } + } + + override fun setContentView(view: View) { + super.setContentView(wrapInBottomSheet(0, view, null)!!) + } + + override fun setContentView(view: View, params: ViewGroup.LayoutParams?) { + super.setContentView(wrapInBottomSheet(0, view, params)!!) + } + + override fun setCancelable(cancelable: Boolean) { + super.setCancelable(cancelable) + if (this.cancelable != cancelable) { + this.cancelable = cancelable + if (behavior != null) { + behavior!!.isHideable = cancelable + } + } + } + + override fun onStart() { + super.onStart() + if (behavior != null && behavior!!.state == BottomSheetBehavior.STATE_HIDDEN) { + behavior!!.setState(BottomSheetBehavior.STATE_COLLAPSED) + } + } + + override fun cancel() { + val behavior = getBehavior() + + if (!dismissWithAnimation || behavior.state == BottomSheetBehavior.STATE_HIDDEN) { + super.cancel() + } else { + behavior.setState(BottomSheetBehavior.STATE_HIDDEN) + } + } + + override fun setCanceledOnTouchOutside(cancel: Boolean) { + super.setCanceledOnTouchOutside(cancel) + if (cancel && !cancelable) { + cancelable = true + } + canceledOnTouchOutside = cancel + canceledOnTouchOutsideSet = true + } + + fun getBehavior(): DraggableByHandleBottomSheetBehavior { + if (behavior == null) { + // The content hasn't been set, so the behavior doesn't exist yet. Let's create it. + ensureContainerAndBehavior() + } + return behavior!! + } + + /** Creates the container layout which must exist to find the behavior */ + private fun ensureContainerAndBehavior(): FrameLayout? { + if (container == null) { + // ## Modified ## + container = + View.inflate(context, R.layout.dialog_bottom_sheet_custom_behavior, null) as FrameLayout + // ## END Modified ## + + coordinator = container!!.findViewById(R.id.coordinator) as CoordinatorLayout + bottomSheet = container!!.findViewById(R.id.design_bottom_sheet) as FrameLayout + + behavior = BottomSheetBehavior.from(bottomSheet!!) as DraggableByHandleBottomSheetBehavior + + behavior!!.addBottomSheetCallback(bottomSheetCallback) + behavior!!.isHideable = cancelable + } + return container + } + + private fun wrapInBottomSheet( + layoutResId: Int, + view: View?, + params: ViewGroup.LayoutParams? + ): View? { + var view = view + ensureContainerAndBehavior() + val coordinator = container!!.findViewById(R.id.coordinator) as CoordinatorLayout + if (layoutResId != 0 && view == null) { + view = layoutInflater.inflate(layoutResId, coordinator, false) + } + + bottomSheet!!.removeAllViews() + if (params == null) { + bottomSheet!!.addView(view) + } else { + bottomSheet!!.addView(view, params) + } + // We treat the CoordinatorLayout as outside the dialog though it is technically inside + coordinator + .findViewById(R.id.touch_outside) + .setOnClickListener { + if (cancelable && isShowing && shouldWindowCloseOnTouchOutside()) { + cancel() + } + } + // Handle accessibility events + ViewCompat.setAccessibilityDelegate( + bottomSheet!!, + object : AccessibilityDelegateCompat() { + override fun onInitializeAccessibilityNodeInfo( + host: View, + info: AccessibilityNodeInfoCompat + ) { + super.onInitializeAccessibilityNodeInfo(host, info) + if (cancelable) { + info.addAction(AccessibilityNodeInfoCompat.ACTION_DISMISS) + info.isDismissable = true + } else { + info.isDismissable = false + } + } + + override fun performAccessibilityAction(host: View, action: Int, args: Bundle?): Boolean { + if (action == AccessibilityNodeInfoCompat.ACTION_DISMISS && cancelable) { + cancel() + return true + } + return super.performAccessibilityAction(host, action, args) + } + } + ) + bottomSheet!!.setOnTouchListener { _, event -> // Consume the event and prevent it from falling through + true + } + return container + } + + fun shouldWindowCloseOnTouchOutside(): Boolean { + if (!canceledOnTouchOutsideSet) { + val a = + context.obtainStyledAttributes(intArrayOf(android.R.attr.windowCloseOnTouchOutside)) + canceledOnTouchOutside = a.getBoolean(0, true) + a.recycle() + canceledOnTouchOutsideSet = true + } + return canceledOnTouchOutside + } + + private val bottomSheetCallback: BottomSheetCallback = object : BottomSheetCallback() { + override fun onStateChanged( + bottomSheet: View, + @BottomSheetBehavior.State newState: Int + ) { + if (newState == BottomSheetBehavior.STATE_HIDDEN) { + cancel() + } + } + + override fun onSlide(bottomSheet: View, slideOffset: Float) = Unit + } + + companion object { + private fun getThemeResId(context: Context, themeId: Int): Int { + var themeId = themeId + if (themeId == 0) { + // If the provided theme is 0, then retrieve the dialogTheme from our theme + val outValue = TypedValue() + themeId = if (context.theme.resolveAttribute(MaterialR.attr.bottomSheetDialogTheme, outValue, true)) { + outValue.resourceId + } else { + // bottomSheetDialogTheme is not provided; we default to our light theme + MaterialR.style.Theme_Design_Light_BottomSheetDialog + } + } + return themeId + } + + @Deprecated("use {@link EdgeToEdgeUtils#setLightStatusBar(Window, boolean)} instead") + fun setLightStatusBar(view: View, isLight: Boolean) { + if (Build.VERSION.SDK_INT >= VERSION_CODES.M) { + var flags = view.systemUiVisibility + flags = if (isLight) { + flags or View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR + } else { + flags and View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR.inv() + } + view.systemUiVisibility = flags + } + } + } +} diff --git a/app/src/main/res/layout/dialog_bottom_sheet_custom_behavior.xml b/app/src/main/res/layout/dialog_bottom_sheet_custom_behavior.xml new file mode 100644 index 00000000000..62e2b9b040f --- /dev/null +++ b/app/src/main/res/layout/dialog_bottom_sheet_custom_behavior.xml @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values-night/themes.xml b/app/src/main/res/values-night/themes.xml new file mode 100644 index 00000000000..fc8ba7146f1 --- /dev/null +++ b/app/src/main/res/values-night/themes.xml @@ -0,0 +1,30 @@ + + + + + + + diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d8e4a4dd475..4fb2ed8fe22 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -26,6 +26,7 @@ androidx-appcompat = "1.6.1" androidx-core = "1.12.0" androidx-dataStore = "1.0.0" androidx-exif = "1.3.6" +androidx-emoji = "1.4.0" androidx-jetpack = "1.1.0" androidx-lifecycle = "2.6.2" androidx-paging3 = "3.1.1" @@ -35,8 +36,8 @@ androidx-workManager = "2.8.1" androidx-browser = "1.5.0" # Compose -compose = "1.6.0-alpha05" -compose-material = "1.6.0-alpha05" +compose = "1.6.0-alpha07" +compose-material = "1.6.0-alpha07" compose-activity = "1.7.2" compose-compiler = "1.5.2" compose-constraint = "1.0.1" @@ -139,6 +140,7 @@ androidx-lifecycle-viewModelSavedState = { module = "androidx.lifecycle:lifecycl # AndroidX - Other androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidx-appcompat" } +androidx-emoji-picker = { module = "androidx.emoji2:emoji2-emojipicker", version.ref = "androidx-emoji" } androidx-core = { module = "androidx.core:core-ktx", version.ref = "androidx-core" } androidx-work = { module = "androidx.work:work-runtime-ktx", version.ref = "androidx-workManager" } androidx-paging3 = { module = "androidx.paging:paging-runtime", version.ref = "androidx-paging3" }