diff --git a/app/src/main/java/org/fdroid/fdroid/views/categories/AppPreviewAdapter.java b/app/src/main/java/org/fdroid/fdroid/views/categories/AppPreviewAdapter.java new file mode 100644 index 000000000..a37e80117 --- /dev/null +++ b/app/src/main/java/org/fdroid/fdroid/views/categories/AppPreviewAdapter.java @@ -0,0 +1,40 @@ +package org.fdroid.fdroid.views.categories; + +import android.app.Activity; +import android.database.Cursor; +import android.support.v7.widget.RecyclerView; +import android.view.ViewGroup; + +import org.fdroid.fdroid.R; +import org.fdroid.fdroid.data.App; + +class AppPreviewAdapter extends RecyclerView.Adapter<AppCardController> { + + private Cursor cursor; + private final Activity activity; + + AppPreviewAdapter(Activity activity) { + this.activity = activity; + } + + @Override + public AppCardController onCreateViewHolder(ViewGroup parent, int viewType) { + return new AppCardController(activity, activity.getLayoutInflater().inflate(R.layout.app_card_normal, parent, false)); + } + + @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 setAppCursor(Cursor cursor) { + this.cursor = cursor; + notifyDataSetChanged(); + } +} diff --git a/app/src/main/java/org/fdroid/fdroid/views/categories/CategoryAdapter.java b/app/src/main/java/org/fdroid/fdroid/views/categories/CategoryAdapter.java new file mode 100644 index 000000000..cf7f4b017 --- /dev/null +++ b/app/src/main/java/org/fdroid/fdroid/views/categories/CategoryAdapter.java @@ -0,0 +1,44 @@ +package org.fdroid.fdroid.views.categories; + +import android.app.Activity; +import android.database.Cursor; +import android.support.v4.app.LoaderManager; +import android.support.v7.widget.RecyclerView; +import android.view.ViewGroup; + +import org.fdroid.fdroid.R; +import org.fdroid.fdroid.data.Schema; + +public class CategoryAdapter extends RecyclerView.Adapter<CategoryController> { + + private Cursor cursor; + private final Activity activity; + private final LoaderManager loaderManager; + + public CategoryAdapter(Activity activity, LoaderManager loaderManager) { + this.activity = activity; + this.loaderManager = loaderManager; + } + + @Override + public CategoryController onCreateViewHolder(ViewGroup parent, int viewType) { + return new CategoryController(activity, loaderManager, activity.getLayoutInflater().inflate(R.layout.category_item, parent, false)); + } + + @Override + public void onBindViewHolder(CategoryController holder, int position) { + cursor.moveToPosition(position); + holder.bindModel(cursor.getString(cursor.getColumnIndex(Schema.CategoryTable.Cols.NAME))); + } + + @Override + public int getItemCount() { + return cursor == null ? 0 : cursor.getCount(); + } + + public void setCategoriesCursor(Cursor cursor) { + this.cursor = cursor; + notifyDataSetChanged(); + } + +} diff --git a/app/src/main/java/org/fdroid/fdroid/views/categories/CategoryController.java b/app/src/main/java/org/fdroid/fdroid/views/categories/CategoryController.java new file mode 100644 index 000000000..85dcc9aa0 --- /dev/null +++ b/app/src/main/java/org/fdroid/fdroid/views/categories/CategoryController.java @@ -0,0 +1,168 @@ +package org.fdroid.fdroid.views.categories; + +import android.app.Activity; +import android.content.Context; +import android.database.Cursor; +import android.graphics.Color; +import android.graphics.Rect; +import android.os.Bundle; +import android.support.annotation.NonNull; +import android.support.v4.app.LoaderManager; +import android.support.v4.content.CursorLoader; +import android.support.v4.content.Loader; +import android.support.v4.view.ViewCompat; +import android.support.v7.widget.RecyclerView; +import android.view.View; +import android.widget.Button; +import android.widget.FrameLayout; +import android.widget.TextView; + +import org.fdroid.fdroid.R; +import org.fdroid.fdroid.data.AppProvider; +import org.fdroid.fdroid.data.Schema; + +import java.util.Random; + +public class CategoryController extends RecyclerView.ViewHolder implements LoaderManager.LoaderCallbacks<Cursor> { + private final Button viewAll; + private final TextView heading; + private final AppPreviewAdapter appCardsAdapter; + private final FrameLayout background; + + private final Activity activity; + private final LoaderManager loaderManager; + + private String currentCategory; + + CategoryController(final Activity activity, LoaderManager loaderManager, View itemView) { + super(itemView); + + this.activity = activity; + this.loaderManager = loaderManager; + + appCardsAdapter = new AppPreviewAdapter(activity); + + viewAll = (Button) itemView.findViewById(R.id.button); + viewAll.setOnClickListener(onViewAll); + + heading = (TextView) itemView.findViewById(R.id.name); + + background = (FrameLayout) itemView.findViewById(R.id.category_background); + + RecyclerView appCards = (RecyclerView) itemView.findViewById(R.id.app_cards); + appCards.setAdapter(appCardsAdapter); + appCards.addItemDecoration(new ItemDecorator(activity)); + } + + void bindModel(@NonNull String categoryName) { + currentCategory = categoryName; + heading.setText(categoryName); + viewAll.setVisibility(View.INVISIBLE); + loaderManager.initLoader(currentCategory.hashCode(), null, this); + loaderManager.initLoader(currentCategory.hashCode() + 1, null, this); + + background.setBackgroundColor(getBackgroundColour(categoryName)); + } + + public static int getBackgroundColour(@NonNull String categoryName) { + // Seed based on the categoryName, so that each time we try to choose a colour for the same + // category it will look the same for each different user, and each different session. + Random random = new Random(categoryName.toLowerCase().hashCode()); + + float[] hsv = new float[3]; + hsv[0] = random.nextFloat() * 360; + hsv[1] = 0.4f; + hsv[2] = 0.5f; + return Color.HSVToColor(hsv); + } + + @Override + public Loader<Cursor> onCreateLoader(int id, Bundle args) { + if (id == currentCategory.hashCode() + 1) { + return new CursorLoader( + activity, + AppProvider.getCategoryUri(currentCategory), + new String[]{Schema.AppMetadataTable.Cols._COUNT}, + null, + null, + null + ); + } else { + return new CursorLoader( + activity, + AppProvider.getTopFromCategoryUri(currentCategory, 20), + new String[]{ + Schema.AppMetadataTable.Cols.NAME, + Schema.AppMetadataTable.Cols.Package.PACKAGE_NAME, + Schema.AppMetadataTable.Cols.SUMMARY, + Schema.AppMetadataTable.Cols.ICON_URL, + }, + null, + null, + null + ); + } + } + + @Override + public void onLoadFinished(Loader<Cursor> loader, Cursor cursor) { + int topAppsId = currentCategory.hashCode(); + int countAllAppsId = topAppsId + 1; + + // Anything other than these IDs indicates that the loader which just finished finished + // is no longer the one this view holder is interested in, due to the user having + // scrolled away already during the asynchronous query being run. + if (loader.getId() == topAppsId) { + appCardsAdapter.setAppCursor(cursor); + } else if (loader.getId() == countAllAppsId) { + cursor.moveToFirst(); + int numAppsInCategory = cursor.getInt(0); + viewAll.setVisibility(View.VISIBLE); + viewAll.setText(activity.getResources().getQuantityString(R.plurals.button_view_all_apps_in_category, numAppsInCategory, numAppsInCategory)); + } + } + + @Override + public void onLoaderReset(Loader<Cursor> loader) { + appCardsAdapter.setAppCursor(null); + } + + private final View.OnClickListener onViewAll = new View.OnClickListener() { + @Override + public void onClick(View v) { + } + }; + + /** + * Applies excessive padding to the start of the first item. This is so that the category artwork + * can peek out and make itself visible. This is RTL friendly. + * @see org.fdroid.fdroid.R.dimen#category_preview__app_list__padding__horizontal + * @see org.fdroid.fdroid.R.dimen#category_preview__app_list__padding__horizontal__first + */ + private static class ItemDecorator extends RecyclerView.ItemDecoration { + private final Context context; + + ItemDecorator(Context context) { + this.context = context.getApplicationContext(); + } + + @Override + public void getItemOffsets(Rect outRect, View view, RecyclerView parent, RecyclerView.State state) { + int horizontalPadding = (int) context.getResources().getDimension(R.dimen.category_preview__app_list__padding__horizontal); + int horizontalPaddingFirst = (int) context.getResources().getDimension(R.dimen.category_preview__app_list__padding__horizontal__first); + boolean isLtr = ViewCompat.getLayoutDirection(parent) == ViewCompat.LAYOUT_DIRECTION_LTR; + int itemPosition = parent.getChildLayoutPosition(view); + boolean first = itemPosition == 0; + + // Leave this "paddingEnd" local variable here for clarity when converting from + // left/right to start/end for RTL friendly layout. + // noinspection UnnecessaryLocalVariable + int paddingEnd = horizontalPadding; + int paddingStart = first ? horizontalPaddingFirst : horizontalPadding; + + int paddingLeft = isLtr ? paddingStart : paddingEnd; + int paddingRight = isLtr ? paddingEnd : paddingStart; + outRect.set(paddingLeft, 0, paddingRight, 0); + } + } +} diff --git a/app/src/main/java/org/fdroid/fdroid/views/main/CategoriesViewBinder.java b/app/src/main/java/org/fdroid/fdroid/views/main/CategoriesViewBinder.java new file mode 100644 index 000000000..3bcf6567c --- /dev/null +++ b/app/src/main/java/org/fdroid/fdroid/views/main/CategoriesViewBinder.java @@ -0,0 +1,80 @@ +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.LinearLayoutManager; +import android.support.v7.widget.RecyclerView; +import android.view.View; +import android.widget.FrameLayout; + +import org.fdroid.fdroid.R; +import org.fdroid.fdroid.data.CategoryProvider; +import org.fdroid.fdroid.data.Schema; +import org.fdroid.fdroid.views.categories.CategoryAdapter; + +/** + * Responsible for ensuring that the categories view is inflated and then populated correctly. + * Will start a loader to get the list of categories from the database and populate a recycler + * view with relevant info about each. + */ +class CategoriesViewBinder implements LoaderManager.LoaderCallbacks<Cursor> { + + private static final int LOADER_ID = 429820532; + + private final CategoryAdapter categoryAdapter; + private final AppCompatActivity activity; + + CategoriesViewBinder(AppCompatActivity activity, FrameLayout parent) { + this.activity = activity; + + View categoriesView = activity.getLayoutInflater().inflate(R.layout.main_tab_categories, parent, true); + + categoryAdapter = new CategoryAdapter(activity, activity.getSupportLoaderManager()); + + RecyclerView categoriesList = (RecyclerView) categoriesView.findViewById(R.id.category_list); + categoriesList.setHasFixedSize(true); + categoriesList.setLayoutManager(new LinearLayoutManager(activity)); + categoriesList.setAdapter(categoryAdapter); + + 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, + CategoryProvider.getAllCategories(), + Schema.CategoryTable.Cols.ALL, + null, + null, + null + ); + } + + @Override + public void onLoadFinished(Loader<Cursor> loader, Cursor cursor) { + if (loader.getId() != LOADER_ID) { + return; + } + + categoryAdapter.setCategoriesCursor(cursor); + } + + @Override + public void onLoaderReset(Loader<Cursor> loader) { + if (loader.getId() != LOADER_ID) { + return; + } + + categoryAdapter.setCategoriesCursor(null); + } + +} 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 668a44f9a..234a6ebe4 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 @@ -46,7 +46,11 @@ class MainViewController extends RecyclerView.ViewHolder { new MyAppsViewBinder(activity, frame); } + /** + * @see CategoriesViewBinder + */ public void bindCategoriesView() { + new CategoriesViewBinder(activity, frame); } /** diff --git a/app/src/main/res/drawable/category_preview_app_card_background.xml b/app/src/main/res/drawable/category_preview_app_card_background.xml new file mode 100644 index 000000000..b2eb1a1fa --- /dev/null +++ b/app/src/main/res/drawable/category_preview_app_card_background.xml @@ -0,0 +1,9 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Visually different from other app cards because it doesn't have a drop shadow, and has a larger + corner radius. +--> +<shape xmlns:android="http://schemas.android.com/apk/res/android"> + <corners android:radius="4dp" /> + <solid android:color="#faf8ef" /> +</shape> \ No newline at end of file diff --git a/app/src/main/res/layout/app_card_normal.xml b/app/src/main/res/layout/app_card_normal.xml new file mode 100644 index 000000000..e303d6196 --- /dev/null +++ b/app/src/main/res/layout/app_card_normal.xml @@ -0,0 +1,41 @@ +<?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="100dp" + android:layout_height="130dp" + android:background="@drawable/category_preview_app_card_background" + android:padding="8dp"> + + <!-- Ignore ContentDescription because it is kind of meaningless to have TTS read out "App icon" + when it will inevitably read out the name of the app straight after. --> + <ImageView + android:id="@+id/icon" + android:layout_width="48dip" + android:layout_height="48dip" + 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" + tools:ignore="ContentDescription" /> + + <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="3" + android:ellipsize="end" + app:layout_constraintTop_toBottomOf="@+id/icon" + app:layout_constraintRight_toRightOf="parent" + app:layout_constraintLeft_toLeftOf="parent" + android:layout_marginTop="8dp" /> + +</android.support.constraint.ConstraintLayout> \ No newline at end of file diff --git a/app/src/main/res/layout/category_item.xml b/app/src/main/res/layout/category_item.xml new file mode 100644 index 000000000..392ee0889 --- /dev/null +++ b/app/src/main/res/layout/category_item.xml @@ -0,0 +1,80 @@ +<?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:orientation="vertical" android:layout_width="match_parent" + android:layout_height="wrap_content"> + + <TextView + android:id="@+id/name" + tools:text="Business" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintBaseline_toBaselineOf="@+id/button" + android:textAppearance="@style/TextAppearance.AppCompat.Headline" + android:textSize="18sp" + android:textColor="#4a4a4a" + android:paddingLeft="18dp" + android:paddingStart="18dp" + android:paddingRight="18dp" + android:paddingEnd="18dp" + tools:layout_editor_absoluteX="0dp" /> + + <Button + android:id="@+id/button" + tools:text="View all 10" + android:layout_width="wrap_content" + android:layout_height="0dp" + app:layout_constraintTop_toTopOf="parent" + app:layout_constraintEnd_toEndOf="parent" + android:background="@android:color/transparent" + android:paddingLeft="18dp" + android:paddingStart="18dp" + android:paddingRight="18dp" + android:paddingEnd="18dp" + android:paddingTop="24dp" + android:paddingBottom="12dp" + android:textSize="14sp" + android:textAllCaps="true" + android:textColor="@color/fdroid_blue" + + tools:layout_editor_absoluteX="268dp" /> + + <FrameLayout + android:id="@+id/category_background" + android:layout_width="0dp" + android:layout_height="0dp" + app:layout_constraintTop_toTopOf="@+id/app_cards" + app:layout_constraintBottom_toBottomOf="@+id/app_cards" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintEnd_toEndOf="parent" + tools:background="#ffffbbbb"/> + + <ImageView + android:id="@+id/category_image" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@+id/button" + android:layout_width="100dp" + android:layout_height="100dp" + tools:ignore="ContentDescription" /> + + <android.support.v7.widget.RecyclerView + android:id="@+id/app_cards" + tools:listitem="@layout/app_card_normal" + android:layout_width="0dp" + android:layout_height="wrap_content" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintTop_toBottomOf="@+id/button" + app:layout_constraintLeft_toLeftOf="parent" + app:layout_constraintRight_toRightOf="parent" + app:layoutManager="LinearLayoutManager" + android:orientation="horizontal" + android:paddingTop="@dimen/category_preview__app_list__padding__vertical" + android:paddingBottom="@dimen/category_preview__app_list__padding__vertical" + android:clipToPadding="false" + android:layout_marginLeft="8dp" + android:layout_marginRight="8dp"/> + +</android.support.constraint.ConstraintLayout> \ No newline at end of file diff --git a/app/src/main/res/layout/main_tab_categories.xml b/app/src/main/res/layout/main_tab_categories.xml new file mode 100644 index 000000000..2b4676585 --- /dev/null +++ b/app/src/main/res/layout/main_tab_categories.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/category_list" + android:layout_width="match_parent" + android:layout_height="match_parent" + tools:listitem="@layout/category_item" + 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 bf3461874..2e70e8ac4 100644 --- a/app/src/main/res/values/dimens.xml +++ b/app/src/main/res/values/dimens.xml @@ -20,4 +20,11 @@ <dimen name="whats_new__padding__app_card__horizontal">12dp</dimen> <dimen name="whats_new__padding__app_card__vertical">10dp</dimen> + + <dimen name="category_preview__app_list__padding__horizontal">4dp</dimen> + <dimen name="category_preview__app_list__padding__horizontal__first">72dp</dimen> + <dimen name="category_preview__app_list__padding__vertical">18dp</dimen> + <dimen name="category_preview__padding__recycler_view__top">12dp</dimen> + <dimen name="category_preview__padding__app_card__horizontal">3dp</dimen> + <dimen name="category_preview__padding__app_card__vertical">4dp</dimen> </resources> diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4ee0d76f1..7052fc58c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -277,6 +277,13 @@ <string name="category_Time">Time</string> <string name="category_Writing">Writing</string> + <plurals name="button_view_all_apps_in_category"> + <!-- Even though these are the same as eachother, Android docs suggest always specifying at + least "one" and "other": https://developer.android.com/guide/topics/resources/string-resource.html#Plurals --> + <item quantity="one">View all %d</item> + <item quantity="other">View all %d</item> + </plurals> + <string name="empty_installed_app_list">No apps installed.\n\nThere are apps on your device, but they are not available from F-Droid. This could be because you need to update your repositories, or the repositories genuinely don\'t have your apps available.</string> <string name="empty_available_app_list">No apps in this category.\n\nTry selecting a different category or updating your repositories to get a fresh list of apps.</string> <string name="empty_can_update_app_list">All apps up to date.\n\nCongratulations! All of your apps are up to date (or your repositories are out of date).</string>