From 17c7609d72ff73a819f3f66102b96637eda9e0a2 Mon Sep 17 00:00:00 2001 From: Martijn Brekhof Date: Tue, 8 Dec 2015 14:29:15 +0100 Subject: [PATCH] Implemented shared element transition for TV shows Added SharedElementCallback to be able to detect if shared element is still visible. If not, the shared element transition should not be performed when returning to the previous fragment. Added the pager tab strip to the fade animation to keep shared element transition smooth when poster is partly below the toolbar. --- .../xbmc/kore/ui/TVShowDetailsFragment.java | 66 ++++++++++++---- .../org/xbmc/kore/ui/TVShowListFragment.java | 61 +++++++++++---- .../xbmc/kore/ui/TVShowOverviewFragment.java | 61 ++++++++++----- .../org/xbmc/kore/ui/TVShowsActivity.java | 75 ++++++++++++++----- .../java/org/xbmc/kore/utils/UIUtils.java | 10 +++ .../main/res/transition-v21/media_details.xml | 1 + 6 files changed, 207 insertions(+), 67 deletions(-) diff --git a/app/src/main/java/org/xbmc/kore/ui/TVShowDetailsFragment.java b/app/src/main/java/org/xbmc/kore/ui/TVShowDetailsFragment.java index 155f28f..289d4fa 100644 --- a/app/src/main/java/org/xbmc/kore/ui/TVShowDetailsFragment.java +++ b/app/src/main/java/org/xbmc/kore/ui/TVShowDetailsFragment.java @@ -15,6 +15,7 @@ */ package org.xbmc.kore.ui; +import android.annotation.TargetApi; import android.os.Bundle; import android.support.v4.app.Fragment; import android.support.v4.view.ViewPager; @@ -23,9 +24,12 @@ import android.view.View; import android.view.ViewGroup; import com.astuetz.PagerSlidingTabStrip; + import org.xbmc.kore.R; import org.xbmc.kore.utils.LogUtils; import org.xbmc.kore.utils.TabsAdapter; +import org.xbmc.kore.utils.UIUtils; +import org.xbmc.kore.utils.Utils; import butterknife.ButterKnife; import butterknife.InjectView; @@ -36,22 +40,45 @@ import butterknife.InjectView; public class TVShowDetailsFragment extends Fragment { private static final String TAG = LogUtils.makeLogTag(TVShowDetailsFragment.class); - public static final String TVSHOWID = "tvshow_id"; + public static final String POSTER_TRANS_NAME = "POSTER_TRANS_NAME"; + public static final String BUNDLE_KEY_TVSHOWID = "tvshow_id"; + public static final String BUNDLE_KEY_TITLE = "title"; + public static final String BUNDLE_KEY_PREMIERED = "premiered"; + public static final String BUNDLE_KEY_STUDIO = "studio"; + public static final String BUNDLE_KEY_EPISODE = "episode"; + public static final String BUNDLE_KEY_WATCHEDEPISODES = "watchedepisodes"; + public static final String BUNDLE_KEY_RATING = "rating"; + public static final String BUNDLE_KEY_PLOT = "plot"; + public static final String BUNDLE_KEY_GENRES = "genres"; // Displayed movie id private int tvshowId = -1; + private TabsAdapter tabsAdapter; + @InjectView(R.id.pager_tab_strip) PagerSlidingTabStrip pagerTabStrip; @InjectView(R.id.pager) ViewPager viewPager; /** * Create a new instance of this, initialized to show tvshowId */ - public static TVShowDetailsFragment newInstance(int tvshowId) { + @TargetApi(21) + public static TVShowDetailsFragment newInstance(TVShowListFragment.ViewHolder vh) { TVShowDetailsFragment fragment = new TVShowDetailsFragment(); Bundle args = new Bundle(); - args.putInt(TVSHOWID, tvshowId); + args.putInt(BUNDLE_KEY_TVSHOWID, vh.tvshowId); + args.putInt(BUNDLE_KEY_EPISODE, vh.episode); + args.putString(BUNDLE_KEY_GENRES, vh.genres); + args.putString(BUNDLE_KEY_PLOT, vh.plot); + args.putString(BUNDLE_KEY_PREMIERED, vh.premiered); + args.putDouble(BUNDLE_KEY_RATING, vh.rating); + args.putString(BUNDLE_KEY_STUDIO, vh.studio); + args.putString(BUNDLE_KEY_TITLE, vh.tvshowTitle); + args.putInt(BUNDLE_KEY_WATCHEDEPISODES, vh.watchedEpisodes); + if( Utils.isLollipopOrLater()) { + args.putString(POSTER_TRANS_NAME, vh.artView.getTransitionName()); + } fragment.setArguments(args); return fragment; } @@ -63,7 +90,7 @@ public class TVShowDetailsFragment extends Fragment { @Override public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { - tvshowId = getArguments().getInt(TVSHOWID, -1); + tvshowId = getArguments().getInt(BUNDLE_KEY_TVSHOWID, -1); if ((container == null) || (tvshowId == -1)) { // We're not being shown or there's nothing to show @@ -74,7 +101,7 @@ public class TVShowDetailsFragment extends Fragment { ButterKnife.inject(this, root); long baseFragmentId = tvshowId * 10; - TabsAdapter tabsAdapter = new TabsAdapter(getActivity(), getChildFragmentManager()) + tabsAdapter = new TabsAdapter(getActivity(), getChildFragmentManager()) .addTab(TVShowOverviewFragment.class, getArguments(), R.string.tvshow_overview, baseFragmentId) .addTab(TVShowEpisodeListFragment.class, getArguments(), @@ -92,19 +119,26 @@ public class TVShowDetailsFragment extends Fragment { setHasOptionsMenu(false); } - @Override - public void onResume() { - super.onResume(); + public Fragment getCurrentTabFragment() { + return tabsAdapter.getItem(viewPager.getCurrentItem()); } - @Override - public void onPause() { - super.onPause(); - } + public View getSharedElement() { + View view = getView(); + if (view == null) + return null; - @Override - public void onSaveInstanceState(Bundle outState) { - super.onSaveInstanceState(outState); -// outState.putInt(TVSHOWID, tvshowId); + //Note: this works as R.id.poster is only used in TVShowOverviewFragment. + //If the same id is used in other fragments in the TabsAdapter we + //need to check which fragment is currently displayed + View artView = view.findViewById(R.id.poster); + View scrollView = view.findViewById(R.id.media_panel); + if (( artView != null ) && + ( scrollView != null ) && + UIUtils.isViewInBounds(scrollView, artView)) { + return artView; + } + + return null; } } diff --git a/app/src/main/java/org/xbmc/kore/ui/TVShowListFragment.java b/app/src/main/java/org/xbmc/kore/ui/TVShowListFragment.java index 6aa9220..c4f7376 100644 --- a/app/src/main/java/org/xbmc/kore/ui/TVShowListFragment.java +++ b/app/src/main/java/org/xbmc/kore/ui/TVShowListFragment.java @@ -15,6 +15,7 @@ */ package org.xbmc.kore.ui; +import android.annotation.TargetApi; import android.app.Activity; import android.content.Context; import android.content.SharedPreferences; @@ -47,6 +48,7 @@ import org.xbmc.kore.provider.MediaDatabase; import org.xbmc.kore.service.LibrarySyncService; import org.xbmc.kore.utils.LogUtils; import org.xbmc.kore.utils.UIUtils; +import org.xbmc.kore.utils.Utils; /** * Fragment that presents the tv show list @@ -55,7 +57,7 @@ public class TVShowListFragment extends AbstractListFragment { private static final String TAG = LogUtils.makeLogTag(TVShowListFragment.class); public interface OnTVShowSelectedListener { - public void onTVShowSelected(int tvshowId, String tvshowTitle); + public void onTVShowSelected(TVShowListFragment.ViewHolder vh); } // Activity listener @@ -72,7 +74,7 @@ public class TVShowListFragment extends AbstractListFragment { // Get the movie id from the tag ViewHolder tag = (ViewHolder) view.getTag(); // Notify the activity - listenerActivity.onTVShowSelected(tag.tvshowId, tag.tvshowTitle); + listenerActivity.onTVShowSelected(tag); } }; } @@ -225,9 +227,16 @@ public class TVShowListFragment extends AbstractListFragment { MediaContract.TVShows.TVSHOWID, MediaContract.TVShows.TITLE, MediaContract.TVShows.THUMBNAIL, + MediaContract.TVShows.FANART, MediaContract.TVShows.PREMIERED, + MediaContract.TVShows.STUDIO, MediaContract.TVShows.EPISODE, MediaContract.TVShows.WATCHEDEPISODES, + MediaContract.TVShows.RATING, + MediaContract.TVShows.PLOT, + MediaContract.TVShows.PLAYCOUNT, + MediaContract.TVShows.IMDBNUMBER, + MediaContract.TVShows.GENRES, }; String SORT_BY_NAME = MediaContract.TVShows.TITLE + " ASC"; @@ -238,9 +247,16 @@ public class TVShowListFragment extends AbstractListFragment { final int TVSHOWID = 1; final int TITLE = 2; final int THUMBNAIL = 3; - final int PREMIERED = 4; - final int EPISODE = 5; - final int WATCHEDEPISODES = 6; + final int FANART = 4; + final int PREMIERED = 5; + final int STUDIO = 6; + final int EPISODE = 7; + final int WATCHEDEPISODES = 8; + final int RATING = 9; + final int PLOT = 10; + final int PLAYCOUNT = 11; + final int IMDBNUMBER = 12; + final int GENRES = 13; } private static class TVShowsAdapter extends CursorAdapter { @@ -257,16 +273,16 @@ public class TVShowListFragment extends AbstractListFragment { // the user transitions to that fragment, avoiding another call and imediatelly showing the image Resources resources = context.getResources(); artWidth = (int)(resources.getDimension(R.dimen.now_playing_poster_width) / - UIUtils.IMAGE_RESIZE_FACTOR); + UIUtils.IMAGE_RESIZE_FACTOR); artHeight = (int)(resources.getDimension(R.dimen.now_playing_poster_height) / - UIUtils.IMAGE_RESIZE_FACTOR); + UIUtils.IMAGE_RESIZE_FACTOR); } /** {@inheritDoc} */ @Override public View newView(Context context, final Cursor cursor, ViewGroup parent) { final View view = LayoutInflater.from(context) - .inflate(R.layout.grid_item_tvshow, parent, false); + .inflate(R.layout.grid_item_tvshow, parent, false); // Setup View holder pattern ViewHolder viewHolder = new ViewHolder(); @@ -281,6 +297,7 @@ public class TVShowListFragment extends AbstractListFragment { } /** {@inheritDoc} */ + @TargetApi(21) @Override public void bindView(View view, Context context, Cursor cursor) { final ViewHolder viewHolder = (ViewHolder)view.getTag(); @@ -288,16 +305,25 @@ public class TVShowListFragment extends AbstractListFragment { // Save the movie id viewHolder.tvshowId = cursor.getInt(TVShowListQuery.TVSHOWID); viewHolder.tvshowTitle = cursor.getString(TVShowListQuery.TITLE); + viewHolder.episode = cursor.getInt(TVShowListQuery.EPISODE); + viewHolder.genres = cursor.getString(TVShowListQuery.GENRES); + viewHolder.plot = cursor.getString(TVShowListQuery.PLOT); + viewHolder.premiered = cursor.getString(TVShowListQuery.PREMIERED); + viewHolder.rating = cursor.getInt(TVShowListQuery.RATING); + viewHolder.studio = cursor.getString(TVShowListQuery.STUDIO); + viewHolder.watchedEpisodes = cursor.getInt(TVShowListQuery.WATCHEDEPISODES); + + if(Utils.isLollipopOrLater()) { + viewHolder.artView.setTransitionName("a"+viewHolder.tvshowId); + } viewHolder.titleView.setText(viewHolder.tvshowTitle); - int numEpisodes = cursor.getInt(TVShowListQuery.EPISODE), - watchedEpisodes = cursor.getInt(TVShowListQuery.WATCHEDEPISODES); String details = String.format(context.getString(R.string.num_episodes), - numEpisodes, numEpisodes - watchedEpisodes); + viewHolder.episode, viewHolder.episode - viewHolder.watchedEpisodes); viewHolder.detailsView.setText(details); String premiered = String.format(context.getString(R.string.premiered), - cursor.getString(TVShowListQuery.PREMIERED)); + viewHolder.premiered); viewHolder.premieredView.setText(premiered); UIUtils.loadImageWithCharacterAvatar(context, hostManager, cursor.getString(TVShowListQuery.THUMBNAIL), viewHolder.tvshowTitle, @@ -308,14 +334,21 @@ public class TVShowListFragment extends AbstractListFragment { /** * View holder pattern */ - private static class ViewHolder { + public static class ViewHolder { TextView titleView; TextView detailsView; -// TextView yearView; + // TextView yearView; TextView premieredView; ImageView artView; int tvshowId; String tvshowTitle; + String premiered; + String studio; + int episode; + int watchedEpisodes; + double rating; + String plot; + String genres; } } diff --git a/app/src/main/java/org/xbmc/kore/ui/TVShowOverviewFragment.java b/app/src/main/java/org/xbmc/kore/ui/TVShowOverviewFragment.java index 1176f12..60debd0 100644 --- a/app/src/main/java/org/xbmc/kore/ui/TVShowOverviewFragment.java +++ b/app/src/main/java/org/xbmc/kore/ui/TVShowOverviewFragment.java @@ -15,6 +15,7 @@ */ package org.xbmc.kore.ui; +import android.annotation.TargetApi; import android.content.res.Resources; import android.database.Cursor; import android.net.Uri; @@ -42,6 +43,7 @@ import org.xbmc.kore.provider.MediaContract; import org.xbmc.kore.service.LibrarySyncService; import org.xbmc.kore.utils.LogUtils; import org.xbmc.kore.utils.UIUtils; +import org.xbmc.kore.utils.Utils; import java.util.ArrayList; @@ -105,8 +107,10 @@ public class TVShowOverviewFragment extends AbstractDetailsFragment } @Override + @TargetApi(21) protected View createView(LayoutInflater inflater, ViewGroup container) { - tvshowId = getArguments().getInt(TVSHOWID, -1); + Bundle bundle = getArguments(); + tvshowId = bundle.getInt(TVShowDetailsFragment.BUNDLE_KEY_TVSHOWID, -1); if (tvshowId == -1) { // There's nothing to show @@ -130,6 +134,18 @@ public class TVShowOverviewFragment extends AbstractDetailsFragment } }); + tvshowTitle = bundle.getString(TVShowDetailsFragment.BUNDLE_KEY_TITLE); + + mediaTitle.setText(tvshowTitle); + setMediaUndertitle(bundle.getInt(TVShowDetailsFragment.BUNDLE_KEY_EPISODE), bundle.getInt(TVShowDetailsFragment.BUNDLE_KEY_WATCHEDEPISODES)); + setMediaPremiered(bundle.getString(TVShowDetailsFragment.BUNDLE_KEY_PREMIERED), bundle.getString(TVShowDetailsFragment.BUNDLE_KEY_STUDIO)); + mediaGenres.setText(bundle.getString(TVShowDetailsFragment.BUNDLE_KEY_GENRES)); + setMediaRating(bundle.getDouble(TVShowDetailsFragment.BUNDLE_KEY_RATING)); + mediaDescription.setText(bundle.getString(TVShowDetailsFragment.BUNDLE_KEY_PLOT)); + + if(Utils.isLollipopOrLater()) { + mediaPoster.setTransitionName(getArguments().getString(TVShowDetailsFragment.POSTER_TRANS_NAME)); + } // Pad main content view to overlap with bottom system bar // UIUtils.setPaddingForSystemBars(getActivity(), mediaPanel, false, false, true); // mediaPanel.setClipToPadding(false); @@ -244,26 +260,13 @@ public class TVShowOverviewFragment extends AbstractDetailsFragment mediaTitle.setText(tvshowTitle); int numEpisodes = cursor.getInt(TVShowDetailsQuery.EPISODE), watchedEpisodes = cursor.getInt(TVShowDetailsQuery.WATCHEDEPISODES); - String episodes = String.format(getString(R.string.num_episodes), - numEpisodes, numEpisodes - watchedEpisodes); - mediaUndertitle.setText(episodes); + setMediaUndertitle(numEpisodes, watchedEpisodes); + + setMediaPremiered(cursor.getString(TVShowDetailsQuery.PREMIERED), cursor.getString(TVShowDetailsQuery.STUDIO)); - String premiered = String.format(getString(R.string.premiered), - cursor.getString(TVShowDetailsQuery.PREMIERED)) + - " | " + cursor.getString(TVShowDetailsQuery.STUDIO); - mediaPremiered.setText(premiered); mediaGenres.setText(cursor.getString(TVShowDetailsQuery.GENRES)); - double rating = cursor.getDouble(TVShowDetailsQuery.RATING); - if (rating > 0) { - mediaRating.setVisibility(View.VISIBLE); - mediaMaxRating.setVisibility(View.VISIBLE); - mediaRating.setText(String.format("%01.01f", rating)); - mediaMaxRating.setText(getString(R.string.max_rating_video)); - } else { - mediaRating.setVisibility(View.INVISIBLE); - mediaMaxRating.setVisibility(View.INVISIBLE); - } + setMediaRating(cursor.getDouble(TVShowDetailsQuery.RATING)); mediaDescription.setText(cursor.getString(TVShowDetailsQuery.PLOT)); @@ -286,6 +289,28 @@ public class TVShowOverviewFragment extends AbstractDetailsFragment mediaArt, displayMetrics.widthPixels, artHeight); } + private void setMediaUndertitle(int numEpisodes, int watchedEpisodes) { + String episodes = String.format(getString(R.string.num_episodes), + numEpisodes, numEpisodes - watchedEpisodes); + mediaUndertitle.setText(episodes); + } + + private void setMediaPremiered(String premiered, String studio) { + mediaPremiered.setText(String.format(getString(R.string.premiered), + premiered) + " | " + studio); + } + + private void setMediaRating(double rating) { + if (rating > 0) { + mediaRating.setVisibility(View.VISIBLE); + mediaMaxRating.setVisibility(View.VISIBLE); + mediaRating.setText(String.format("%01.01f", rating)); + mediaMaxRating.setText(getString(R.string.max_rating_video)); + } else { + mediaRating.setVisibility(View.INVISIBLE); + mediaMaxRating.setVisibility(View.INVISIBLE); + } + } /** * Display the cast details * diff --git a/app/src/main/java/org/xbmc/kore/ui/TVShowsActivity.java b/app/src/main/java/org/xbmc/kore/ui/TVShowsActivity.java index c2f3ee1..e444b98 100644 --- a/app/src/main/java/org/xbmc/kore/ui/TVShowsActivity.java +++ b/app/src/main/java/org/xbmc/kore/ui/TVShowsActivity.java @@ -22,15 +22,20 @@ import android.support.v4.app.FragmentTransaction; import android.support.v4.widget.DrawerLayout; import android.support.v7.app.ActionBar; import android.support.v7.widget.Toolbar; +import android.transition.Transition; import android.transition.TransitionInflater; import android.view.Menu; import android.view.MenuItem; +import android.view.View; import android.view.Window; import org.xbmc.kore.R; import org.xbmc.kore.utils.LogUtils; import org.xbmc.kore.utils.Utils; +import java.util.List; +import java.util.Map; + /** * Controls the presentation of TV Shows information (list, details) * All the information is presented by specific fragments @@ -48,6 +53,9 @@ public class TVShowsActivity extends BaseActivity private String selectedTVShowTitle = null; private int selectedEpisodeId = -1; + private TVShowDetailsFragment tvshowDetailsFragment; + private boolean clearSharedElements; + private NavigationDrawerFragment navigationDrawerFragment; @TargetApi(21) @@ -70,10 +78,27 @@ public class TVShowsActivity extends BaseActivity // Setup animations if (Utils.isLollipopOrLater()) { - tvshowListFragment.setExitTransition(null); - tvshowListFragment.setReenterTransition(TransitionInflater + //Fade added to prevent shared element from disappearing very shortly at the start of the transition. + Transition fade = TransitionInflater .from(this) - .inflateTransition(android.R.transition.fade)); + .inflateTransition(android.R.transition.fade); + tvshowListFragment.setExitTransition(fade); + tvshowListFragment.setReenterTransition(fade); + + tvshowListFragment.setSharedElementReturnTransition(TransitionInflater.from( + this).inflateTransition(R.transition.change_image)); + + android.support.v4.app.SharedElementCallback seCallback = new android.support.v4.app.SharedElementCallback() { + @Override + public void onMapSharedElements(List names, Map sharedElements) { + if (clearSharedElements) { + names.clear(); + sharedElements.clear(); + clearSharedElements = false; + } + } + }; + tvshowListFragment.setExitSharedElementCallback(seCallback); } getSupportFragmentManager() .beginTransaction() @@ -93,16 +118,6 @@ public class TVShowsActivity extends BaseActivity // UIUtils.setPaddingForSystemBars(this, findViewById(R.id.drawer_layout), true, true, true); } - @Override - public void onResume() { - super.onResume(); - } - - @Override - public void onPause() { - super.onPause(); - } - @Override protected void onSaveInstanceState (Bundle outState) { super.onSaveInstanceState(outState); @@ -192,24 +207,46 @@ public class TVShowsActivity extends BaseActivity /** * Callback from tvshows list fragment when a show is selected. * Switch fragment in portrait - * @param tvshowId Selected show - * @param tvshowTitle Title + * @param vh */ @TargetApi(21) - public void onTVShowSelected(int tvshowId, String tvshowTitle) { - selectedTVShowId = tvshowId; - selectedTVShowTitle = tvshowTitle; + public void onTVShowSelected(TVShowListFragment.ViewHolder vh) { + selectedTVShowId = vh.tvshowId; + selectedTVShowTitle = vh.tvshowTitle; // Replace list fragment - TVShowDetailsFragment tvshowDetailsFragment = TVShowDetailsFragment.newInstance(tvshowId); + tvshowDetailsFragment = TVShowDetailsFragment.newInstance(vh); FragmentTransaction fragTrans = getSupportFragmentManager().beginTransaction(); // Set up transitions if (Utils.isLollipopOrLater()) { + android.support.v4.app.SharedElementCallback seCallback = new android.support.v4.app.SharedElementCallback() { + @Override + public void onMapSharedElements(List names, Map sharedElements) { + //On returning onMapSharedElements for the exiting fragment is called before the onMapSharedElements + // for the reentering fragment. We use this to determine if we are returning and if + // we should clear the shared element lists. Note that, clearing must be done in the reentering fragment + // as this is called last. Otherwise it the app will crash during transition setup. Not sure, but might + // be a v4 support package bug. + if (tvshowDetailsFragment.isVisible()) { + View sharedView = tvshowDetailsFragment.getSharedElement(); + if (sharedView == null) { // shared element not visible + clearSharedElements = true; + } + } + } + }; + tvshowDetailsFragment.setEnterSharedElementCallback(seCallback); + tvshowDetailsFragment.setEnterTransition(TransitionInflater .from(this) .inflateTransition(R.transition.media_details)); tvshowDetailsFragment.setReturnTransition(null); + Transition changeImageTransition = TransitionInflater.from( + this).inflateTransition(R.transition.change_image); + tvshowDetailsFragment.setSharedElementReturnTransition(changeImageTransition); + tvshowDetailsFragment.setSharedElementEnterTransition(changeImageTransition); + fragTrans.addSharedElement(vh.artView, vh.artView.getTransitionName()); } else { fragTrans.setCustomAnimations(R.anim.fragment_details_enter, 0, R.anim.fragment_list_popenter, 0); diff --git a/app/src/main/java/org/xbmc/kore/utils/UIUtils.java b/app/src/main/java/org/xbmc/kore/utils/UIUtils.java index cdc1df2..b28160a 100644 --- a/app/src/main/java/org/xbmc/kore/utils/UIUtils.java +++ b/app/src/main/java/org/xbmc/kore/utils/UIUtils.java @@ -22,6 +22,7 @@ import android.content.Context; import android.content.Intent; import android.content.res.Resources; import android.content.res.TypedArray; +import android.graphics.Rect; import android.os.Vibrator; import android.preference.PreferenceManager; import android.support.annotation.NonNull; @@ -462,4 +463,13 @@ public class UIUtils { } }); } + + /** + * Returns true if {@param view} is contained within {@param container}'s bounds. + */ + public static boolean isViewInBounds(@NonNull View container, @NonNull View view) { + Rect containerBounds = new Rect(); + container.getHitRect(containerBounds); + return view.getLocalVisibleRect(containerBounds); + } } diff --git a/app/src/main/res/transition-v21/media_details.xml b/app/src/main/res/transition-v21/media_details.xml index f7d8877..a6cb737 100644 --- a/app/src/main/res/transition-v21/media_details.xml +++ b/app/src/main/res/transition-v21/media_details.xml @@ -18,6 +18,7 @@ +