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 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). + 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 { + + 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 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 loader, Cursor cursor) { + if (loader.getId() != LOADER_ID) { + return; + } + + whatsNewAdapter.setAppsCursor(cursor); + } + + @Override + public void onLoaderReset(Loader 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 { + + 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 @@ + + + + + + + + + + + + + + + + + + + + + + \ 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 @@ + + + + + + + + + + + + + + \ 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 @@ + + + + + + + + + + + + + + \ 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 @@ + + + + + + + + + + + + + + \ 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 @@ + + + + + + \ 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 @@ 3dp + 12dp + 10dp 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 @@ + + + + + \ No newline at end of file