Merge pull request #4286 from kiwix/Fixes#4245

Migrated the `LocalFileTransferFragment` to Jetpack Compose.
This commit is contained in:
Kelson 2025-04-19 17:38:36 +02:00 committed by GitHub
commit 8b499c6332
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
25 changed files with 852 additions and 820 deletions

View File

@ -19,23 +19,25 @@
package org.kiwix.kiwixmobile.localFileTransfer
import android.util.Log
import androidx.compose.ui.test.assertTextEquals
import androidx.compose.ui.test.junit4.ComposeContentTestRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performClick
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.action.ViewActions.click
import androidx.test.espresso.assertion.ViewAssertions.doesNotExist
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.espresso.matcher.ViewMatchers.withText
import applyWithViewHierarchyPrinting
import com.adevinta.android.barista.interaction.BaristaSleepInteractions
import org.kiwix.kiwixmobile.BaseRobot
import org.kiwix.kiwixmobile.Findable.StringId.TextId
import org.kiwix.kiwixmobile.Findable.ViewId
import org.kiwix.kiwixmobile.R
import org.kiwix.kiwixmobile.core.R.string
import org.kiwix.kiwixmobile.core.ui.components.SHOWCASE_VIEW_MESSAGE_TESTING_TAG
import org.kiwix.kiwixmobile.core.ui.components.SHOWCASE_VIEW_NEXT_BUTTON_TESTING_TAG
import org.kiwix.kiwixmobile.core.ui.components.TOOLBAR_TITLE_TESTING_TAG
import org.kiwix.kiwixmobile.core.page.SEARCH_ICON_TESTING_TAG
import org.kiwix.kiwixmobile.testutils.TestUtils
import org.kiwix.kiwixmobile.testutils.TestUtils.testFlakyView
import uk.co.deanwild.materialshowcaseview.R.id
/**
* Authored by Ayush Shrivastava on 29/10/20
@ -45,22 +47,32 @@ fun localFileTransfer(func: LocalFileTransferRobot.() -> Unit) =
LocalFileTransferRobot().applyWithViewHierarchyPrinting(func)
class LocalFileTransferRobot : BaseRobot() {
fun assertReceiveFileTitleVisible() {
isVisible(TextId(R.string.receive_files_title))
fun assertReceiveFileTitleVisible(composeContentTestRule: ComposeContentTestRule) {
composeContentTestRule.apply {
waitForIdle()
onNodeWithTag(TOOLBAR_TITLE_TESTING_TAG)
.assertTextEquals(context.getString(R.string.receive_files_title))
}
}
fun assertSearchDeviceMenuItemVisible() {
isVisible(ViewId(R.id.menu_item_search_devices))
fun assertSearchDeviceMenuItemVisible(composeContentTestRule: ComposeContentTestRule) {
composeContentTestRule.apply {
waitForIdle()
onNodeWithTag(SEARCH_ICON_TESTING_TAG).assertExists()
}
}
fun clickOnSearchDeviceMenuItem() {
clickOn(ViewId(R.id.menu_item_search_devices))
fun clickOnSearchDeviceMenuItem(composeContentTestRule: ComposeContentTestRule) {
composeContentTestRule.apply {
waitForIdle()
onNodeWithTag(SEARCH_ICON_TESTING_TAG).performClick()
}
}
fun assertLocalFileTransferScreenVisible() {
fun assertLocalFileTransferScreenVisible(composeContentTestRule: ComposeContentTestRule) {
BaristaSleepInteractions.sleep(TestUtils.TEST_PAUSE_MS_FOR_DOWNLOAD_TEST.toLong())
closeEnableWifiP2PDialogIfVisible()
assertReceiveFileTitleVisible()
assertReceiveFileTitleVisible(composeContentTestRule)
}
private fun closeEnableWifiP2PDialogIfVisible() {
@ -69,7 +81,7 @@ class LocalFileTransferRobot : BaseRobot() {
onView(withText(string.request_enable_wifi)).check(matches(isDisplayed()))
pressBack()
})
} catch (ignore: Throwable) {
} catch (_: Throwable) {
Log.i(
"LOCAL_FILE_TRANSFER_TEST",
"Couldn't found WIFI P2P dialog, probably this is not exist"
@ -77,53 +89,66 @@ class LocalFileTransferRobot : BaseRobot() {
}
}
fun assertLocalLibraryVisible() {
isVisible(TextId(string.library))
fun assertLocalLibraryVisible(composeContentTestRule: ComposeContentTestRule) {
composeContentTestRule.apply {
waitForIdle()
onNodeWithTag(TOOLBAR_TITLE_TESTING_TAG)
.assertTextEquals(context.getString(string.library))
}
}
fun assertClickNearbyDeviceMessageVisible() {
fun assertClickNearbyDeviceMessageVisible(composeContentTestRule: ComposeContentTestRule) {
pauseForBetterTestPerformance()
testFlakyView({
onView(withId(id.tv_content))
.check(matches(withText(string.click_nearby_devices_message)))
})
composeContentTestRule.apply {
waitForIdle()
onNodeWithTag(SHOWCASE_VIEW_MESSAGE_TESTING_TAG)
.assertTextEquals(context.getString(string.click_nearby_devices_message))
}
}
fun clickOnGotItButton() {
fun clickOnNextButton(composeContentTestRule: ComposeContentTestRule) {
pauseForBetterTestPerformance()
testFlakyView({
onView(withId(id.tv_dismiss))
.perform(click())
})
composeContentTestRule.apply {
waitForIdle()
onNodeWithTag(SHOWCASE_VIEW_NEXT_BUTTON_TESTING_TAG)
.performClick()
}
}
fun assertDeviceNameMessageVisible() {
fun assertDeviceNameMessageVisible(composeContentTestRule: ComposeContentTestRule) {
pauseForBetterTestPerformance()
testFlakyView({
onView(withId(id.tv_content))
.check(matches(withText(string.your_device_name_message)))
})
composeContentTestRule.apply {
waitForIdle()
onNodeWithTag(SHOWCASE_VIEW_MESSAGE_TESTING_TAG)
.assertTextEquals(context.getString(string.your_device_name_message))
}
}
fun assertNearbyDeviceListMessageVisible() {
fun assertNearbyDeviceListMessageVisible(composeContentTestRule: ComposeContentTestRule) {
pauseForBetterTestPerformance()
testFlakyView({
onView(withId(id.tv_content))
.check(matches(withText(string.nearby_devices_list_message)))
})
composeContentTestRule.apply {
waitForIdle()
onNodeWithTag(SHOWCASE_VIEW_MESSAGE_TESTING_TAG)
.assertTextEquals(context.getString(string.nearby_devices_list_message))
}
}
fun assertTransferZimFilesListMessageVisible() {
fun assertTransferZimFilesListMessageVisible(composeContentTestRule: ComposeContentTestRule) {
pauseForBetterTestPerformance()
testFlakyView({
onView(withId(id.tv_content))
.check(matches(withText(string.transfer_zim_files_list_message)))
})
composeContentTestRule.apply {
waitForIdle()
onNodeWithTag(SHOWCASE_VIEW_MESSAGE_TESTING_TAG)
.assertTextEquals(context.getString(string.transfer_zim_files_list_message))
}
}
fun assertClickNearbyDeviceMessageNotVisible() {
fun assertClickNearbyDeviceMessageNotVisible(composeContentTestRule: ComposeContentTestRule) {
pauseForBetterTestPerformance()
onView(withText(string.click_nearby_devices_message)).check(doesNotExist())
composeContentTestRule.apply {
waitForIdle()
onNodeWithTag(SHOWCASE_VIEW_MESSAGE_TESTING_TAG)
.assertDoesNotExist()
}
}
private fun pauseForBetterTestPerformance() {

View File

@ -28,17 +28,10 @@ import androidx.lifecycle.Lifecycle
import androidx.preference.PreferenceManager
import androidx.test.core.app.ActivityScenario
import androidx.test.espresso.accessibility.AccessibilityChecks
import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.rule.GrantPermissionRule
import androidx.test.uiautomator.UiDevice
import com.google.android.apps.common.testing.accessibility.framework.AccessibilityCheckResultUtils.matchesCheck
import com.google.android.apps.common.testing.accessibility.framework.AccessibilityCheckResultUtils.matchesViews
import com.google.android.apps.common.testing.accessibility.framework.checks.SpeakableTextPresentCheck
import com.google.android.apps.common.testing.accessibility.framework.checks.TouchTargetSizeCheck
import leakcanary.LeakAssertions
import org.hamcrest.core.AllOf.allOf
import org.hamcrest.core.AnyOf.anyOf
import org.junit.Before
import org.junit.Rule
import org.junit.Test
@ -89,22 +82,6 @@ class LocalFileTransferTest {
init {
AccessibilityChecks.enable().apply {
setRunChecksFromRootView(true)
setSuppressingResultMatcher(
anyOf(
allOf(
matchesCheck(SpeakableTextPresentCheck::class.java),
matchesViews(withId(uk.co.deanwild.materialshowcaseview.R.id.tv_skip))
),
allOf(
matchesCheck(TouchTargetSizeCheck::class.java),
matchesViews(withId(uk.co.deanwild.materialshowcaseview.R.id.tv_skip))
),
allOf(
matchesCheck(TouchTargetSizeCheck::class.java),
matchesViews(withId(R.id.text_view_device_name))
)
)
)
}
}
@ -141,12 +118,12 @@ class LocalFileTransferTest {
library {
assertGetZimNearbyDeviceDisplayed(composeTestRule)
clickFileTransferIcon(composeTestRule) {
assertReceiveFileTitleVisible()
assertSearchDeviceMenuItemVisible()
clickOnSearchDeviceMenuItem()
assertLocalFileTransferScreenVisible()
assertReceiveFileTitleVisible(composeTestRule)
assertSearchDeviceMenuItemVisible(composeTestRule)
clickOnSearchDeviceMenuItem(composeTestRule)
assertLocalFileTransferScreenVisible(composeTestRule)
pressBack()
assertLocalLibraryVisible()
assertLocalLibraryVisible(composeTestRule)
}
}
LeakAssertions.assertNoLeaks()
@ -155,7 +132,7 @@ class LocalFileTransferTest {
@Test
fun showCaseFeature() {
shouldShowShowCaseFeatureToUser(true, isResetShowCaseId = true)
shouldShowShowCaseFeatureToUser(true)
activityScenario =
ActivityScenario.launch(KiwixMainActivity::class.java).apply {
moveToState(Lifecycle.State.RESUMED)
@ -172,14 +149,14 @@ class LocalFileTransferTest {
library {
assertGetZimNearbyDeviceDisplayed(composeTestRule)
clickFileTransferIcon(composeTestRule) {
assertClickNearbyDeviceMessageVisible()
clickOnGotItButton()
assertDeviceNameMessageVisible()
clickOnGotItButton()
assertNearbyDeviceListMessageVisible()
clickOnGotItButton()
assertTransferZimFilesListMessageVisible()
clickOnGotItButton()
assertClickNearbyDeviceMessageVisible(composeTestRule)
clickOnNextButton(composeTestRule)
assertDeviceNameMessageVisible(composeTestRule)
clickOnNextButton(composeTestRule)
assertNearbyDeviceListMessageVisible(composeTestRule)
clickOnNextButton(composeTestRule)
assertTransferZimFilesListMessageVisible(composeTestRule)
clickOnNextButton(composeTestRule)
pressBack()
assertGetZimNearbyDeviceDisplayed(composeTestRule)
}
@ -189,7 +166,7 @@ class LocalFileTransferTest {
@Test
fun testShowCaseFeatureShowOnce() {
shouldShowShowCaseFeatureToUser(true)
shouldShowShowCaseFeatureToUser(false)
activityScenario =
ActivityScenario.launch(KiwixMainActivity::class.java).apply {
moveToState(Lifecycle.State.RESUMED)
@ -201,15 +178,12 @@ class LocalFileTransferTest {
library {
// test show case view show once.
clickFileTransferIcon(composeTestRule) {
LocalFileTransferRobot::assertClickNearbyDeviceMessageNotVisible
assertClickNearbyDeviceMessageNotVisible(composeTestRule)
}
}
}
private fun shouldShowShowCaseFeatureToUser(
shouldShowShowCase: Boolean,
isResetShowCaseId: Boolean = false
) {
private fun shouldShowShowCaseFeatureToUser(shouldShowShowCase: Boolean) {
PreferenceManager.getDefaultSharedPreferences(context).edit {
putBoolean(SharedPreferenceUtil.PREF_SHOW_INTRO, false)
putBoolean(SharedPreferenceUtil.PREF_WIFI_ONLY, false)
@ -217,15 +191,5 @@ class LocalFileTransferTest {
putBoolean(SharedPreferenceUtil.PREF_SHOW_SHOWCASE, shouldShowShowCase)
putString(SharedPreferenceUtil.PREF_LANG, "en")
}
if (isResetShowCaseId) {
// To clear showCaseID to ensure the showcase view will show.
uk.co.deanwild.materialshowcaseview.PrefsManager.resetAll(context)
} else {
// set that Show Case is showed, because sometimes its change the
// order of test case on API level 33 and our test case fails.
val internal =
context.getSharedPreferences("material_showcaseview_prefs", Context.MODE_PRIVATE)
internal.edit().putInt("status_$SHOWCASE_ID", -1).apply()
}
}
}

View File

@ -41,7 +41,6 @@ import org.kiwix.kiwixmobile.core.utils.SharedPreferenceUtil
import org.kiwix.kiwixmobile.core.utils.TestingUtils.COMPOSE_TEST_RULE_ORDER
import org.kiwix.kiwixmobile.core.utils.TestingUtils.RETRY_RULE_ORDER
import org.kiwix.kiwixmobile.help.HelpRobot
import org.kiwix.kiwixmobile.localFileTransfer.LocalFileTransferRobot
import org.kiwix.kiwixmobile.nav.destination.library.OnlineLibraryRobot
import org.kiwix.kiwixmobile.settings.SettingsRobot
import org.kiwix.kiwixmobile.testutils.RetryRule
@ -115,7 +114,7 @@ class TopLevelDestinationTest : BaseActivityTest() {
clickLibraryOnBottomNav {
assertGetZimNearbyDeviceDisplayed(composeTestRule)
clickFileTransferIcon(composeTestRule) {
LocalFileTransferRobot::assertReceiveFileTitleVisible
assertReceiveFileTitleVisible(composeTestRule)
}
}
clickBookmarksOnNavDrawer {

View File

@ -39,7 +39,6 @@ import org.kiwix.kiwixmobile.core.utils.SharedPreferenceUtil
import org.kiwix.kiwixmobile.core.utils.TestingUtils.COMPOSE_TEST_RULE_ORDER
import org.kiwix.kiwixmobile.core.utils.TestingUtils.RETRY_RULE_ORDER
import org.kiwix.kiwixmobile.help.HelpRobot
import org.kiwix.kiwixmobile.localFileTransfer.LocalFileTransferRobot
import org.kiwix.kiwixmobile.main.ACTION_GET_CONTENT
import org.kiwix.kiwixmobile.main.KiwixMainActivity
import org.kiwix.kiwixmobile.main.topLevel
@ -119,7 +118,7 @@ class GetContentShortcutTest {
clickLibraryOnBottomNav {
assertGetZimNearbyDeviceDisplayed(composeTestRule)
clickFileTransferIcon(composeTestRule) {
LocalFileTransferRobot::assertReceiveFileTitleVisible
assertReceiveFileTitleVisible(composeTestRule)
}
}
clickBookmarksOnNavDrawer {

View File

@ -1,90 +0,0 @@
/*
* Kiwix Android
* Copyright (c) 2019 Kiwix <android.kiwix.org>
* 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/>.
*
*/
@file:Suppress("PackageNaming")
package org.kiwix.kiwixmobile.localFileTransfer
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView
import org.kiwix.kiwixmobile.R
import org.kiwix.kiwixmobile.core.base.adapter.BaseViewHolder
import org.kiwix.kiwixmobile.databinding.ItemTransferListBinding
import org.kiwix.kiwixmobile.localFileTransfer.FileItem.FileStatus.ERROR
import org.kiwix.kiwixmobile.localFileTransfer.FileItem.FileStatus.SENDING
import org.kiwix.kiwixmobile.localFileTransfer.FileItem.FileStatus.SENT
import org.kiwix.kiwixmobile.localFileTransfer.FileListAdapter.FileViewHolder
/**
* Helper class, part of the local file sharing module.
*
* Defines the Adapter for the list of file-items displayed in {TransferProgressFragment}
*/
class FileListAdapter(private val fileItems: List<FileItem>) :
RecyclerView.Adapter<FileViewHolder>() {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): FileViewHolder =
FileViewHolder(
ItemTransferListBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
),
this
)
override fun onBindViewHolder(holder: FileViewHolder, position: Int) {
holder.bind(fileItems[position])
}
override fun getItemCount(): Int = fileItems.size
inner class FileViewHolder(
private val itemTransferListBinding: ItemTransferListBinding,
val fileListAdapter: FileListAdapter
) :
BaseViewHolder<FileItem>(itemTransferListBinding.root) {
override fun bind(item: FileItem) {
itemTransferListBinding.textViewFileItemName.text = item.fileName
itemTransferListBinding.imageViewFileTransferred.isVisible = item.fileStatus != SENDING
itemTransferListBinding.progressBarTransferringFile.isVisible = item.fileStatus == SENDING
if (item.fileStatus != FileItem.FileStatus.TO_BE_SENT) {
// Icon for TO_BE_SENT is assigned by default in the item layout
itemTransferListBinding.progressBarTransferringFile.visibility = View.GONE
when (item.fileStatus) {
SENDING -> itemTransferListBinding.progressBarTransferringFile.visibility = View.VISIBLE
SENT -> {
itemTransferListBinding.imageViewFileTransferred.setImageResource(
R.drawable.ic_baseline_check_24px
)
itemTransferListBinding.progressBarTransferringFile.visibility = View.GONE
}
ERROR -> {
itemTransferListBinding.imageViewFileTransferred.setImageResource(
R.drawable.ic_baseline_error_24px
)
itemTransferListBinding.progressBarTransferringFile.visibility = View.GONE
}
else -> {
}
}
}
}
}
}

View File

@ -33,52 +33,40 @@ import android.net.wifi.p2p.WifiP2pDevice
import android.net.wifi.p2p.WifiP2pDeviceList
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.provider.Settings
import android.view.LayoutInflater
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.RequiresApi
import androidx.appcompat.widget.Toolbar
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Search
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.platform.ComposeView
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.core.view.MenuHost
import androidx.core.view.MenuProvider
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import org.kiwix.kiwixmobile.R
import org.kiwix.kiwixmobile.cachedComponent
import org.kiwix.kiwixmobile.core.R.dimen
import org.kiwix.kiwixmobile.core.R.drawable
import org.kiwix.kiwixmobile.core.R.string
import org.kiwix.kiwixmobile.core.base.BaseActivity
import org.kiwix.kiwixmobile.core.base.BaseFragment
import org.kiwix.kiwixmobile.core.extensions.ActivityExtensions.isLandScapeMode
import org.kiwix.kiwixmobile.core.extensions.ActivityExtensions.isTablet
import org.kiwix.kiwixmobile.core.extensions.ActivityExtensions.popNavigationBackstack
import org.kiwix.kiwixmobile.core.extensions.getToolbarNavigationIcon
import org.kiwix.kiwixmobile.core.extensions.setToolTipWithContentDescription
import org.kiwix.kiwixmobile.core.extensions.toast
import org.kiwix.kiwixmobile.core.main.CoreMainActivity
import org.kiwix.kiwixmobile.core.navigateToAppSettings
import org.kiwix.kiwixmobile.core.ui.components.NavigationIcon
import org.kiwix.kiwixmobile.core.ui.models.ActionMenuItem
import org.kiwix.kiwixmobile.core.ui.models.IconItem
import org.kiwix.kiwixmobile.core.ui.models.IconItem.Vector
import org.kiwix.kiwixmobile.core.utils.SharedPreferenceUtil
import org.kiwix.kiwixmobile.core.utils.dialog.AlertDialogShower
import org.kiwix.kiwixmobile.core.utils.dialog.KiwixDialog
import org.kiwix.kiwixmobile.core.utils.files.Log
import org.kiwix.kiwixmobile.databinding.FragmentLocalFileTransferBinding
import org.kiwix.kiwixmobile.core.page.SEARCH_ICON_TESTING_TAG
import org.kiwix.kiwixmobile.localFileTransfer.WifiDirectManager.Companion.getDeviceStatus
import org.kiwix.kiwixmobile.localFileTransfer.adapter.WifiP2pDelegate
import org.kiwix.kiwixmobile.localFileTransfer.adapter.WifiPeerListAdapter
import uk.co.deanwild.materialshowcaseview.MaterialShowcaseSequence
import uk.co.deanwild.materialshowcaseview.ShowcaseConfig
import javax.inject.Inject
/**
@ -96,7 +84,6 @@ import javax.inject.Inject
*/
const val URIS_KEY = "uris"
const val SHOWCASE_ID = "MaterialShowcaseId"
@SuppressLint("GoogleAppIndexingApiWarning", "Registered")
class LocalFileTransferFragment :
@ -114,147 +101,64 @@ class LocalFileTransferFragment :
@Inject
lateinit var sharedPreferenceUtil: SharedPreferenceUtil
private var fileListAdapter: FileListAdapter? = null
private var wifiPeerListAdapter: WifiPeerListAdapter? = null
private var fragmentLocalFileTransferBinding: FragmentLocalFileTransferBinding? = null
private var materialShowCaseSequence: MaterialShowcaseSequence? = null
private var searchIconView: View? = null
private val deviceName = mutableStateOf("")
private val isPeerSearching = mutableStateOf(false)
private val peerDeviceList = mutableStateOf(emptyList<WifiP2pDevice>())
private val transferFileList = mutableStateOf(emptyList<FileItem>())
private var composeView: ComposeView? = null
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
fragmentLocalFileTransferBinding =
FragmentLocalFileTransferBinding.inflate(inflater, container, false)
return fragmentLocalFileTransferBinding?.root
): View? = ComposeView(requireContext()).also {
composeView = it
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
setupMenu()
val activity = requireActivity() as CoreMainActivity
val filesForTransfer = getFilesForTransfer()
val isReceiver = filesForTransfer.isEmpty()
setupToolbar(view, activity, isReceiver)
wifiPeerListAdapter = WifiPeerListAdapter(WifiP2pDelegate(wifiDirectManager::sendToDevice))
setupPeerDevicesList(activity)
composeView?.setContent {
LocalFileTransferScreen(
deviceName = deviceName.value,
toolbarTitle = if (isReceiver) {
R.string.receive_files_title
} else {
R.string.send_files_title
},
isPeerSearching = isPeerSearching.value,
peerDeviceList = peerDeviceList.value,
transferFileList = transferFileList.value,
actionMenuItems = actionMenuItem(),
onDeviceItemClick = { wifiDirectManager.sendToDevice(it) },
sharedPreferenceUtil = sharedPreferenceUtil,
navigationIcon = {
NavigationIcon(
iconItem = IconItem.Drawable(drawable.ic_close_white_24dp),
onClick = { activity.popNavigationBackstack() }
)
}
)
}
displayFileTransferProgress(filesForTransfer)
wifiDirectManager.callbacks = this
wifiDirectManager.lifecycleCoroutineScope = lifecycleScope
wifiDirectManager.startWifiDirectManager(filesForTransfer)
fragmentLocalFileTransferBinding?.apply {
textViewDeviceName.setToolTipWithContentDescription(getString(string.your_device))
fileTransferShowCaseView.apply {
val fileTransferShowViewParams = layoutParams
fileTransferShowViewParams.width = getShowCaseViewWidth()
fileTransferShowViewParams.height = getShowCaseViewHeight()
layoutParams = fileTransferShowViewParams
}
nearbyDeviceShowCaseView.apply {
val nearbyDeviceShowCaseViewParams = layoutParams
nearbyDeviceShowCaseViewParams.width = getShowCaseViewWidth()
nearbyDeviceShowCaseViewParams.height = getShowCaseViewHeight()
layoutParams = nearbyDeviceShowCaseViewParams
}
}
}
private fun getShowCaseViewWidth(): Int {
return when {
requireActivity().isTablet() -> {
requireActivity().resources.getDimensionPixelSize(dimen.maximum_donation_popup_width)
}
requireActivity().isLandScapeMode() -> {
requireActivity().resources.getDimensionPixelSize(
dimen.showcase_view_maximum_width_in_landscape_mode
)
}
else -> FrameLayout.LayoutParams.MATCH_PARENT
}
}
private fun getShowCaseViewHeight(): Int =
requireActivity()
.resources
.getDimensionPixelSize(dimen.showcase_view_maximum_height)
private fun setupMenu() {
(requireActivity() as MenuHost).addMenuProvider(
object : MenuProvider {
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
menuInflater.inflate(R.menu.wifi_file_share_items, menu)
if (sharedPreferenceUtil.prefShowShowCaseToUser) {
Handler(Looper.getMainLooper()).post {
searchIconView =
fragmentLocalFileTransferBinding?.root?.findViewById(R.id.menu_item_search_devices)
showCaseFeatureToUsers()
}
}
}
override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
if (menuItem.itemId == R.id.menu_item_search_devices) {
// Permissions essential for this module
return onSearchMenuClicked()
}
return false
}
},
viewLifecycleOwner,
Lifecycle.State.RESUMED
private fun actionMenuItem() = listOf(
ActionMenuItem(
Vector(Icons.Default.Search),
string.search_label,
{ onSearchMenuClicked() },
testingTag = SEARCH_ICON_TESTING_TAG
)
}
private fun showCaseFeatureToUsers() {
searchIconView?.let {
materialShowCaseSequence =
MaterialShowcaseSequence(activity, SHOWCASE_ID).apply {
val config =
ShowcaseConfig().apply {
// half second between each showcase view
delay = 500
}
setConfig(config)
addSequenceItem(
it,
getString(string.click_nearby_devices_message),
getString(string.got_it)
)
addSequenceItem(
fragmentLocalFileTransferBinding?.textViewDeviceName,
getString(string.your_device_name_message),
getString(string.got_it)
)
addSequenceItem(
fragmentLocalFileTransferBinding?.nearbyDeviceShowCaseView,
getString(string.nearby_devices_list_message),
getString(string.got_it)
)
addSequenceItem(
fragmentLocalFileTransferBinding?.fileTransferShowCaseView,
getString(string.transfer_zim_files_list_message),
getString(string.got_it)
)
setOnItemDismissedListener { showcaseView, _ ->
// To fix the memory leak by setting setTarget to null
// because the memory leak occurred inside the library.
// They had forgotten to detach the view after its successful use,
// so it holds the reference of these views in memory.
// By setting these views as null we remove the reference from
// the memory after they are successfully shown.
showcaseView.setTarget(null)
}
start()
}
}
}
)
private fun onSearchMenuClicked(): Boolean =
when {
@ -281,53 +185,24 @@ class LocalFileTransferFragment :
}
}
private fun setupPeerDevicesList(activity: CoreMainActivity) {
fragmentLocalFileTransferBinding?.listPeerDevices?.apply {
adapter = wifiPeerListAdapter
layoutManager = LinearLayoutManager(activity)
setHasFixedSize(true)
}
}
private fun setupToolbar(view: View, activity: CoreMainActivity, isReceiver: Boolean) {
val toolbar: Toolbar = view.findViewById(R.id.toolbar)
toolbar.apply {
activity.setSupportActionBar(this)
title =
if (isReceiver) {
getString(R.string.receive_files_title)
} else {
getString(R.string.send_files_title)
}
setNavigationIcon(drawable.ic_close_white_24dp)
// set the contentDescription to navigation back button
getToolbarNavigationIcon()?.setToolTipWithContentDescription(
getString(string.toolbar_back_button_content_description)
)
setNavigationOnClickListener { activity.popNavigationBackstack() }
}
}
private fun getFilesForTransfer() =
LocalFileTransferFragmentArgs.fromBundle(requireArguments()).uris?.map(::FileItem).orEmpty()
private fun showPeerDiscoveryProgressBar() { // Setup UI for searching peers
fragmentLocalFileTransferBinding?.progressBarSearchingPeers?.visibility = View.VISIBLE
fragmentLocalFileTransferBinding?.listPeerDevices?.visibility = View.INVISIBLE
fragmentLocalFileTransferBinding?.textViewEmptyPeerList?.visibility = View.INVISIBLE
isPeerSearching.value = true
}
// From WifiDirectManager.Callbacks interface
override fun onUserDeviceDetailsAvailable(userDevice: WifiP2pDevice?) {
// Update UI with user device's details
if (userDevice != null) {
fragmentLocalFileTransferBinding?.textViewDeviceName?.text = userDevice.deviceName
deviceName.value = userDevice.deviceName
Log.d(TAG, getDeviceStatus(userDevice.status))
}
}
override fun onConnectionToPeersLost() {
wifiPeerListAdapter?.items = emptyList()
peerDeviceList.value = emptyList()
}
override fun onFilesForTransferAvailable(filesForTransfer: List<FileItem>) {
@ -335,23 +210,20 @@ class LocalFileTransferFragment :
}
private fun displayFileTransferProgress(filesToSend: List<FileItem>) {
fileListAdapter = FileListAdapter(filesToSend)
fragmentLocalFileTransferBinding?.recyclerViewTransferFiles?.apply {
adapter = fileListAdapter
layoutManager =
LinearLayoutManager(requireActivity())
}
transferFileList.value = filesToSend
}
override fun onFileStatusChanged(itemIndex: Int) {
fileListAdapter?.notifyItemChanged(itemIndex)
override fun onFileStatusChanged(itemIndex: Int, fileStatus: FileItem.FileStatus) {
val tempTransferList = transferFileList.value
tempTransferList[itemIndex].fileStatus = fileStatus
transferFileList.value = emptyList()
transferFileList.value = tempTransferList
}
override fun updateListOfAvailablePeers(peers: WifiP2pDeviceList) {
val deviceList: List<WifiP2pDevice> = ArrayList<WifiP2pDevice>(peers.deviceList)
fragmentLocalFileTransferBinding?.progressBarSearchingPeers?.visibility = View.GONE
fragmentLocalFileTransferBinding?.listPeerDevices?.visibility = View.VISIBLE
wifiPeerListAdapter?.items = deviceList
isPeerSearching.value = false
peerDeviceList.value = deviceList
if (deviceList.isEmpty()) {
Log.d(TAG, "No devices found")
}
@ -533,10 +405,6 @@ class LocalFileTransferFragment :
override fun onDestroyView() {
wifiDirectManager.stopWifiDirectManager()
wifiDirectManager.callbacks = null
fragmentLocalFileTransferBinding?.root?.removeAllViews()
fragmentLocalFileTransferBinding = null
searchIconView = null
materialShowCaseSequence = null
super.onDestroyView()
}

View File

@ -0,0 +1,371 @@
/*
* Kiwix Android
* Copyright (c) 2025 Kiwix <android.kiwix.org>
* 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 org.kiwix.kiwixmobile.localFileTransfer
import android.content.Context
import android.net.wifi.p2p.WifiP2pDevice
import androidx.annotation.StringRes
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.minimumInteractiveComponentSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.snapshots.SnapshotStateMap
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import org.kiwix.kiwixmobile.R.drawable
import org.kiwix.kiwixmobile.core.R
import org.kiwix.kiwixmobile.core.R.string
import org.kiwix.kiwixmobile.core.downloader.downloadManager.ZERO
import org.kiwix.kiwixmobile.core.ui.components.ContentLoadingProgressBar
import org.kiwix.kiwixmobile.core.ui.components.KiwixAppBar
import org.kiwix.kiwixmobile.core.ui.components.KiwixShowCaseView
import org.kiwix.kiwixmobile.core.ui.components.ShowcaseProperty
import org.kiwix.kiwixmobile.core.ui.models.ActionMenuItem
import org.kiwix.kiwixmobile.core.ui.theme.DodgerBlue
import org.kiwix.kiwixmobile.core.ui.theme.KiwixTheme
import org.kiwix.kiwixmobile.core.utils.ComposeDimens.DEFAULT_TEXT_ALPHA
import org.kiwix.kiwixmobile.core.utils.ComposeDimens.FIFTEEN_DP
import org.kiwix.kiwixmobile.core.utils.ComposeDimens.FILE_FOR_TRANSFER_SHOW_CASE_VIEW_SIZE
import org.kiwix.kiwixmobile.core.utils.ComposeDimens.FILE_FOR_TRANSFER_TEXT_SIZE
import org.kiwix.kiwixmobile.core.utils.ComposeDimens.FILE_ITEM_ICON_SIZE
import org.kiwix.kiwixmobile.core.utils.ComposeDimens.FILE_ITEM_TEXT_SIZE
import org.kiwix.kiwixmobile.core.utils.ComposeDimens.FIVE_DP
import org.kiwix.kiwixmobile.core.utils.ComposeDimens.NEARBY_DEVICES_SHOW_CASE_VIEW_SIZE
import org.kiwix.kiwixmobile.core.utils.ComposeDimens.NEARBY_DEVICES_TEXT_SIZE
import org.kiwix.kiwixmobile.core.utils.ComposeDimens.NEARBY_DEVICE_LIST_HEIGHT
import org.kiwix.kiwixmobile.core.utils.ComposeDimens.NO_DEVICE_FOUND_TEXT_PADDING
import org.kiwix.kiwixmobile.core.utils.ComposeDimens.ONE_DP
import org.kiwix.kiwixmobile.core.utils.ComposeDimens.PEER_DEVICE_ITEM_TEXT_SIZE
import org.kiwix.kiwixmobile.core.utils.ComposeDimens.TEN_DP
import org.kiwix.kiwixmobile.core.utils.ComposeDimens.YOUR_DEVICE_TEXT_SIZE
import org.kiwix.kiwixmobile.core.utils.SharedPreferenceUtil
import org.kiwix.kiwixmobile.core.page.SEARCH_ICON_TESTING_TAG
import org.kiwix.kiwixmobile.localFileTransfer.FileItem.FileStatus.ERROR
import org.kiwix.kiwixmobile.localFileTransfer.FileItem.FileStatus.SENDING
import org.kiwix.kiwixmobile.localFileTransfer.FileItem.FileStatus.SENT
import org.kiwix.kiwixmobile.localFileTransfer.FileItem.FileStatus.TO_BE_SENT
const val YOUR_DEVICE_SHOW_CASE_TAG = "yourDeviceShowCaseTag"
const val PEER_DEVICE_LIST_SHOW_CASE_TAG = "peerDeviceListShowCaseTag"
const val FILE_FOR_TRANSFER_SHOW_CASE_TAG = "fileForTransferShowCaseTag"
@OptIn(ExperimentalMaterial3Api::class)
@Suppress("ComposableLambdaParameterNaming", "LongParameterList")
@Composable
fun LocalFileTransferScreen(
deviceName: String,
@StringRes toolbarTitle: Int,
isPeerSearching: Boolean,
peerDeviceList: List<WifiP2pDevice>,
transferFileList: List<FileItem>,
actionMenuItems: List<ActionMenuItem>,
onDeviceItemClick: (WifiP2pDevice) -> Unit,
sharedPreferenceUtil: SharedPreferenceUtil,
navigationIcon: @Composable () -> Unit
) {
val targets = remember { mutableStateMapOf<String, ShowcaseProperty>() }
val context = LocalContext.current
KiwixTheme {
Scaffold(
topBar = {
KiwixAppBar(
titleId = toolbarTitle,
actionMenuItems = actionMenuItems.map {
it.copy(
modifier =
Modifier.onGloballyPositioned { coordinates ->
targets[SEARCH_ICON_TESTING_TAG] = ShowcaseProperty(
index = ZERO,
coordinates = coordinates,
showCaseMessage = context.getString(string.click_nearby_devices_message)
)
}
)
},
navigationIcon = navigationIcon
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.background(Color.Transparent)
) {
YourDeviceHeader(deviceName, context, targets)
HorizontalDivider(
color = DodgerBlue,
thickness = ONE_DP,
modifier = Modifier.padding(horizontal = FIVE_DP)
)
NearbyDevicesSection(peerDeviceList, isPeerSearching, onDeviceItemClick, context, targets)
HorizontalDivider(
color = DodgerBlue,
thickness = ONE_DP,
modifier = Modifier
.padding(horizontal = FIVE_DP)
)
TransferFilesSection(transferFileList, context, targets)
}
}
ShowShowCaseToUserIfNotShown(targets, sharedPreferenceUtil)
}
}
@Composable
fun ShowShowCaseToUserIfNotShown(
targets: SnapshotStateMap<String, ShowcaseProperty>,
sharedPreferenceUtil: SharedPreferenceUtil
) {
if (sharedPreferenceUtil.prefShowShowCaseToUser) {
KiwixShowCaseView(targets = targets) {
sharedPreferenceUtil.showCaseViewForFileTransferShown()
}
}
}
@Composable
fun NearbyDevicesSection(
peerDeviceList: List<WifiP2pDevice>,
isPeerSearching: Boolean,
onDeviceItemClick: (WifiP2pDevice) -> Unit,
context: Context,
targets: SnapshotStateMap<String, ShowcaseProperty>
) {
Column(
modifier = Modifier
.fillMaxWidth()
.defaultMinSize(minHeight = NEARBY_DEVICE_LIST_HEIGHT)
) {
Text(
text = stringResource(R.string.nearby_devices),
fontSize = NEARBY_DEVICES_TEXT_SIZE,
fontFamily = FontFamily.Monospace,
modifier = Modifier
.fillMaxWidth()
.padding(top = FIVE_DP)
.align(Alignment.CenterHorizontally),
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = DEFAULT_TEXT_ALPHA)
)
when {
isPeerSearching -> ContentLoadingProgressBar(
modifier = Modifier
.padding(NO_DEVICE_FOUND_TEXT_PADDING)
.align(Alignment.CenterHorizontally)
)
peerDeviceList.isEmpty() -> Text(
text = stringResource(R.string.no_devices_found),
modifier = Modifier
.padding(NO_DEVICE_FOUND_TEXT_PADDING)
.align(Alignment.CenterHorizontally)
.onGloballyPositioned { coordinates ->
targets[PEER_DEVICE_LIST_SHOW_CASE_TAG] = ShowcaseProperty(
index = 2,
coordinates = coordinates,
showCaseMessage = context.getString(string.nearby_devices_list_message),
customSizeForShowcaseViewCircle = NEARBY_DEVICES_SHOW_CASE_VIEW_SIZE
)
},
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = DEFAULT_TEXT_ALPHA)
)
else -> LazyColumn(
modifier = Modifier
.fillMaxWidth()
.defaultMinSize(minHeight = NEARBY_DEVICE_LIST_HEIGHT)
) {
items(peerDeviceList) { device ->
PeerDeviceItem(device, onDeviceItemClick)
}
}
}
}
}
@Composable
private fun TransferFilesSection(
transferFileList: List<FileItem>,
context: Context,
targets: SnapshotStateMap<String, ShowcaseProperty>
) {
Column(modifier = Modifier.fillMaxWidth()) {
Text(
text = stringResource(R.string.files_for_transfer),
fontSize = FILE_FOR_TRANSFER_TEXT_SIZE,
fontFamily = FontFamily.Monospace,
modifier = Modifier
.fillMaxWidth()
.padding(top = TEN_DP)
.onGloballyPositioned { coordinates ->
targets[FILE_FOR_TRANSFER_SHOW_CASE_TAG] = ShowcaseProperty(
index = 3,
coordinates = coordinates,
showCaseMessage = context.getString(string.transfer_zim_files_list_message),
customSizeForShowcaseViewCircle = FILE_FOR_TRANSFER_SHOW_CASE_VIEW_SIZE
)
},
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = DEFAULT_TEXT_ALPHA)
)
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(transferFileList) { file ->
TransferFileItem(file)
}
}
}
}
@Composable
private fun YourDeviceHeader(
deviceName: String,
context: Context,
targets: SnapshotStateMap<String, ShowcaseProperty>
) {
Column(modifier = Modifier.padding(horizontal = FIFTEEN_DP, vertical = FIVE_DP)) {
Text(
text = stringResource(R.string.your_device),
fontStyle = FontStyle.Italic,
fontSize = YOUR_DEVICE_TEXT_SIZE,
modifier = Modifier
.padding(top = FIVE_DP, bottom = ONE_DP)
.onGloballyPositioned { coordinates ->
targets[YOUR_DEVICE_SHOW_CASE_TAG] = ShowcaseProperty(
index = 1,
coordinates = coordinates,
showCaseMessage = context.getString(string.your_device_name_message)
)
},
color = MaterialTheme.colorScheme.onSurface.copy(alpha = DEFAULT_TEXT_ALPHA)
)
val contentDescription = stringResource(R.string.device_name)
Text(
text = deviceName,
fontWeight = FontWeight.Bold,
fontSize = PEER_DEVICE_ITEM_TEXT_SIZE,
modifier = Modifier
.minimumInteractiveComponentSize()
.semantics { this.contentDescription = contentDescription },
color = MaterialTheme.colorScheme.onSurface.copy(alpha = DEFAULT_TEXT_ALPHA)
)
}
}
@Composable
fun TransferFileItem(
fileItem: FileItem
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(TEN_DP),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = fileItem.fileName,
fontSize = FILE_ITEM_TEXT_SIZE,
modifier = Modifier
.weight(1f)
.padding(horizontal = FIVE_DP, vertical = ONE_DP),
color = MaterialTheme.colorScheme.onSurface.copy(alpha = DEFAULT_TEXT_ALPHA)
)
val modifier = Modifier
.size(FILE_ITEM_ICON_SIZE)
.padding(horizontal = FIVE_DP, vertical = ONE_DP)
when (fileItem.fileStatus) {
SENDING -> ContentLoadingProgressBar(modifier)
TO_BE_SENT,
SENT,
ERROR -> {
val iconRes = when (fileItem.fileStatus) {
FileItem.FileStatus.TO_BE_SENT -> drawable.ic_baseline_wait_24px
FileItem.FileStatus.SENT -> drawable.ic_baseline_check_24px
FileItem.FileStatus.ERROR -> drawable.ic_baseline_error_24px
else -> error("Unhandled status: ${fileItem.fileStatus}")
}
Icon(
painter = painterResource(iconRes),
contentDescription = stringResource(R.string.status),
modifier = modifier
)
}
}
}
}
@Suppress("MagicNumber")
@Composable
fun PeerDeviceItem(
wifiP2PDevice: WifiP2pDevice,
onDeviceItemClick: (WifiP2pDevice) -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(TEN_DP)
.clickable(onClick = { onDeviceItemClick.invoke(wifiP2PDevice) }),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = wifiP2PDevice.deviceName,
fontSize = PEER_DEVICE_ITEM_TEXT_SIZE,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
modifier = Modifier
.weight(3f)
.padding(horizontal = FIVE_DP, vertical = ONE_DP),
color = MaterialTheme.colorScheme.onSurface.copy(alpha = DEFAULT_TEXT_ALPHA)
)
}
}

View File

@ -281,7 +281,7 @@ class WifiDirectManager @Inject constructor(
fun changeStatus(itemIndex: Int, status: FileStatus) {
filesForTransfer[itemIndex].fileStatus = status
callbacks?.onFileStatusChanged(itemIndex)
callbacks?.onFileStatusChanged(itemIndex, status)
if (status == FileStatus.ERROR) {
context.toast(
context.getString(R.string.error_transferring, filesForTransfer[itemIndex].fileName)
@ -344,7 +344,7 @@ class WifiDirectManager @Inject constructor(
fun onConnectionToPeersLost()
fun updateListOfAvailablePeers(peers: WifiP2pDeviceList)
fun onFilesForTransferAvailable(filesForTransfer: List<FileItem>)
fun onFileStatusChanged(itemIndex: Int)
fun onFileStatusChanged(itemIndex: Int, fileStatus: FileStatus)
fun onFileTransferComplete()
}

View File

@ -1,41 +0,0 @@
/*
* Kiwix Android
* Copyright (c) 2020 Kiwix <android.kiwix.org>
* 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 org.kiwix.kiwixmobile.localFileTransfer.adapter
import android.net.wifi.p2p.WifiP2pDevice
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView.ViewHolder
import org.kiwix.kiwixmobile.core.base.adapter.AdapterDelegate
import org.kiwix.kiwixmobile.core.extensions.ViewGroupExtensions.viewBinding
import org.kiwix.kiwixmobile.databinding.RowPeerDeviceBinding
class WifiP2pDelegate(private val onItemClickAction: (WifiP2pDevice) -> Unit) :
AdapterDelegate<WifiP2pDevice> {
override fun createViewHolder(parent: ViewGroup): ViewHolder =
WifiP2pViewHolder(
parent.viewBinding(RowPeerDeviceBinding::inflate, false),
onItemClickAction
)
override fun bind(viewHolder: ViewHolder, itemToBind: WifiP2pDevice) {
(viewHolder as WifiP2pViewHolder).bind(itemToBind)
}
override fun isFor(item: WifiP2pDevice) = true
}

View File

@ -1,35 +0,0 @@
/*
* Kiwix Android
* Copyright (c) 2020 Kiwix <android.kiwix.org>
* 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 org.kiwix.kiwixmobile.localFileTransfer.adapter
import android.net.wifi.p2p.WifiP2pDevice
import org.kiwix.kiwixmobile.core.base.adapter.BaseViewHolder
import org.kiwix.kiwixmobile.databinding.RowPeerDeviceBinding
class WifiP2pViewHolder(
private val rowPeerDeviceBinding: RowPeerDeviceBinding,
private val onItemClickAction: (WifiP2pDevice) -> Unit
) : BaseViewHolder<WifiP2pDevice>(rowPeerDeviceBinding.root) {
override fun bind(item: WifiP2pDevice) {
rowPeerDeviceBinding.rowDeviceName.text = item.deviceName
containerView.setOnClickListener {
onItemClickAction.invoke(item)
}
}
}

View File

@ -1,26 +0,0 @@
/*
* Kiwix Android
* Copyright (c) 2020 Kiwix <android.kiwix.org>
* 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 org.kiwix.kiwixmobile.localFileTransfer.adapter
import android.net.wifi.p2p.WifiP2pDevice
import org.kiwix.kiwixmobile.core.base.adapter.BaseDelegateAdapter
internal class WifiPeerListAdapter(wifiP2pDelegate: WifiP2pDelegate) :
BaseDelegateAdapter<WifiP2pDevice>(wifiP2pDelegate) {
override fun getIdFor(item: WifiP2pDevice) = item.deviceAddress.hashCode().toLong()
}

View File

@ -184,7 +184,8 @@ class OnlineLibraryFragment : BaseFragment(), FragmentActivityExtensions {
): View? {
fragmentDestinationDownloadBinding =
FragmentDestinationDownloadBinding.inflate(inflater, container, false)
val toolbar = fragmentDestinationDownloadBinding?.root?.findViewById<Toolbar>(R.id.toolbar)
val toolbar =
fragmentDestinationDownloadBinding?.root?.findViewById<Toolbar>(org.kiwix.kiwixmobile.core.R.id.toolbar)
val activity = activity as CoreMainActivity
activity.setSupportActionBar(toolbar)
activity.supportActionBar?.apply {

View File

@ -1,172 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@android:color/transparent"
tools:context="org.kiwix.kiwixmobile.localFileTransfer.LocalFileTransferFragment">
<include layout="@layout/layout_toolbar" />
<TextView
android:id="@+id/text_view_your_device"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="15dp"
android:background="@android:color/transparent"
android:paddingStart="5dp"
android:paddingTop="5dp"
android:paddingEnd="5dp"
android:paddingBottom="1dp"
android:text="@string/your_device"
android:textSize="13sp"
android:textStyle="italic"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/toolbar" />
<TextView
android:id="@+id/text_view_device_name"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="15dp"
android:background="@android:color/transparent"
android:contentDescription="@string/device_name"
android:gravity="start|center"
android:minHeight="@dimen/material_minimum_height_and_width"
android:paddingStart="5dp"
android:paddingEnd="5dp"
android:paddingBottom="5dp"
android:textIsSelectable="true"
android:textSize="17sp"
android:textStyle="bold"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/text_view_your_device"
tools:hint="@string/device_name" />
<View
android:id="@+id/view_device_list_boundary"
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginStart="5dp"
android:layout_marginEnd="5dp"
android:background="@color/dodger_blue"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/text_view_device_name" />
<TextView
android:id="@+id/text_view_available_device"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@android:color/transparent"
android:gravity="center"
android:paddingTop="5dp"
android:text="@string/nearby_devices"
android:textSize="16sp"
app:fontFamily="monospace"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/view_device_list_boundary" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/list_peer_devices"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@android:color/transparent"
android:clipToPadding="false"
android:visibility="invisible"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/text_view_available_device"
app:layout_constraintVertical_bias="0.0" />
<TextView
android:id="@+id/text_view_empty_peer_list"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="50dp"
android:background="@android:color/transparent"
android:gravity="center"
android:text="@string/no_devices_found"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/text_view_available_device" />
<View
android:id="@+id/nearby_device_show_case_view"
android:layout_width="10dp"
android:layout_height="10dp"
android:layout_margin="50dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/text_view_available_device" />
<ProgressBar
android:id="@+id/progress_bar_searching_peers"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="50dp"
android:background="@android:color/transparent"
android:indeterminate="true"
android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/text_view_available_device" />
<View
android:id="@+id/view_file_list_boundary"
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginStart="5dp"
android:layout_marginTop="201dp"
android:layout_marginEnd="5dp"
android:background="@color/dodger_blue"
app:layout_constraintBottom_toTopOf="@+id/text_view_files_for_transfer"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/text_view_available_device" />
<TextView
android:id="@+id/text_view_files_for_transfer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@android:color/transparent"
android:gravity="center"
android:paddingTop="10dp"
android:text="@string/files_for_transfer"
android:textSize="16sp"
app:fontFamily="monospace"
app:layout_constraintBottom_toTopOf="@id/recycler_view_transfer_files"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/view_file_list_boundary" />
<View
android:id="@+id/file_transfer_show_case_view"
android:layout_width="10dp"
android:layout_height="10dp"
android:layout_margin="50dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/text_view_files_for_transfer" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recycler_view_transfer_files"
android:layout_width="match_parent"
android:layout_height="0dp"
android:background="@android:color/transparent"
android:clipToPadding="false"
android:contentDescription="@string/files_for_transfer"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/text_view_files_for_transfer"
tools:listitem="@layout/item_transfer_list" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -1,66 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:minHeight="64dp">
<CheckBox
android:id="@+id/item_language_checkbox"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/activity_horizontal_margin"
android:layout_marginEnd="@dimen/activity_horizontal_margin"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/item_language_name"
style="@style/list_item_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/activity_horizontal_margin"
android:layout_marginEnd="@dimen/activity_horizontal_margin"
android:alpha="0.90"
app:layout_constraintBottom_toTopOf="@id/item_language_localized_name"
app:layout_constraintStart_toEndOf="@id/item_language_checkbox"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_chainStyle="packed"
tools:text="English" />
<TextView
android:id="@+id/item_language_localized_name"
style="@style/list_item_body"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:alpha="0.63"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="@id/item_language_name"
app:layout_constraintTop_toBottomOf="@id/item_language_name"
tools:text="English" />
<TextView
android:id="@+id/item_language_books_count"
style="@style/list_item_body"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/activity_horizontal_margin"
android:layout_marginEnd="@dimen/activity_horizontal_margin"
android:alpha="0.63"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="9 books" />
<View
android:id="@+id/item_language_clickable_area"
android:layout_width="0dp"
android:layout_height="0dp"
android:background="?selectableItemBackground"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -1,61 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="10dp">
<TextView
android:id="@+id/text_view_file_item_name"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@android:color/transparent"
android:gravity="center_vertical"
android:paddingStart="5dp"
android:paddingTop="1dp"
android:paddingEnd="5dp"
android:paddingBottom="1dp"
android:textIsSelectable="true"
android:textSize="14sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:hint="File name" />
<ProgressBar
android:id="@+id/progress_bar_transferring_file"
android:layout_width="wrap_content"
android:layout_height="0dp"
android:background="@android:color/transparent"
android:paddingStart="5dp"
android:paddingTop="1dp"
android:paddingEnd="5dp"
android:paddingBottom="1dp"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="1.0"
app:layout_constraintStart_toEndOf="@id/text_view_file_item_name"
app:layout_constraintTop_toTopOf="parent" />
<ImageView
android:id="@+id/image_view_file_transferred"
android:layout_width="wrap_content"
android:layout_height="0dp"
android:layout_marginEnd="12dp"
android:background="@android:color/transparent"
android:contentDescription="@string/status"
android:paddingStart="5dp"
android:paddingTop="1dp"
android:paddingEnd="5dp"
android:paddingBottom="1dp"
android:src="@drawable/ic_baseline_wait_24px"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="1.0"
app:layout_constraintStart_toEndOf="@id/text_view_file_item_name"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -1,25 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:padding="10dp">
<TextView
android:id="@+id/row_device_name"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="3"
android:background="@android:color/transparent"
android:gravity="center_horizontal"
android:paddingStart="5dp"
android:paddingTop="1dp"
android:paddingEnd="5dp"
android:paddingBottom="1dp"
android:textIsSelectable="false"
android:textSize="17sp"
android:textStyle="bold"
tools:hint="Device Name" />
</LinearLayout>

View File

@ -337,12 +337,6 @@ object Libs {
*/
const val junit: String = "androidx.test.ext:junit:" + Versions.junit
/**
* https://github.com/deano2390/MaterialShowcaseView
*/
const val material_show_case_view: String =
"com.github.deano2390:MaterialShowcaseView:" + Versions.material_show_case_view
const val roomKtx = "androidx.room:room-ktx:" + Versions.roomVersion
const val roomCompiler = "androidx.room:room-compiler:" + Versions.roomVersion

View File

@ -106,8 +106,6 @@ object Versions {
const val junit: String = "1.1.5"
const val material_show_case_view: String = "1.3.7"
const val roomVersion = "2.5.2"
const val zxing = "3.5.3"

View File

@ -231,7 +231,6 @@ class AllProjectConfigurer {
implementation(Libs.rxandroid)
implementation(Libs.rxjava)
implementation(Libs.preference_ktx)
implementation(Libs.material_show_case_view)
implementation(Libs.roomKtx)
annotationProcessor(Libs.roomCompiler)
implementation(Libs.roomRuntime)

View File

@ -53,7 +53,7 @@ import org.kiwix.kiwixmobile.core.ui.models.IconItem
import org.kiwix.kiwixmobile.core.utils.SharedPreferenceUtil
import javax.inject.Inject
const val SEARCH_ICON_TESTING_TAG = "search"
const val SEARCH_ICON_TESTING_TAG = "searchIconTestingTag"
const val DELETE_MENU_ICON_TESTING_TAG = "deleteMenuIconTestingTag"
abstract class PageFragment : OnItemClickListener, BaseFragment(), FragmentActivityExtensions {

View File

@ -138,7 +138,7 @@ private fun ActionMenu(actionMenuItems: List<ActionMenuItem>) {
IconButton(
enabled = menuItem.isEnabled,
onClick = menuItem.onClick,
modifier = Modifier.testTag(menuItem.testingTag)
modifier = menuItem.modifier.testTag(menuItem.testingTag)
) {
Icon(
painter = menuItem.icon.toPainter(),

View File

@ -0,0 +1,296 @@
/*
* Kiwix Android
* Copyright (c) 2025 Kiwix <android.kiwix.org>
* 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 org.kiwix.kiwixmobile.core.ui.components
import android.annotation.SuppressLint
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.FastOutLinearInEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshots.SnapshotStateMap
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shadow
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.LayoutCoordinates
import androidx.compose.ui.layout.boundsInRoot
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTag
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import org.kiwix.kiwixmobile.core.R
import org.kiwix.kiwixmobile.core.downloader.downloadManager.ZERO
import org.kiwix.kiwixmobile.core.ui.theme.DodgerBlue
import org.kiwix.kiwixmobile.core.ui.theme.White
import org.kiwix.kiwixmobile.core.utils.ComposeDimens.PULSE_ALPHA
import org.kiwix.kiwixmobile.core.utils.ComposeDimens.PULSE_ANIMATION_END
import org.kiwix.kiwixmobile.core.utils.ComposeDimens.PULSE_ANIMATION_START
import org.kiwix.kiwixmobile.core.utils.ComposeDimens.PULSE_RADIUS_EXTRA
import org.kiwix.kiwixmobile.core.utils.ComposeDimens.SHOWCASE_MESSAGE_SHADOW_BLUR_RADIUS
import org.kiwix.kiwixmobile.core.utils.ComposeDimens.SHOWCASE_MESSAGE_SHADOW_COLOR_ALPHA
import org.kiwix.kiwixmobile.core.utils.ComposeDimens.SHOWCASE_VIEW_BACKGROUND_COLOR_ALPHA
import org.kiwix.kiwixmobile.core.utils.ComposeDimens.SHOWCASE_VIEW_MESSAGE_TEXT_SIZE
import org.kiwix.kiwixmobile.core.utils.ComposeDimens.SHOWCASE_VIEW_NEXT_BUTTON_TEXT_SIZE
import org.kiwix.kiwixmobile.core.utils.ComposeDimens.SIXTEEN_DP
import kotlin.math.max
import kotlin.math.roundToInt
const val SHOWCASE_VIEW_ROUND_ANIMATION_DURATION = 2000
const val ONE = 1
const val TWO = 1
const val SIXTEEN = 16
const val SHOWCASE_VIEW_NEXT_BUTTON_TESTING_TAG = "showcaseViewNextButtonTestingTag"
const val SHOWCASE_VIEW_MESSAGE_TESTING_TAG = "showCaseViewMessageTestingTag"
@Composable
fun KiwixShowCaseView(
targets: SnapshotStateMap<String, ShowcaseProperty>,
onShowCaseCompleted: () -> Unit
) {
val orderedTargets = targets.values.sortedBy { it.index }
var currentIndex by remember { mutableStateOf(ZERO) }
val currentTarget = orderedTargets.getOrNull(currentIndex)
currentTarget?.let {
AnimatedShowCase(target = it) {
currentIndex++
if (currentIndex >= orderedTargets.size) onShowCaseCompleted()
}
}
}
@Composable
private fun AnimatedShowCase(
target: ShowcaseProperty,
onShowCaseCompleted: () -> Unit
) {
val targetRect = target.coordinates.boundsInRoot()
val innerAnimation = remember { Animatable(PULSE_ANIMATION_START) }
val density = LocalDensity.current
val (width, height) = with(density) {
val size = target.customSizeForShowcaseViewCircle?.toPx()
Pair(size ?: targetRect.width, size ?: targetRect.height)
}
val radiusBase = max(width, height) / TWO.toFloat()
val pulseRadius by innerAnimation.asState()
LaunchedEffect(Unit) {
innerAnimation.animateTo(
targetValue = PULSE_ANIMATION_END,
animationSpec = infiniteRepeatable(
animation = tween(SHOWCASE_VIEW_ROUND_ANIMATION_DURATION, easing = FastOutLinearInEasing),
repeatMode = RepeatMode.Restart
)
)
}
Canvas(
modifier = Modifier
.fillMaxSize()
.pointerInput(target) {
detectTapGestures {
if (targetRect.contains(it)) onShowCaseCompleted()
}
}
.graphicsLayer(alpha = PULSE_ALPHA)
) {
drawOverlay(targetRect, radiusBase, pulseRadius)
}
ShowCaseMessage(target, targetRect, radiusBase)
NextButton(onShowCaseCompleted)
}
/**
* Draws the overlay and animated spotlight.
*/
private fun DrawScope.drawOverlay(
targetRect: Rect,
baseRadius: Float,
animatedFraction: Float
) {
drawRect(color = DodgerBlue.copy(alpha = SHOWCASE_VIEW_BACKGROUND_COLOR_ALPHA), size = size)
drawCircle(
color = Color.White,
radius = baseRadius * (ONE + animatedFraction),
center = targetRect.center,
alpha = ONE - animatedFraction
)
drawCircle(
color = Color.White,
radius = baseRadius + PULSE_RADIUS_EXTRA,
center = targetRect.center,
blendMode = BlendMode.Clear
)
}
@SuppressLint("UnusedBoxWithConstraintsScope")
@Composable
private fun ShowCaseMessage(
target: ShowcaseProperty,
targetRect: Rect,
targetRadius: Float
) {
val density = LocalDensity.current
var offset by remember { mutableStateOf(Offset.Zero) }
var calculated by remember { mutableStateOf(false) }
BoxWithConstraints(Modifier.fillMaxSize()) {
val screenWidth = with(density) { maxWidth.toPx() }
val screenHeight = with(density) { maxHeight.toPx() }
if (calculated) {
Box(
modifier = Modifier.offset { IntOffset(offset.x.roundToInt(), offset.y.roundToInt()) }
) {
Text(
text = target.showCaseMessage,
color = target.showCaseMessageColor,
style = TextStyle(
fontSize = SHOWCASE_VIEW_MESSAGE_TEXT_SIZE,
shadow = Shadow(
Color.Black.copy(alpha = SHOWCASE_MESSAGE_SHADOW_COLOR_ALPHA),
defaultBlurOffsetForMessageAndNextButton(),
blurRadius = SHOWCASE_MESSAGE_SHADOW_BLUR_RADIUS
)
),
modifier = Modifier.semantics { testTag = SHOWCASE_VIEW_MESSAGE_TESTING_TAG }
)
}
}
Text(
text = target.showCaseMessage,
modifier = Modifier
.alpha(PULSE_ANIMATION_START)
.onGloballyPositioned {
val size = it.size
val width = size.width.toFloat()
val height = size.height.toFloat()
val center = targetRect.center
val posY = when {
screenHeight - (center.y + targetRadius) > height + SIXTEEN -> center.y + targetRadius + SIXTEEN
center.y - targetRadius > height + SIXTEEN -> center.y - targetRadius - height - SIXTEEN
else -> screenHeight / TWO - height / TWO
}
val posX = when {
screenWidth - targetRect.right > width + SIXTEEN -> targetRect.right + SIXTEEN
targetRect.left > width + SIXTEEN -> targetRect.left - width - SIXTEEN
else -> screenWidth / TWO - width / TWO
}
offset = Offset(posX, posY)
calculated = true
}
)
}
}
private fun defaultBlurOffsetForMessageAndNextButton() =
Offset(SHOWCASE_MESSAGE_SHADOW_COLOR_ALPHA, SHOWCASE_MESSAGE_SHADOW_COLOR_ALPHA)
/**
* Composable for the "Next" button in the showcase.
*/
@Composable
private fun NextButton(onClick: () -> Unit) {
val context = LocalContext.current
Column(
modifier = Modifier
.fillMaxSize()
.padding(SIXTEEN_DP),
verticalArrangement = Arrangement.Bottom,
horizontalAlignment = Alignment.End
) {
TextButton(
onClick = onClick,
modifier = Modifier.semantics { testTag = SHOWCASE_VIEW_NEXT_BUTTON_TESTING_TAG }
) {
Text(
text = context.getString(R.string.next),
style = LocalTextStyle.current.copy(
fontSize = SHOWCASE_VIEW_NEXT_BUTTON_TEXT_SIZE,
fontWeight = FontWeight.Bold,
color = White,
shadow = Shadow(
Color.Black.copy(alpha = SHOWCASE_MESSAGE_SHADOW_COLOR_ALPHA),
defaultBlurOffsetForMessageAndNextButton(),
blurRadius = SHOWCASE_MESSAGE_SHADOW_BLUR_RADIUS
)
)
)
}
}
}
/**
* Represents a single item in the showcase view sequence.
*
* @param index The order in which this target should be shown in the showcase flow.
* @param coordinates Layout coordinates used to determine position and size of the target view on screen.
* @param showCaseMessage Message to be displayed near the highlighted target.
* @param showCaseMessageColor Optional color for the message text (default is white).
* @param blurOpacity Controls the opacity of the background overlay behind the highlight (default is 0.8).
* @param customSizeForShowcaseViewCircle Optional custom size for the radius of the highlight circle.
* If null, it uses the size of the target's bounds.
*/
data class ShowcaseProperty(
val index: Int,
val coordinates: LayoutCoordinates,
val showCaseMessage: String,
val showCaseMessageColor: Color = Color.White,
val blurOpacity: Float = SHOWCASE_VIEW_BACKGROUND_COLOR_ALPHA,
val customSizeForShowcaseViewCircle: Dp? = null,
)

View File

@ -19,6 +19,7 @@
package org.kiwix.kiwixmobile.core.ui.models
import androidx.annotation.StringRes
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import org.kiwix.kiwixmobile.core.ui.theme.White
@ -28,5 +29,6 @@ data class ActionMenuItem(
val onClick: () -> Unit,
val iconTint: Color = White,
val isEnabled: Boolean = true,
val testingTag: String
val testingTag: String,
val modifier: Modifier = Modifier
)

View File

@ -44,6 +44,7 @@ object ComposeDimens {
val TWENTY_DP = 20.dp
val SEVENTEEN_DP = 17.dp
val SIXTEEN_DP = 16.dp
val FIFTEEN_DP = 15.dp
val TWELVE_DP = 12.dp
val TEN_DP = 10.dp
val EIGHT_DP = 8.dp
@ -51,6 +52,7 @@ object ComposeDimens {
val FIVE_DP = 5.dp
val FOUR_DP = 4.dp
val TWO_DP = 2.dp
val ONE_DP = 1.dp
val SEVENTY_DP = 70.dp
val SIXTY_FOUR_DP = 64.dp
@ -62,6 +64,9 @@ object ComposeDimens {
// Default letter spacing in text according to theme
val DEFAULT_LETTER_SPACING = 0.0333.em
// Default Text alpha.
const val DEFAULT_TEXT_ALPHA = 0.67f
// Shape configuration sizes. See Shape.kt
val EXTRA_SMALL_ROUND_SHAPE_SIZE = 4.dp
val SMALL_ROUND_SHAPE_SIZE = 8.dp
@ -111,4 +116,27 @@ object ComposeDimens {
val PAGE_LIST_ITEM_FAVICON_SIZE = 40.dp
val PAGE_SWITCH_LEFT_RIGHT_MARGIN = 10.dp
val PAGE_SWITCH_ROW_BOTTOM_MARGIN = 8.dp
// LocalFileTransferFragment dimens
val PEER_DEVICE_ITEM_TEXT_SIZE = 17.sp
val FILE_ITEM_TEXT_SIZE = 14.sp
val FILE_ITEM_ICON_SIZE = 24.dp
val NEARBY_DEVICE_LIST_HEIGHT = 160.dp
val NO_DEVICE_FOUND_TEXT_PADDING = 50.dp
val YOUR_DEVICE_TEXT_SIZE = 13.sp
val FILE_FOR_TRANSFER_TEXT_SIZE = 16.sp
val NEARBY_DEVICES_TEXT_SIZE = 16.sp
// KiwixShowCase view dimens
val SHOWCASE_VIEW_MESSAGE_TEXT_SIZE = 17.sp
val SHOWCASE_VIEW_NEXT_BUTTON_TEXT_SIZE = 20.sp
val FILE_FOR_TRANSFER_SHOW_CASE_VIEW_SIZE = 100.dp
val NEARBY_DEVICES_SHOW_CASE_VIEW_SIZE = 100.dp
const val SHOWCASE_MESSAGE_SHADOW_BLUR_RADIUS = 3f
const val SHOWCASE_MESSAGE_SHADOW_COLOR_ALPHA = 0.5f
const val SHOWCASE_VIEW_BACKGROUND_COLOR_ALPHA = 0.8f
const val PULSE_ANIMATION_START = 0f
const val PULSE_ANIMATION_END = 1f
const val PULSE_ALPHA = 0.99f
const val PULSE_RADIUS_EXTRA = 20f
}

View File

@ -137,6 +137,10 @@ class SharedPreferenceUtil @Inject constructor(val context: Context) {
ContextWrapper(context).externalMediaDirs[0]?.path
?: context.filesDir.path // a workaround for emulators
fun showCaseViewForFileTransferShown() {
sharedPreferences.edit { putBoolean(PREF_SHOW_SHOWCASE, false) }
}
fun putPrefBookMarkMigrated(isMigrated: Boolean) =
sharedPreferences.edit { putBoolean(PREF_BOOKMARKS_MIGRATED, isMigrated) }