From 69b58c7646884d197a844081bd9bcfdc59bbb6fe Mon Sep 17 00:00:00 2001
From: Peter Serwylo <peter@serwylo.com>
Date: Thu, 24 Nov 2016 10:56:15 +1100
Subject: [PATCH] Whats New: Added list of recently updated apps to the main
 view.

Smooshes the recently updated and recently added lists into one,
and adds a status line under each app saying which of the two it
is (i.e. "Recnelty Updated" or "Whats New".
---
 .../main/java/org/fdroid/fdroid/Utils.java    |  21 ++
 .../org/fdroid/fdroid/data/AppProvider.java   |  25 +--
 .../views/categories/AppCardController.java   | 179 ++++++++++++++++++
 .../fragments/AvailableAppsFragment.java      |   4 +-
 .../fdroid/views/main/MainViewController.java |   4 +
 .../fdroid/views/main/WhatsNewViewBinder.java |  96 ++++++++++
 .../views/whatsnew/WhatsNewAdapter.java       | 129 +++++++++++++
 app/src/main/res/layout/app_card_featured.xml |  86 +++++++++
 .../main/res/layout/app_card_horizontal.xml   |  52 +++++
 app/src/main/res/layout/app_card_large.xml    |  58 ++++++
 .../main/res/layout/app_card_list_item.xml    |  55 ++++++
 .../main/res/layout/main_tab_whats_new.xml    |  19 ++
 app/src/main/res/values/dimens.xml            |   2 +
 app/src/main/res/values/ids.xml               |   5 +
 14 files changed, 711 insertions(+), 24 deletions(-)
 create mode 100644 app/src/main/java/org/fdroid/fdroid/views/categories/AppCardController.java
 create mode 100644 app/src/main/java/org/fdroid/fdroid/views/main/WhatsNewViewBinder.java
 create mode 100644 app/src/main/java/org/fdroid/fdroid/views/whatsnew/WhatsNewAdapter.java
 create mode 100644 app/src/main/res/layout/app_card_featured.xml
 create mode 100644 app/src/main/res/layout/app_card_horizontal.xml
 create mode 100644 app/src/main/res/layout/app_card_large.xml
 create mode 100644 app/src/main/res/layout/app_card_list_item.xml
 create mode 100644 app/src/main/res/layout/main_tab_whats_new.xml

diff --git a/app/src/main/java/org/fdroid/fdroid/Utils.java b/app/src/main/java/org/fdroid/fdroid/Utils.java
index 08a39be78..b26768c86 100644
--- a/app/src/main/java/org/fdroid/fdroid/Utils.java
+++ b/app/src/main/java/org/fdroid/fdroid/Utils.java
@@ -29,7 +29,11 @@ import android.support.annotation.Nullable;
 import android.support.annotation.RequiresApi;
 import android.text.Editable;
 import android.text.Html;
+import android.text.SpannableStringBuilder;
+import android.text.Spanned;
 import android.text.TextUtils;
+import android.text.style.CharacterStyle;
+import android.text.style.TypefaceSpan;
 import android.util.DisplayMetrics;
 import android.util.Log;
 import android.util.TypedValue;
@@ -487,6 +491,23 @@ public final class Utils {
         return formatDateFormat(TIME_FORMAT, date, fallback);
     }
 
+    /**
+     * Formats the app name using "sans-serif" and then appends the summary after a space with
+     * "sans-serif-light". Doesn't mandate any font sizes or any other styles, that is up to the
+     * {@link android.widget.TextView} which it ends up being displayed in.
+     */
+    public static CharSequence formatAppNameAndSummary(String appName, String summary) {
+        String toFormat = appName + ' ' + summary;
+        CharacterStyle normal = new TypefaceSpan("sans-serif");
+        CharacterStyle light = new TypefaceSpan("sans-serif-light");
+
+        SpannableStringBuilder sb = new SpannableStringBuilder(toFormat);
+        sb.setSpan(normal, 0, appName.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
+        sb.setSpan(light, appName.length(), toFormat.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
+
+        return sb;
+    }
+
     // Need this to add the unimplemented support for ordered and unordered
     // lists to Html.fromHtml().
     public static class HtmlTagHandler implements Html.TagHandler {
diff --git a/app/src/main/java/org/fdroid/fdroid/data/AppProvider.java b/app/src/main/java/org/fdroid/fdroid/data/AppProvider.java
index 5ba551ecb..122885612 100644
--- a/app/src/main/java/org/fdroid/fdroid/data/AppProvider.java
+++ b/app/src/main/java/org/fdroid/fdroid/data/AppProvider.java
@@ -373,7 +373,6 @@ public class AppProvider extends FDroidProvider {
     protected static final String PATH_APPS = "apps";
     protected static final String PATH_SPECIFIC_APP = "app";
     private static final String PATH_RECENTLY_UPDATED = "recentlyUpdated";
-    private static final String PATH_NEWLY_ADDED = "newlyAdded";
     private static final String PATH_CATEGORY = "category";
     private static final String PATH_REPO = "repo";
     private static final String PATH_HIGHEST_PRIORITY = "highestPriority";
@@ -386,8 +385,7 @@ public class AppProvider extends FDroidProvider {
     private static final int SEARCH_TEXT = INSTALLED + 1;
     private static final int SEARCH_TEXT_AND_CATEGORIES = SEARCH_TEXT + 1;
     private static final int RECENTLY_UPDATED = SEARCH_TEXT_AND_CATEGORIES + 1;
-    private static final int NEWLY_ADDED = RECENTLY_UPDATED + 1;
-    private static final int CATEGORY = NEWLY_ADDED + 1;
+    private static final int CATEGORY = RECENTLY_UPDATED + 1;
     private static final int CALC_SUGGESTED_APKS = CATEGORY + 1;
     private static final int REPO = CALC_SUGGESTED_APKS + 1;
     private static final int SEARCH_REPO = REPO + 1;
@@ -401,7 +399,6 @@ public class AppProvider extends FDroidProvider {
         MATCHER.addURI(getAuthority(), null, CODE_LIST);
         MATCHER.addURI(getAuthority(), PATH_CALC_SUGGESTED_APKS, CALC_SUGGESTED_APKS);
         MATCHER.addURI(getAuthority(), PATH_RECENTLY_UPDATED, RECENTLY_UPDATED);
-        MATCHER.addURI(getAuthority(), PATH_NEWLY_ADDED, NEWLY_ADDED);
         MATCHER.addURI(getAuthority(), PATH_CATEGORY + "/*", CATEGORY);
         MATCHER.addURI(getAuthority(), PATH_SEARCH + "/*/*", SEARCH_TEXT_AND_CATEGORIES);
         MATCHER.addURI(getAuthority(), PATH_SEARCH + "/*", SEARCH_TEXT);
@@ -425,10 +422,6 @@ public class AppProvider extends FDroidProvider {
         return Uri.withAppendedPath(getContentUri(), PATH_RECENTLY_UPDATED);
     }
 
-    public static Uri getNewlyAddedUri() {
-        return Uri.withAppendedPath(getContentUri(), PATH_NEWLY_ADDED);
-    }
-
     private static Uri calcSuggestedApksUri() {
         return Uri.withAppendedPath(getContentUri(), PATH_CALC_SUGGESTED_APKS);
     }
@@ -666,12 +659,6 @@ public class AppProvider extends FDroidProvider {
         return new AppQuerySelection(selection);
     }
 
-    private AppQuerySelection queryNewlyAdded() {
-        final String selection = getTableName() + "." + Cols.ADDED + " > ?";
-        final String[] args = {Utils.formatDate(Preferences.get().calcMaxHistory(), "")};
-        return new AppQuerySelection(selection, args);
-    }
-
     /**
      * Ensures that for each app metadata row with the same package name, only the one from the repo
      * with the best priority is represented in the result set. While possible to calculate this
@@ -689,9 +676,7 @@ public class AppProvider extends FDroidProvider {
     }
 
     private AppQuerySelection queryRecentlyUpdated() {
-        final String app = getTableName();
-        final String lastUpdated = app + "." + Cols.LAST_UPDATED;
-        final String selection = app + "." + Cols.ADDED + " != " + lastUpdated + " AND " + lastUpdated + " > ?";
+        final String selection = getTableName() + "." + Cols.LAST_UPDATED + " > ? ";
         final String[] args = {Utils.formatDate(Preferences.get().calcMaxHistory(), "")};
         return new AppQuerySelection(selection, args);
     }
@@ -810,12 +795,6 @@ public class AppProvider extends FDroidProvider {
                 includeSwap = false;
                 break;
 
-            case NEWLY_ADDED:
-                sortOrder = getTableName() + "." + Cols.ADDED + " DESC";
-                selection = selection.add(queryNewlyAdded());
-                includeSwap = false;
-                break;
-
             case HIGHEST_PRIORITY:
                 selection = selection.add(queryPackageName(uri.getLastPathSegment()));
                 includeSwap = false;
diff --git a/app/src/main/java/org/fdroid/fdroid/views/categories/AppCardController.java b/app/src/main/java/org/fdroid/fdroid/views/categories/AppCardController.java
new file mode 100644
index 000000000..4c1d54008
--- /dev/null
+++ b/app/src/main/java/org/fdroid/fdroid/views/categories/AppCardController.java
@@ -0,0 +1,179 @@
+package org.fdroid.fdroid.views.categories;
+
+import android.app.Activity;
+import android.content.Intent;
+import android.graphics.Bitmap;
+import android.os.Build;
+import android.os.Bundle;
+import android.support.annotation.IdRes;
+import android.support.annotation.NonNull;
+import android.support.annotation.Nullable;
+import android.support.v4.app.ActivityOptionsCompat;
+import android.support.v4.util.Pair;
+import android.support.v7.graphics.Palette;
+import android.support.v7.widget.RecyclerView;
+import android.view.View;
+import android.widget.ImageView;
+import android.widget.TextView;
+
+import com.nostra13.universalimageloader.core.DisplayImageOptions;
+import com.nostra13.universalimageloader.core.ImageLoader;
+import com.nostra13.universalimageloader.core.assist.FailReason;
+import com.nostra13.universalimageloader.core.listener.ImageLoadingListener;
+
+import org.fdroid.fdroid.AppDetails;
+import org.fdroid.fdroid.Preferences;
+import org.fdroid.fdroid.R;
+import org.fdroid.fdroid.Utils;
+import org.fdroid.fdroid.data.App;
+
+import java.util.Date;
+
+/**
+ * The {@link AppCardController} can bind an app to several different layouts, as long as the layout
+ * contains the following elements:
+ *  + {@link R.id#icon} ({@link ImageView}, required)
+ *  + {@link R.id#summary} ({@link TextView}, required)
+ *  + {@link R.id#featured_image} ({@link ImageView}, optional)
+ *  + {@link R.id#status} ({@link TextView}, optional)
+ */
+public class AppCardController extends RecyclerView.ViewHolder implements ImageLoadingListener, View.OnClickListener {
+    @NonNull
+    private final ImageView icon;
+
+    @NonNull
+    private final TextView summary;
+
+    @Nullable
+    private final TextView status;
+
+    @Nullable
+    private final ImageView featuredImage;
+
+    @Nullable
+    private App currentApp;
+
+    private final Activity activity;
+    private final int defaultFeaturedImageColour;
+    private final DisplayImageOptions displayImageOptions;
+
+    private final Date recentCuttoffDate;
+
+    public AppCardController(Activity activity, View itemView) {
+        super(itemView);
+
+        this.activity = activity;
+
+        recentCuttoffDate = Preferences.get().calcMaxHistory();
+
+        icon = (ImageView) findViewAndEnsureNonNull(itemView, R.id.icon);
+        summary = (TextView) findViewAndEnsureNonNull(itemView, R.id.summary);
+
+        featuredImage = (ImageView) itemView.findViewById(R.id.featured_image);
+        status = (TextView) itemView.findViewById(R.id.status);
+
+        defaultFeaturedImageColour = activity.getResources().getColor(R.color.cardview_light_background);
+        displayImageOptions = Utils.getImageLoadingOptions().build();
+
+        itemView.setOnClickListener(this);
+    }
+
+    /**
+     * The contract that this controller has is that it will consume any layout resource, given
+     * it has some specific view types (with specific IDs) available. This helper function will
+     * throw an {@link IllegalArgumentException} if the view doesn't exist,
+     */
+    @NonNull
+    private View findViewAndEnsureNonNull(View view, @IdRes int res) {
+        View found = view.findViewById(res);
+        if (found == null) {
+            String resName = activity.getResources().getResourceName(res);
+            throw new IllegalArgumentException("Layout for AppCardController requires " + resName);
+        }
+
+        return found;
+    }
+
+    public void bindApp(@NonNull App app) {
+        currentApp = app;
+
+        summary.setText(Utils.formatAppNameAndSummary(app.name, app.summary));
+
+        if (status != null) {
+            if (app.added != null && app.added.after(recentCuttoffDate) && (app.lastUpdated == null || app.added.equals(app.lastUpdated))) {
+                status.setText(activity.getString(R.string.category_Whats_New));
+                status.setVisibility(View.VISIBLE);
+            } else if (app.lastUpdated != null && app.lastUpdated.after(recentCuttoffDate)) {
+                status.setText(activity.getString(R.string.category_Recently_Updated));
+                status.setVisibility(View.VISIBLE);
+            } else {
+                status.setVisibility(View.GONE);
+            }
+        }
+
+        if (featuredImage != null) {
+            featuredImage.setBackgroundColor(defaultFeaturedImageColour);
+        }
+
+        ImageLoader.getInstance().displayImage(app.iconUrl, icon, displayImageOptions, this);
+    }
+
+    /**
+     * When the user clicks/touches an app card, we launch the {@link AppDetails} activity in response.
+     */
+    @Override
+    public void onClick(View v) {
+        if (currentApp == null) {
+            return;
+        }
+
+        Intent intent = new Intent(activity, AppDetails.class);
+        intent.putExtra(AppDetails.EXTRA_APPID, currentApp.packageName);
+        if (Build.VERSION.SDK_INT >= 21) {
+            Pair<View, String> iconTransitionPair = Pair.create((View) icon, activity.getString(R.string.transition_app_item_icon));
+
+            @SuppressWarnings("unchecked") // We are passing the right type as the second varargs argument (i.e. a Pair<View, String>).
+            Bundle bundle = ActivityOptionsCompat.makeSceneTransitionAnimation(activity, iconTransitionPair).toBundle();
+
+            activity.startActivity(intent, bundle);
+        } else {
+            activity.startActivity(intent);
+        }
+    }
+
+    // =============================================================================================
+    //  Icon loader callbacks
+    //
+    //  Most are unused, the main goal is to specify a background colour for the featured image if
+    //  no featured image is specified in the apps metadata.
+    // =============================================================================================
+
+    @Override
+    public void onLoadingComplete(String imageUri, View view, Bitmap loadedImage) {
+        final ImageView image = featuredImage;
+        if (image != null) {
+            new Palette.Builder(loadedImage).generate(new Palette.PaletteAsyncListener() {
+                @Override
+                public void onGenerated(Palette palette) {
+                    image.setBackgroundColor(palette.getDominantColor(defaultFeaturedImageColour));
+                }
+            });
+        }
+    }
+
+    @Override
+    public void onLoadingStarted(String imageUri, View view) {
+        // Do nothing
+    }
+
+    @Override
+    public void onLoadingCancelled(String imageUri, View view) {
+        // Do nothing
+    }
+
+    @Override
+    public void onLoadingFailed(String imageUri, View view, FailReason failReason) {
+        // Do nothing
+    }
+
+}
diff --git a/app/src/main/java/org/fdroid/fdroid/views/fragments/AvailableAppsFragment.java b/app/src/main/java/org/fdroid/fdroid/views/fragments/AvailableAppsFragment.java
index 8cd21d398..ce495b2e8 100644
--- a/app/src/main/java/org/fdroid/fdroid/views/fragments/AvailableAppsFragment.java
+++ b/app/src/main/java/org/fdroid/fdroid/views/fragments/AvailableAppsFragment.java
@@ -178,7 +178,9 @@ public class AvailableAppsFragment extends AppListFragment implements
             return AppProvider.getRecentlyUpdatedUri();
         }
         if (currentCategory.equals(CategoryProvider.Helper.getCategoryWhatsNew(getActivity()))) {
-            return AppProvider.getNewlyAddedUri();
+            // Removed this feature in the new UI. this fragment will be gone soon so not implementing it again.
+            // return AppProvider.getNewlyAddedUri();
+            return AppProvider.getRecentlyUpdatedUri();
         }
         return AppProvider.getCategoryUri(currentCategory);
     }
diff --git a/app/src/main/java/org/fdroid/fdroid/views/main/MainViewController.java b/app/src/main/java/org/fdroid/fdroid/views/main/MainViewController.java
index 690dfce6c..8d894584c 100644
--- a/app/src/main/java/org/fdroid/fdroid/views/main/MainViewController.java
+++ b/app/src/main/java/org/fdroid/fdroid/views/main/MainViewController.java
@@ -31,7 +31,11 @@ class MainViewController extends RecyclerView.ViewHolder {
         frame.removeAllViews();
     }
 
+    /**
+     * @see WhatsNewViewBinder
+     */
     public void bindWhatsNewView() {
+        new WhatsNewViewBinder(activity, frame);
     }
 
     public void bindMyApps() {
diff --git a/app/src/main/java/org/fdroid/fdroid/views/main/WhatsNewViewBinder.java b/app/src/main/java/org/fdroid/fdroid/views/main/WhatsNewViewBinder.java
new file mode 100644
index 000000000..b1860c112
--- /dev/null
+++ b/app/src/main/java/org/fdroid/fdroid/views/main/WhatsNewViewBinder.java
@@ -0,0 +1,96 @@
+package org.fdroid.fdroid.views.main;
+
+import android.database.Cursor;
+import android.os.Bundle;
+import android.support.v4.app.LoaderManager;
+import android.support.v4.content.CursorLoader;
+import android.support.v4.content.Loader;
+import android.support.v7.app.AppCompatActivity;
+import android.support.v7.widget.GridLayoutManager;
+import android.support.v7.widget.RecyclerView;
+import android.view.View;
+import android.widget.FrameLayout;
+
+import org.fdroid.fdroid.R;
+import org.fdroid.fdroid.data.AppProvider;
+import org.fdroid.fdroid.data.Schema;
+import org.fdroid.fdroid.views.whatsnew.WhatsNewAdapter;
+
+/**
+ * Loads a list of newly added or recently updated apps and displays them to the user.
+ */
+class WhatsNewViewBinder implements LoaderManager.LoaderCallbacks<Cursor> {
+
+    private static final int LOADER_ID = 978015789;
+
+    private final WhatsNewAdapter whatsNewAdapter;
+    private final AppCompatActivity activity;
+
+    private static RecyclerView.ItemDecoration appListDecorator;
+
+    WhatsNewViewBinder(AppCompatActivity activity, FrameLayout parent) {
+        this.activity = activity;
+
+        View whatsNewView = activity.getLayoutInflater().inflate(R.layout.main_tab_whats_new, parent, true);
+
+        whatsNewAdapter = new WhatsNewAdapter(activity);
+
+        GridLayoutManager layoutManager = new GridLayoutManager(activity, 2);
+        layoutManager.setSpanSizeLookup(new WhatsNewAdapter.SpanSizeLookup());
+
+        RecyclerView appList = (RecyclerView) whatsNewView.findViewById(R.id.app_list);
+        appList.setHasFixedSize(true);
+        appList.setLayoutManager(layoutManager);
+        appList.setAdapter(whatsNewAdapter);
+
+        // This is a bit hacky, but for some reason even though we are inflating the main_tab_whats_new
+        // layout above, the app_list RecyclerView seems to remember that it has decorations from before.
+        // If we blindly call addItemDecoration here without first removing the existing one, it will
+        // double up on all of the paddings the second time we view it. The third time it will triple up
+        // on the paddings, etc. In addition, the API doesn't allow us to "clearAllDecorators()". Instead
+        // we need to hold onto the reference to the one we added in order to remove it.
+        if (appListDecorator == null) {
+            appListDecorator = new WhatsNewAdapter.ItemDecorator(activity);
+        } else {
+            appList.removeItemDecoration(appListDecorator);
+        }
+
+        appList.addItemDecoration(appListDecorator);
+
+        activity.getSupportLoaderManager().initLoader(LOADER_ID, null, this);
+    }
+
+    @Override
+    public Loader<Cursor> onCreateLoader(int id, Bundle args) {
+        if (id != LOADER_ID) {
+            return null;
+        }
+
+        return new CursorLoader(
+                activity,
+                AppProvider.getRecentlyUpdatedUri(),
+                Schema.AppMetadataTable.Cols.ALL,
+                null,
+                null,
+                null
+        );
+    }
+
+    @Override
+    public void onLoadFinished(Loader<Cursor> loader, Cursor cursor) {
+        if (loader.getId() != LOADER_ID) {
+            return;
+        }
+
+        whatsNewAdapter.setAppsCursor(cursor);
+    }
+
+    @Override
+    public void onLoaderReset(Loader<Cursor> loader) {
+        if (loader.getId() != LOADER_ID) {
+            return;
+        }
+
+        whatsNewAdapter.setAppsCursor(null);
+    }
+}
diff --git a/app/src/main/java/org/fdroid/fdroid/views/whatsnew/WhatsNewAdapter.java b/app/src/main/java/org/fdroid/fdroid/views/whatsnew/WhatsNewAdapter.java
new file mode 100644
index 000000000..447bd1f00
--- /dev/null
+++ b/app/src/main/java/org/fdroid/fdroid/views/whatsnew/WhatsNewAdapter.java
@@ -0,0 +1,129 @@
+package org.fdroid.fdroid.views.whatsnew;
+
+import android.app.Activity;
+import android.content.Context;
+import android.database.Cursor;
+import android.graphics.Rect;
+import android.support.v4.view.ViewCompat;
+import android.support.v7.widget.GridLayoutManager;
+import android.support.v7.widget.RecyclerView;
+import android.view.View;
+import android.view.ViewGroup;
+
+import org.fdroid.fdroid.R;
+import org.fdroid.fdroid.data.App;
+import org.fdroid.fdroid.views.categories.AppCardController;
+
+public class WhatsNewAdapter extends RecyclerView.Adapter<AppCardController> {
+
+    private Cursor cursor;
+    private final Activity activity;
+
+    public WhatsNewAdapter(Activity activity) {
+        this.activity = activity;
+    }
+
+    @Override
+    public AppCardController onCreateViewHolder(ViewGroup parent, int viewType) {
+        int layout;
+        if (viewType == R.id.whats_new_feature) {
+            layout = R.layout.app_card_featured;
+        } else if (viewType == R.id.whats_new_large_tile) {
+            layout = R.layout.app_card_large;
+        } else if (viewType == R.id.whats_new_small_tile) {
+            layout = R.layout.app_card_horizontal;
+        } else if (viewType == R.id.whats_new_regular_list) {
+            layout = R.layout.app_card_list_item;
+        } else {
+            throw new IllegalArgumentException("Unknown view type when rendering \"Whats New\": " + viewType);
+        }
+
+        return new AppCardController(activity, activity.getLayoutInflater().inflate(layout, parent, false));
+
+    }
+
+    @Override
+    public int getItemViewType(int position) {
+        if (position == 0) {
+            return R.id.whats_new_feature;
+        } else if (position <= 2) {
+            return R.id.whats_new_large_tile;
+        } else if (position <= 4) {
+            return R.id.whats_new_small_tile;
+        } else {
+            return R.id.whats_new_regular_list;
+        }
+    }
+
+    @Override
+    public void onBindViewHolder(AppCardController holder, int position) {
+        cursor.moveToPosition(position);
+        holder.bindApp(new App(cursor));
+    }
+
+    @Override
+    public int getItemCount() {
+        return cursor == null ? 0 : cursor.getCount();
+    }
+
+    public void setAppsCursor(Cursor cursor) {
+        this.cursor = cursor;
+        notifyDataSetChanged();
+    }
+
+    // TODO: Replace with https://github.com/lucasr/twoway-view which looks really really cool, but
+    //       no longer under active development (despite heaps of forks/stars on github).
+    public static class SpanSizeLookup extends GridLayoutManager.SpanSizeLookup {
+        @Override
+        public int getSpanSize(int position) {
+            if (position == 0) {
+                return 2;
+            } else if (position <= 4) {
+                return 1;
+            } else {
+                return 2;
+            }
+        }
+    }
+
+    /**
+     * Applies padding to items, ensuring that the spacing on the left, centre, and right all match.
+     * The vertical padding is slightly shorter than the horizontal padding also.
+     * @see org.fdroid.fdroid.R.dimen#whats_new__padding__app_card__horizontal
+     * @see org.fdroid.fdroid.R.dimen#whats_new__padding__app_card__vertical
+     */
+    public static class ItemDecorator extends RecyclerView.ItemDecoration {
+        private final Context context;
+
+        public ItemDecorator(Context context) {
+            this.context = context.getApplicationContext();
+        }
+
+        @Override
+        public void getItemOffsets(Rect outRect, View view, RecyclerView parent, RecyclerView.State state) {
+            int position = parent.getChildAdapterPosition(view);
+            int horizontalPadding = (int) context.getResources().getDimension(R.dimen.whats_new__padding__app_card__horizontal);
+            int verticalPadding = (int) context.getResources().getDimension(R.dimen.whats_new__padding__app_card__vertical);
+
+            if (position == 0) {
+                // Don't set any padding for the first item as the FeatureImage behind it needs to butt right
+                // up against the left/top/right of the screen.
+                outRect.set(0, 0, 0, verticalPadding);
+            } else if (position <= 4) {
+                // Odd items are on the left, even on the right.
+                // The item on the left will have both left and right padding. The item on the right
+                // will only have padding on the right. This will allow the same amount of padding
+                // on the left, centre, and right of the grid, rather than double the padding in the
+                // middle (which would happen if both left+right paddings were set for both items).
+                boolean isLtr = ViewCompat.getLayoutDirection(parent) == ViewCompat.LAYOUT_DIRECTION_LTR;
+                boolean isAtStart = (position % 2) == 1;
+                int paddingStart = isAtStart ? horizontalPadding : 0;
+                int paddingLeft = isLtr ? paddingStart : horizontalPadding;
+                int paddingRight = isLtr ? horizontalPadding : paddingStart;
+                outRect.set(paddingLeft, 0, paddingRight, verticalPadding);
+            } else {
+                outRect.set(horizontalPadding, 0, horizontalPadding, verticalPadding);
+            }
+        }
+    }
+}
diff --git a/app/src/main/res/layout/app_card_featured.xml b/app/src/main/res/layout/app_card_featured.xml
new file mode 100644
index 000000000..9f1da484d
--- /dev/null
+++ b/app/src/main/res/layout/app_card_featured.xml
@@ -0,0 +1,86 @@
+<?xml version="1.0" encoding="utf-8"?>
+<android.support.constraint.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:paddingBottom="2dp"
+    android:clipToPadding="false">
+
+    <ImageView
+        android:id="@+id/featured_image"
+        android:layout_width="0dp"
+        android:layout_height="120dp"
+        tools:src="@color/fdroid_green"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintEnd_toEndOf="parent"
+        app:layout_constraintTop_toTopOf="parent" />
+
+    <android.support.constraint.Guideline
+        android:id="@+id/header_height"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:orientation="horizontal"
+        app:layout_constraintGuide_begin="80dp" />
+
+    <android.support.v7.widget.CardView
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:layout_marginLeft="@dimen/whats_new__padding__app_card__horizontal"
+        android:layout_marginStart="@dimen/whats_new__padding__app_card__horizontal"
+        android:layout_marginRight="@dimen/whats_new__padding__app_card__horizontal"
+        android:layout_marginEnd="@dimen/whats_new__padding__app_card__horizontal"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintEnd_toEndOf="parent"
+        app:layout_constraintTop_toTopOf="@+id/header_height"
+        >
+
+        <android.support.constraint.ConstraintLayout
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:layout_margin="12dp">
+
+            <ImageView
+                android:id="@+id/icon"
+                android:contentDescription="@string/app_icon"
+                android:layout_width="48dp"
+                android:layout_height="48dp"
+                tools:src="@drawable/ic_launcher"
+                android:scaleType="fitCenter"
+                app:layout_constraintStart_toStartOf="parent"
+                app:layout_constraintTop_toTopOf="parent" />
+
+            <TextView
+                tools:text="F-Droid An application summary which takes up too much space and must ellipsize, perhaps after wrapping to a new line"
+                android:layout_width="0dp"
+                android:layout_height="wrap_content"
+                android:id="@+id/summary"
+                android:lines="2"
+                android:textSize="14sp"
+                android:ellipsize="end"
+                android:layout_marginLeft="16dp"
+                android:layout_marginStart="16dp"
+                app:layout_constraintStart_toEndOf="@+id/icon"
+                app:layout_constraintEnd_toEndOf="parent"
+                app:layout_constraintTop_toTopOf="parent"  />
+
+            <TextView
+                tools:text="Recently added"
+                android:layout_width="wrap_content"
+                android:layout_height="wrap_content"
+                android:id="@+id/status"
+                android:lines="1"
+                android:textSize="12sp"
+                android:ellipsize="end"
+                android:layout_marginLeft="16dp"
+                android:layout_marginStart="16dp"
+                app:layout_constraintTop_toBottomOf="@+id/summary"
+                app:layout_constraintStart_toEndOf="@+id/icon"
+                android:textStyle="italic"
+                android:layout_marginTop="8dp" />
+
+        </android.support.constraint.ConstraintLayout>
+
+    </android.support.v7.widget.CardView>
+
+</android.support.constraint.ConstraintLayout>
\ No newline at end of file
diff --git a/app/src/main/res/layout/app_card_horizontal.xml b/app/src/main/res/layout/app_card_horizontal.xml
new file mode 100644
index 000000000..9baf5f7f0
--- /dev/null
+++ b/app/src/main/res/layout/app_card_horizontal.xml
@@ -0,0 +1,52 @@
+<?xml version="1.0" encoding="utf-8"?>
+<android.support.v7.widget.CardView 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.support.constraint.ConstraintLayout
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:layout_margin="8dp">
+
+        <ImageView
+            android:id="@+id/icon"
+            android:contentDescription="@string/app_icon"
+            android:layout_width="48dp"
+            android:layout_height="48dp"
+            tools:src="@drawable/ic_launcher"
+            android:scaleType="fitCenter"
+            app:layout_constraintStart_toStartOf="parent"
+            app:layout_constraintTop_toTopOf="parent" />
+
+        <TextView
+            tools:text="F-Droid An application summary which takes up too much space and must ellipsize"
+            android:layout_width="0dp"
+            android:layout_height="wrap_content"
+            android:id="@+id/summary"
+            android:lines="4"
+            android:textSize="13sp"
+            android:ellipsize="end"
+            android:layout_marginLeft="8dp"
+            android:layout_marginStart="8dp"
+            app:layout_constraintStart_toEndOf="@+id/icon"
+            app:layout_constraintEnd_toEndOf="parent"
+            app:layout_constraintTop_toTopOf="parent" />
+
+        <TextView
+            tools:text="Recently added"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:id="@+id/status"
+            android:lines="1"
+            android:textSize="12sp"
+            android:ellipsize="end"
+            app:layout_constraintTop_toBottomOf="@+id/summary"
+            app:layout_constraintStart_toStartOf="parent"
+            android:textStyle="italic"
+            android:layout_marginTop="4dp" />
+
+    </android.support.constraint.ConstraintLayout>
+
+</android.support.v7.widget.CardView>
\ No newline at end of file
diff --git a/app/src/main/res/layout/app_card_large.xml b/app/src/main/res/layout/app_card_large.xml
new file mode 100644
index 000000000..df691a2ff
--- /dev/null
+++ b/app/src/main/res/layout/app_card_large.xml
@@ -0,0 +1,58 @@
+<?xml version="1.0" encoding="utf-8"?>
+<android.support.v7.widget.CardView 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="220dp">
+
+    <android.support.constraint.ConstraintLayout
+        android:layout_width="match_parent"
+        android:layout_height="match_parent"
+        android:layout_margin="8dp">
+
+        <ImageView
+            android:id="@+id/icon"
+            android:contentDescription="@string/app_icon"
+            android:layout_width="96dip"
+            android:layout_height="96dip"
+            tools:src="@drawable/ic_launcher"
+            android:scaleType="fitCenter"
+            android:layout_marginEnd="16dp"
+            android:layout_marginRight="16dp"
+            android:layout_marginStart="16dp"
+            android:layout_marginLeft="16dp"
+            android:layout_marginTop="8dp"
+            android:layout_marginBottom="8dp"
+            app:layout_constraintRight_toRightOf="parent"
+            app:layout_constraintLeft_toLeftOf="parent"
+            app:layout_constraintTop_toTopOf="parent" />
+
+        <TextView
+            tools:text="F-Droid An application summary which takes up too much space and must ellipsize"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:id="@+id/summary"
+            android:maxLines="4"
+            android:textSize="14sp"
+            android:ellipsize="end"
+            app:layout_constraintTop_toBottomOf="@+id/icon"
+            app:layout_constraintRight_toRightOf="parent"
+            app:layout_constraintLeft_toLeftOf="parent"
+            android:layout_marginTop="8dp" />
+
+        <TextView
+            tools:text="Recently added"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:id="@+id/status"
+            android:lines="1"
+            android:textSize="12sp"
+            android:ellipsize="end"
+            app:layout_constraintBottom_toBottomOf="parent"
+            app:layout_constraintStart_toStartOf="parent"
+            android:textStyle="italic"
+            android:layout_marginTop="8dp" />
+
+    </android.support.constraint.ConstraintLayout>
+
+</android.support.v7.widget.CardView>
\ No newline at end of file
diff --git a/app/src/main/res/layout/app_card_list_item.xml b/app/src/main/res/layout/app_card_list_item.xml
new file mode 100644
index 000000000..5373d6cd4
--- /dev/null
+++ b/app/src/main/res/layout/app_card_list_item.xml
@@ -0,0 +1,55 @@
+<?xml version="1.0" encoding="utf-8"?>
+<android.support.v7.widget.CardView 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.support.constraint.ConstraintLayout
+        android:layout_width="match_parent"
+        android:layout_height="match_parent"
+        android:layout_margin="8dp">
+
+        <ImageView
+            android:id="@+id/icon"
+            android:contentDescription="@string/app_icon"
+            android:layout_width="48dp"
+            android:layout_height="48dp"
+            tools:src="@drawable/ic_launcher"
+            android:scaleType="fitCenter"
+            app:layout_constraintStart_toStartOf="parent"
+            app:layout_constraintTop_toTopOf="parent" />
+
+        <TextView
+            tools:text="F-Droid An application summary which takes up too much space and must ellipsize"
+            android:layout_width="0dp"
+            android:layout_height="wrap_content"
+            android:id="@+id/summary"
+            android:lines="2"
+            android:textSize="14sp"
+            android:ellipsize="end"
+            android:layout_marginLeft="16dp"
+            android:layout_marginStart="16dp"
+            app:layout_constraintStart_toEndOf="@+id/icon"
+            app:layout_constraintEnd_toEndOf="parent"
+            app:layout_constraintTop_toTopOf="parent"
+            tools:layout_editor_absoluteY="8dp" />
+
+        <TextView
+            tools:text="Recently added"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:id="@+id/status"
+            android:lines="1"
+            android:textSize="12sp"
+            android:ellipsize="end"
+            android:layout_marginLeft="16dp"
+            android:layout_marginStart="16dp"
+            app:layout_constraintTop_toBottomOf="@+id/summary"
+            app:layout_constraintStart_toEndOf="@+id/icon"
+            android:textStyle="italic"
+            android:layout_marginTop="8dp" />
+
+    </android.support.constraint.ConstraintLayout>
+
+</android.support.v7.widget.CardView>
\ No newline at end of file
diff --git a/app/src/main/res/layout/main_tab_whats_new.xml b/app/src/main/res/layout/main_tab_whats_new.xml
new file mode 100644
index 000000000..77a1d4fe7
--- /dev/null
+++ b/app/src/main/res/layout/main_tab_whats_new.xml
@@ -0,0 +1,19 @@
+<?xml version="1.0" encoding="utf-8"?>
+<LinearLayout 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:orientation="vertical"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent">
+
+    <android.support.v7.widget.RecyclerView
+        android:id="@+id/app_list"
+        android:layout_width="match_parent"
+        android:layout_height="match_parent"
+        tools:listitem="@layout/app_card_normal"
+        app:layout_constraintBottom_toBottomOf="parent"
+        app:layout_constraintLeft_toRightOf="parent"
+        app:layout_constraintTop_toTopOf="parent"
+        android:scrollbars="vertical" />
+
+</LinearLayout>
\ No newline at end of file
diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml
index 20d2d578d..bf3461874 100644
--- a/app/src/main/res/values/dimens.xml
+++ b/app/src/main/res/values/dimens.xml
@@ -18,4 +18,6 @@
     <!-- The selected item stands out from the background by this elevation -->
     <dimen name="details_screenshot_selected_elevation">3dp</dimen>
 
+    <dimen name="whats_new__padding__app_card__horizontal">12dp</dimen>
+    <dimen name="whats_new__padding__app_card__vertical">10dp</dimen>
 </resources>
diff --git a/app/src/main/res/values/ids.xml b/app/src/main/res/values/ids.xml
index 9b031b54e..4eaa17c4b 100644
--- a/app/src/main/res/values/ids.xml
+++ b/app/src/main/res/values/ids.xml
@@ -2,4 +2,9 @@
 <resources>
   <item type="id" name="category_spinner" />
   <item type="id" name="appDetailsSummaryHeader" />
+
+  <item type="id" name="whats_new_feature" />
+  <item type="id" name="whats_new_large_tile" />
+  <item type="id" name="whats_new_small_tile" />
+  <item type="id" name="whats_new_regular_list" />
 </resources>
\ No newline at end of file