From 8b79d0ee2944eb47232ed5eba4d44b48865ed57a Mon Sep 17 00:00:00 2001 From: toliuweijing Date: Mon, 19 Aug 2024 15:11:51 +0800 Subject: [PATCH] Migrate empty_state_view to Jetpack Compose --- .../newpipe/fragments/EmptyFragment.java | 8 +- .../list/channel/ChannelFragment.java | 19 +- .../list/channel/ChannelTabFragment.java | 7 + .../fragments/list/search/SearchFragment.java | 6 + .../local/bookmark/BookmarkFragment.java | 2 + .../schabi/newpipe/local/feed/FeedFragment.kt | 2 + .../subscription/SubscriptionFragment.kt | 3 + .../settings/SelectPlaylistFragment.java | 5 +- .../PreferenceSearchFragment.java | 5 + .../ui/emptystate/EmptyStateComposable.kt | 168 ++++++++++++++++++ .../newpipe/ui/emptystate/EmptyStateUtil.kt | 87 +++++++++ .../newpipe/ui/theme/ColorExtensions.kt | 21 +++ .../main/res/layout/fragment_bookmarks.xml | 6 +- app/src/main/res/layout/fragment_channel.xml | 29 +-- .../main/res/layout/fragment_channel_tab.xml | 6 +- .../res/layout/fragment_channel_videos.xml | 28 +-- app/src/main/res/layout/fragment_empty.xml | 7 +- app/src/main/res/layout/fragment_feed.xml | 6 +- app/src/main/res/layout/fragment_search.xml | 25 +-- .../main/res/layout/fragment_subscription.xml | 6 +- .../res/layout/select_playlist_fragment.xml | 6 +- .../settings_preferencesearch_fragment.xml | 25 +-- 22 files changed, 356 insertions(+), 121 deletions(-) create mode 100644 app/src/main/java/org/schabi/newpipe/ui/emptystate/EmptyStateComposable.kt create mode 100644 app/src/main/java/org/schabi/newpipe/ui/emptystate/EmptyStateUtil.kt create mode 100644 app/src/main/java/org/schabi/newpipe/ui/theme/ColorExtensions.kt diff --git a/app/src/main/java/org/schabi/newpipe/fragments/EmptyFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/EmptyFragment.java index d4e73bcac..8c939a3e8 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/EmptyFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/EmptyFragment.java @@ -6,9 +6,11 @@ import android.view.View; import android.view.ViewGroup; import androidx.annotation.Nullable; +import androidx.compose.ui.platform.ComposeView; import org.schabi.newpipe.BaseFragment; import org.schabi.newpipe.R; +import org.schabi.newpipe.ui.emptystate.EmptyStateUtil; public class EmptyFragment extends BaseFragment { private static final String SHOW_MESSAGE = "SHOW_MESSAGE"; @@ -26,8 +28,10 @@ public class EmptyFragment extends BaseFragment { final Bundle savedInstanceState) { final boolean showMessage = getArguments().getBoolean(SHOW_MESSAGE); final View view = inflater.inflate(R.layout.fragment_empty, container, false); - view.findViewById(R.id.empty_state_view).setVisibility( - showMessage ? View.VISIBLE : View.GONE); + + final ComposeView composeView = view.findViewById(R.id.empty_state_view); + EmptyStateUtil.setEmptyStateComposable(composeView); + composeView.setVisibility(showMessage ? View.VISIBLE : View.GONE); return view; } } diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelFragment.java index 55e3ae52a..394d97f12 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelFragment.java @@ -10,7 +10,6 @@ import android.graphics.Color; import android.os.Bundle; import android.text.TextUtils; import android.util.Log; -import android.util.TypedValue; import android.view.LayoutInflater; import android.view.Menu; import android.view.MenuInflater; @@ -20,6 +19,7 @@ import android.view.ViewGroup; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.compose.runtime.MutableState; import androidx.core.content.ContextCompat; import androidx.core.graphics.ColorUtils; import androidx.core.view.MenuProvider; @@ -45,6 +45,9 @@ import org.schabi.newpipe.fragments.detail.TabAdapter; import org.schabi.newpipe.ktx.AnimationType; import org.schabi.newpipe.local.feed.notifications.NotificationHelper; import org.schabi.newpipe.local.subscription.SubscriptionManager; +import org.schabi.newpipe.ui.emptystate.EmptyStateSpec; +import org.schabi.newpipe.ui.emptystate.EmptyStateSpecBuilder; +import org.schabi.newpipe.ui.emptystate.EmptyStateUtil; import org.schabi.newpipe.util.ChannelTabHelper; import org.schabi.newpipe.util.Constants; import org.schabi.newpipe.util.ExtractorHelper; @@ -102,6 +105,8 @@ public class ChannelFragment extends BaseStateFragment private SubscriptionEntity channelSubscription; private MenuProvider menuProvider; + private MutableState emptyStateSpec; + public static ChannelFragment getInstance(final int serviceId, final String url, final String name) { final ChannelFragment instance = new ChannelFragment(); @@ -199,6 +204,10 @@ public class ChannelFragment extends BaseStateFragment protected void initViews(final View rootView, final Bundle savedInstanceState) { super.initViews(rootView, savedInstanceState); + emptyStateSpec = EmptyStateUtil.mutableStateOf( + EmptyStateSpec.Companion.getContentNotSupported()); + EmptyStateUtil.setEmptyStateComposable(binding.emptyStateView, emptyStateSpec); + tabAdapter = new TabAdapter(getChildFragmentManager()); binding.viewPager.setAdapter(tabAdapter); binding.tabLayout.setupWithViewPager(binding.viewPager); @@ -645,8 +654,10 @@ public class ChannelFragment extends BaseStateFragment return; } - binding.errorContentNotSupported.setVisibility(View.VISIBLE); - binding.channelKaomoji.setText("(︶︹︺)"); - binding.channelKaomoji.setTextSize(TypedValue.COMPLEX_UNIT_SP, 45f); + emptyStateSpec.setValue( + new EmptyStateSpecBuilder(emptyStateSpec.getValue()) + .descriptionVisibility(true) + .build() + ); } } diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelTabFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelTabFragment.java index 5d398821a..feb23b6ac 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelTabFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelTabFragment.java @@ -26,6 +26,7 @@ import org.schabi.newpipe.fragments.list.BaseListInfoFragment; import org.schabi.newpipe.fragments.list.playlist.PlaylistControlViewHolder; import org.schabi.newpipe.player.playqueue.ChannelTabPlayQueue; import org.schabi.newpipe.player.playqueue.PlayQueue; +import org.schabi.newpipe.ui.emptystate.EmptyStateUtil; import org.schabi.newpipe.util.ChannelTabHelper; import org.schabi.newpipe.util.ExtractorHelper; import org.schabi.newpipe.util.PlayButtonHelper; @@ -79,6 +80,12 @@ public class ChannelTabFragment extends BaseListInfoFragment() { override fun onViewCreated(rootView: View, savedInstanceState: Bundle?) { // super.onViewCreated() calls initListeners() which require the binding to be initialized _feedBinding = FragmentFeedBinding.bind(rootView) + feedBinding.emptyStateView.setEmptyStateComposable() super.onViewCreated(rootView, savedInstanceState) val factory = FeedViewModel.getFactory(requireContext(), groupId) diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionFragment.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionFragment.kt index 5583a2c4a..e4a9b79a2 100644 --- a/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionFragment.kt +++ b/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionFragment.kt @@ -56,6 +56,7 @@ import org.schabi.newpipe.local.subscription.services.SubscriptionsImportService import org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.PREVIOUS_EXPORT_MODE import org.schabi.newpipe.streams.io.NoFileManagerSafeGuard import org.schabi.newpipe.streams.io.StoredFileHelper +import org.schabi.newpipe.ui.emptystate.setEmptyStateComposable import org.schabi.newpipe.util.NavigationHelper import org.schabi.newpipe.util.OnClickGesture import org.schabi.newpipe.util.ServiceHelper @@ -257,6 +258,8 @@ class SubscriptionFragment : BaseStateFragment() { binding.itemsList.adapter = groupAdapter binding.itemsList.itemAnimator = null + binding.emptyStateView.setEmptyStateComposable() + viewModel = ViewModelProvider(this)[SubscriptionViewModel::class.java] viewModel.stateLiveData.observe(viewLifecycleOwner) { it?.let(this::handleResult) } viewModel.feedGroupsLiveData.observe(viewLifecycleOwner) { diff --git a/app/src/main/java/org/schabi/newpipe/settings/SelectPlaylistFragment.java b/app/src/main/java/org/schabi/newpipe/settings/SelectPlaylistFragment.java index c340dca22..efe3c4f66 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/SelectPlaylistFragment.java +++ b/app/src/main/java/org/schabi/newpipe/settings/SelectPlaylistFragment.java @@ -11,6 +11,7 @@ import android.widget.ProgressBar; import android.widget.TextView; import androidx.annotation.NonNull; +import androidx.compose.ui.platform.ComposeView; import androidx.fragment.app.DialogFragment; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; @@ -27,6 +28,7 @@ import org.schabi.newpipe.error.ErrorUtil; import org.schabi.newpipe.error.UserAction; import org.schabi.newpipe.local.playlist.LocalPlaylistManager; import org.schabi.newpipe.local.playlist.RemotePlaylistManager; +import org.schabi.newpipe.ui.emptystate.EmptyStateUtil; import org.schabi.newpipe.util.image.CoilHelper; import java.util.List; @@ -40,7 +42,7 @@ public class SelectPlaylistFragment extends DialogFragment { private OnSelectedListener onSelectedListener = null; private ProgressBar progressBar; - private TextView emptyView; + private ComposeView emptyView; private RecyclerView recyclerView; private Disposable disposable = null; @@ -62,6 +64,7 @@ public class SelectPlaylistFragment extends DialogFragment { recyclerView = v.findViewById(R.id.items_list); emptyView = v.findViewById(R.id.empty_state_view); + EmptyStateUtil.setEmptyStateText(emptyView, R.string.no_playlist_bookmarked_yet); recyclerView.setLayoutManager(new LinearLayoutManager(getContext())); final SelectPlaylistAdapter playlistAdapter = new SelectPlaylistAdapter(); recyclerView.setAdapter(playlistAdapter); diff --git a/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchFragment.java b/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchFragment.java index 9d169d660..f667bb900 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchFragment.java +++ b/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchFragment.java @@ -11,6 +11,8 @@ import androidx.fragment.app.Fragment; import androidx.recyclerview.widget.LinearLayoutManager; import org.schabi.newpipe.databinding.SettingsPreferencesearchFragmentBinding; +import org.schabi.newpipe.ui.emptystate.EmptyStateSpec; +import org.schabi.newpipe.ui.emptystate.EmptyStateUtil; import java.util.List; @@ -39,6 +41,9 @@ public class PreferenceSearchFragment extends Fragment { binding = SettingsPreferencesearchFragmentBinding.inflate(inflater, container, false); binding.searchResults.setLayoutManager(new LinearLayoutManager(getContext())); + EmptyStateUtil.setEmptyStateComposable( + binding.emptyStateView, + EmptyStateSpec.Companion.getNoSearchMaxSizeResult()); adapter = new PreferenceSearchAdapter(); adapter.setOnItemClickListener(this::onItemClicked); diff --git a/app/src/main/java/org/schabi/newpipe/ui/emptystate/EmptyStateComposable.kt b/app/src/main/java/org/schabi/newpipe/ui/emptystate/EmptyStateComposable.kt new file mode 100644 index 000000000..3af217a9c --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/ui/emptystate/EmptyStateComposable.kt @@ -0,0 +1,168 @@ +package org.schabi.newpipe.ui.emptystate + +import android.graphics.Color +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.schabi.newpipe.R +import org.schabi.newpipe.ui.theme.AppTheme +import org.schabi.newpipe.ui.theme.errorHint + +@Composable +fun EmptyStateComposable( + spec: EmptyStateSpec, + modifier: Modifier = Modifier, +) = EmptyStateComposable( + modifier = spec.modifier(modifier), + emojiModifier = spec.emojiModifier(), + emojiText = spec.emojiText(), + emojiTextStyle = spec.emojiTextStyle(), + descriptionModifier = spec.descriptionModifier(), + descriptionText = spec.descriptionText(), + descriptionTextStyle = spec.descriptionTextStyle(), + descriptionTextVisibility = spec.descriptionVisibility(), +) + +@Composable +private fun EmptyStateComposable( + modifier: Modifier, + emojiModifier: Modifier, + emojiText: String, + emojiTextStyle: TextStyle, + descriptionModifier: Modifier, + descriptionText: String, + descriptionTextStyle: TextStyle, + descriptionTextVisibility: Boolean, +) { + CompositionLocalProvider( + LocalContentColor provides MaterialTheme.colorScheme.errorHint + ) { + Column( + modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + modifier = emojiModifier, + text = emojiText, + style = emojiTextStyle, + ) + + if (descriptionTextVisibility) { + Text( + modifier = descriptionModifier, + text = descriptionText, + style = descriptionTextStyle, + ) + } + } + } +} + +@Preview(showBackground = true, backgroundColor = Color.WHITE.toLong()) +@Composable +fun EmptyStateComposableGenericErrorPreview() { + AppTheme { + EmptyStateComposable(EmptyStateSpec.GenericError) + } +} + +@Preview(showBackground = true, backgroundColor = Color.WHITE.toLong()) +@Composable +fun EmptyStateComposableNoCommentPreview() { + AppTheme { + EmptyStateComposable(EmptyStateSpec.NoComment) + } +} + +data class EmptyStateSpec( + val modifier: (Modifier) -> Modifier, + val emojiModifier: () -> Modifier, + val emojiText: @Composable () -> String, + val emojiTextStyle: @Composable () -> TextStyle, + val descriptionText: @Composable () -> String, + val descriptionModifier: () -> Modifier, + val descriptionTextStyle: @Composable () -> TextStyle, + val descriptionVisibility: () -> Boolean = { true }, +) { + + companion object { + + val GenericError = + EmptyStateSpec( + modifier = { + it + .fillMaxWidth() + .heightIn(min = 128.dp) + }, + emojiModifier = { Modifier }, + emojiText = { "¯\\_(ツ)_/¯" }, + emojiTextStyle = { MaterialTheme.typography.titleLarge }, + descriptionModifier = { + Modifier + .padding(top = 6.dp) + .padding(horizontal = 16.dp) + }, + descriptionText = { stringResource(id = R.string.empty_list_subtitle) }, + descriptionTextStyle = { MaterialTheme.typography.bodyMedium } + ) + + val NoComment = + EmptyStateSpec( + modifier = { it.padding(top = 85.dp) }, + emojiModifier = { Modifier.padding(bottom = 10.dp) }, + emojiText = { "(╯°-°)╯" }, + emojiTextStyle = { + LocalTextStyle.current.merge( + fontFamily = FontFamily.Monospace, + fontSize = 35.sp, + ) + }, + descriptionModifier = { Modifier }, + descriptionText = { stringResource(id = R.string.no_comments) }, + descriptionTextStyle = { + LocalTextStyle.current.merge(fontSize = 24.sp) + } + ) + + val NoSearchResult = + NoComment.copy( + modifier = { it }, + emojiText = { "╰(°●°╰)" }, + descriptionText = { stringResource(id = R.string.search_no_results) } + ) + + val NoSearchMaxSizeResult = + NoSearchResult.copy( + modifier = { it.fillMaxSize() }, + ) + + val ContentNotSupported = + NoComment.copy( + modifier = { it.padding(top = 90.dp) }, + emojiText = { "(︶︹︺)" }, + emojiTextStyle = { LocalTextStyle.current.merge(fontSize = 45.sp) }, + descriptionModifier = { Modifier.padding(top = 20.dp) }, + descriptionText = { stringResource(id = R.string.content_not_supported) }, + descriptionTextStyle = { LocalTextStyle.current.merge(fontSize = 15.sp) }, + descriptionVisibility = { false }, + ) + } +} diff --git a/app/src/main/java/org/schabi/newpipe/ui/emptystate/EmptyStateUtil.kt b/app/src/main/java/org/schabi/newpipe/ui/emptystate/EmptyStateUtil.kt new file mode 100644 index 000000000..b025bdc5b --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/ui/emptystate/EmptyStateUtil.kt @@ -0,0 +1,87 @@ +@file:JvmName("EmptyStateUtil") + +package org.schabi.newpipe.ui.emptystate + +import androidx.annotation.StringRes +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.State +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.res.stringResource +import org.schabi.newpipe.ui.theme.AppTheme +import org.schabi.newpipe.ui.theme.errorHint +import androidx.compose.runtime.mutableStateOf as composeRuntimeMutableStateOf + +@JvmOverloads +fun ComposeView.setEmptyStateText( + @StringRes stringRes: Int, + strategy: ViewCompositionStrategy = ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed, +) = apply { + setViewCompositionStrategy(strategy) + setContent { + AppTheme { + Text( + text = stringResource(id = stringRes), + color = MaterialTheme.colorScheme.errorHint, + ) + } + } +} + +@JvmOverloads +fun ComposeView.setEmptyStateComposable( + spec: EmptyStateSpec = EmptyStateSpec.GenericError, + strategy: ViewCompositionStrategy = ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed, +) = apply { + setViewCompositionStrategy(strategy) + setContent { + AppTheme { + EmptyStateComposable( + spec = spec + ) + } + } +} + +@JvmOverloads +fun ComposeView.setEmptyStateComposable( + spec: State, + strategy: ViewCompositionStrategy = ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed, +) = apply { + setViewCompositionStrategy(strategy) + setContent { + AppTheme { + EmptyStateComposable( + spec = spec.value, + ) + } + } +} + +/** + * Used in Java land to bridge the [MutableState] API. + */ +fun mutableStateOf(param: T): MutableState { + return composeRuntimeMutableStateOf(param) +} + +/** + * Used in Java land to modify [EmptyStateSpec] properties. + * TODO: remove after Kotlin migration + */ +class EmptyStateSpecBuilder(var spec: EmptyStateSpec) { + + fun descriptionText(@StringRes stringRes: Int) = apply { + spec = spec.copy( + descriptionText = { stringResource(id = stringRes) } + ) + } + + fun descriptionVisibility(descriptionTextVisibility: Boolean) = apply { + spec = spec.copy(descriptionVisibility = { descriptionTextVisibility }) + } + + fun build() = spec +} diff --git a/app/src/main/java/org/schabi/newpipe/ui/theme/ColorExtensions.kt b/app/src/main/java/org/schabi/newpipe/ui/theme/ColorExtensions.kt new file mode 100644 index 000000000..8e9118c6e --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/ui/theme/ColorExtensions.kt @@ -0,0 +1,21 @@ +package org.schabi.newpipe.ui.theme + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.ColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color + +/** + * Extended color for error hint. + * See [specification](https://m1.material.io/patterns/errors.html#errors-user-input-errors) + */ +val md_theme_light_error_hint = Color(0x61000000) + +val md_theme_dark_error_hint = Color(0x80FFFFFF) + +val ColorScheme.errorHint: Color + @Composable get() = if (isSystemInDarkTheme()) { + Color(0x80FFFFFF) + } else { + Color(0x61000000) + } diff --git a/app/src/main/res/layout/fragment_bookmarks.xml b/app/src/main/res/layout/fragment_bookmarks.xml index 418c11964..9767a1081 100644 --- a/app/src/main/res/layout/fragment_bookmarks.xml +++ b/app/src/main/res/layout/fragment_bookmarks.xml @@ -24,15 +24,15 @@ android:visibility="gone" tools:visibility="visible" /> - + tools:visibility="visible" + /> - - - - - - - + tools:visibility="visible" + /> - + tools:visibility="visible" + /> - - - - - - - + tools:visibility="visible" + /> - + android:layout_marginTop="90dp" + /> + diff --git a/app/src/main/res/layout/fragment_feed.xml b/app/src/main/res/layout/fragment_feed.xml index de2096605..3c61c824f 100644 --- a/app/src/main/res/layout/fragment_feed.xml +++ b/app/src/main/res/layout/fragment_feed.xml @@ -140,15 +140,15 @@ android:visibility="gone" tools:visibility="visible" /> - + tools:visibility="visible" + /> - - - - - - - + tools:visibility="visible" + /> - + tools:visibility="visible" + /> - - + /> - - - - - - - + tools:visibility="gone" + />