diff --git a/app/build.gradle b/app/build.gradle index 66958b6b..d0f67b34 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -68,7 +68,7 @@ dependencies { implementation "com.android.support:appcompat-v7:$supportVersion" implementation "com.android.support:cardview-v7:$supportVersion" implementation "com.android.support:design:$supportVersion" - implementation 'com.android.support.constraint:constraint-layout:1.1.0' + implementation 'com.android.support.constraint:constraint-layout:1.1.1' // Android Play services libraries def playServicesVersion = '15.0.1' implementation "com.google.android.gms:play-services-location:$playServicesVersion" diff --git a/app/schemas/com.gophillygo.app.data.GpgDatabase/11.json b/app/schemas/com.gophillygo.app.data.GpgDatabase/11.json new file mode 100644 index 00000000..a1bcc39e --- /dev/null +++ b/app/schemas/com.gophillygo.app.data.GpgDatabase/11.json @@ -0,0 +1,530 @@ +{ + "formatVersion": 1, + "database": { + "version": 11, + "identityHash": "5cbe98c663ba7f4df4514cb325906a9a", + "entities": [ + { + "tableName": "AttractionFlag", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`attraction_id` INTEGER NOT NULL, `is_event` INTEGER NOT NULL, `option` INTEGER, PRIMARY KEY(`attraction_id`, `is_event`))", + "fields": [ + { + "fieldPath": "attractionID", + "columnName": "attraction_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isEvent", + "columnName": "is_event", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "option", + "columnName": "option", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "attraction_id", + "is_event" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_AttractionFlag_is_event_option", + "unique": false, + "columnNames": [ + "is_event", + "option" + ], + "createSql": "CREATE INDEX `index_AttractionFlag_is_event_option` ON `${TABLE_NAME}` (`is_event`, `option`)" + }, + { + "name": "index_AttractionFlag_attraction_id", + "unique": false, + "columnNames": [ + "attraction_id" + ], + "createSql": "CREATE INDEX `index_AttractionFlag_attraction_id` ON `${TABLE_NAME}` (`attraction_id`)" + }, + { + "name": "index_AttractionFlag_is_event", + "unique": false, + "columnNames": [ + "is_event" + ], + "createSql": "CREATE INDEX `index_AttractionFlag_is_event` ON `${TABLE_NAME}` (`is_event`)" + }, + { + "name": "index_AttractionFlag_option", + "unique": false, + "columnNames": [ + "option" + ], + "createSql": "CREATE INDEX `index_AttractionFlag_option` ON `${TABLE_NAME}` (`option`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "Destination", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`city` TEXT, `state` TEXT, `address` TEXT, `categories` TEXT, `watershed_alliance` INTEGER NOT NULL, `zipcode` TEXT, `distance` REAL NOT NULL, `id` INTEGER NOT NULL, `placeID` INTEGER NOT NULL, `name` TEXT, `accessible` INTEGER NOT NULL, `image` TEXT, `cycling` INTEGER NOT NULL, `description` TEXT, `priority` INTEGER NOT NULL, `activities` TEXT, `website_url` TEXT, `wide_image` TEXT, `is_event` INTEGER NOT NULL, `extra_wide_images` TEXT, `timestamp` INTEGER NOT NULL, `nature` INTEGER, `exercise` INTEGER, `educational` INTEGER, `x` REAL, `y` REAL, `street_address` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "city", + "columnName": "city", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "address", + "columnName": "address", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "categories", + "columnName": "categories", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "watershedAlliance", + "columnName": "watershed_alliance", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "zipCode", + "columnName": "zipcode", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "distance", + "columnName": "distance", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "placeID", + "columnName": "placeID", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "accessible", + "columnName": "accessible", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "image", + "columnName": "image", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "cycling", + "columnName": "cycling", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "priority", + "columnName": "priority", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "activities", + "columnName": "activities", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "websiteUrl", + "columnName": "website_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "wideImage", + "columnName": "wide_image", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isEvent", + "columnName": "is_event", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "extraWideImages", + "columnName": "extra_wide_images", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "categoryFlags.nature", + "columnName": "nature", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "categoryFlags.exercise", + "columnName": "exercise", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "categoryFlags.educational", + "columnName": "educational", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "location.x", + "columnName": "x", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "location.y", + "columnName": "y", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "attributes.streetAddress", + "columnName": "street_address", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_Destination_educational", + "unique": false, + "columnNames": [ + "educational" + ], + "createSql": "CREATE INDEX `index_Destination_educational` ON `${TABLE_NAME}` (`educational`)" + }, + { + "name": "index_Destination_nature", + "unique": false, + "columnNames": [ + "nature" + ], + "createSql": "CREATE INDEX `index_Destination_nature` ON `${TABLE_NAME}` (`nature`)" + }, + { + "name": "index_Destination_exercise", + "unique": false, + "columnNames": [ + "exercise" + ], + "createSql": "CREATE INDEX `index_Destination_exercise` ON `${TABLE_NAME}` (`exercise`)" + }, + { + "name": "index_Destination_watershed_alliance", + "unique": false, + "columnNames": [ + "watershed_alliance" + ], + "createSql": "CREATE INDEX `index_Destination_watershed_alliance` ON `${TABLE_NAME}` (`watershed_alliance`)" + }, + { + "name": "index_Destination_distance", + "unique": false, + "columnNames": [ + "distance" + ], + "createSql": "CREATE INDEX `index_Destination_distance` ON `${TABLE_NAME}` (`distance`)" + }, + { + "name": "index_Destination_placeID", + "unique": false, + "columnNames": [ + "placeID" + ], + "createSql": "CREATE INDEX `index_Destination_placeID` ON `${TABLE_NAME}` (`placeID`)" + }, + { + "name": "index_Destination_name", + "unique": false, + "columnNames": [ + "name" + ], + "createSql": "CREATE INDEX `index_Destination_name` ON `${TABLE_NAME}` (`name`)" + }, + { + "name": "index_Destination_accessible", + "unique": false, + "columnNames": [ + "accessible" + ], + "createSql": "CREATE INDEX `index_Destination_accessible` ON `${TABLE_NAME}` (`accessible`)" + }, + { + "name": "index_Destination_cycling", + "unique": false, + "columnNames": [ + "cycling" + ], + "createSql": "CREATE INDEX `index_Destination_cycling` ON `${TABLE_NAME}` (`cycling`)" + }, + { + "name": "index_Destination_activities", + "unique": false, + "columnNames": [ + "activities" + ], + "createSql": "CREATE INDEX `index_Destination_activities` ON `${TABLE_NAME}` (`activities`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "Event", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`destination` INTEGER, `start_date` TEXT, `end_date` TEXT, `id` INTEGER NOT NULL, `placeID` INTEGER NOT NULL, `name` TEXT, `accessible` INTEGER NOT NULL, `image` TEXT, `cycling` INTEGER NOT NULL, `description` TEXT, `priority` INTEGER NOT NULL, `activities` TEXT, `website_url` TEXT, `wide_image` TEXT, `is_event` INTEGER NOT NULL, `extra_wide_images` TEXT, `timestamp` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`destination`) REFERENCES `Destination`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL DEFERRABLE INITIALLY DEFERRED)", + "fields": [ + { + "fieldPath": "destination", + "columnName": "destination", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "startDate", + "columnName": "start_date", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "endDate", + "columnName": "end_date", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "placeID", + "columnName": "placeID", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "accessible", + "columnName": "accessible", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "image", + "columnName": "image", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "cycling", + "columnName": "cycling", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "priority", + "columnName": "priority", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "activities", + "columnName": "activities", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "websiteUrl", + "columnName": "website_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "wideImage", + "columnName": "wide_image", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isEvent", + "columnName": "is_event", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "extraWideImages", + "columnName": "extra_wide_images", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_Event_destination", + "unique": false, + "columnNames": [ + "destination" + ], + "createSql": "CREATE INDEX `index_Event_destination` ON `${TABLE_NAME}` (`destination`)" + }, + { + "name": "index_Event_start_date", + "unique": false, + "columnNames": [ + "start_date" + ], + "createSql": "CREATE INDEX `index_Event_start_date` ON `${TABLE_NAME}` (`start_date`)" + }, + { + "name": "index_Event_end_date", + "unique": false, + "columnNames": [ + "end_date" + ], + "createSql": "CREATE INDEX `index_Event_end_date` ON `${TABLE_NAME}` (`end_date`)" + }, + { + "name": "index_Event_placeID", + "unique": false, + "columnNames": [ + "placeID" + ], + "createSql": "CREATE INDEX `index_Event_placeID` ON `${TABLE_NAME}` (`placeID`)" + }, + { + "name": "index_Event_name", + "unique": false, + "columnNames": [ + "name" + ], + "createSql": "CREATE INDEX `index_Event_name` ON `${TABLE_NAME}` (`name`)" + }, + { + "name": "index_Event_accessible", + "unique": false, + "columnNames": [ + "accessible" + ], + "createSql": "CREATE INDEX `index_Event_accessible` ON `${TABLE_NAME}` (`accessible`)" + }, + { + "name": "index_Event_cycling", + "unique": false, + "columnNames": [ + "cycling" + ], + "createSql": "CREATE INDEX `index_Event_cycling` ON `${TABLE_NAME}` (`cycling`)" + }, + { + "name": "index_Event_activities", + "unique": false, + "columnNames": [ + "activities" + ], + "createSql": "CREATE INDEX `index_Event_activities` ON `${TABLE_NAME}` (`activities`)" + } + ], + "foreignKeys": [ + { + "table": "Destination", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "destination" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, \"5cbe98c663ba7f4df4514cb325906a9a\")" + ] + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 31933e04..7ae2e213 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -101,8 +101,6 @@ - diff --git a/app/src/main/java/com/gophillygo/app/FilterDialog.java b/app/src/main/java/com/gophillygo/app/FilterDialog.java index f1797580..a1eaae20 100644 --- a/app/src/main/java/com/gophillygo/app/FilterDialog.java +++ b/app/src/main/java/com/gophillygo/app/FilterDialog.java @@ -20,7 +20,7 @@ public class FilterDialog extends BottomSheetDialogFragment { private FilterModalBinding binding; public interface FilterChangeListener { - void filterChanged(Filter filter); + void setFilter(Filter filter); } private static final String LOG_LABEL = "FilterDialog"; @@ -41,7 +41,7 @@ public void onDismiss(DialogInterface dialog) { Log.d(LOG_LABEL, "Selected " + String.valueOf(filter.count()) + " filters."); FilterChangeListener listener = (FilterChangeListener) getActivity(); if (listener != null) { - listener.filterChanged(filter); + listener.setFilter(filter); } super.onDismiss(dialog); diff --git a/app/src/main/java/com/gophillygo/app/activities/EventsListActivity.java b/app/src/main/java/com/gophillygo/app/activities/EventsListActivity.java index ce25c8f2..3c14207e 100644 --- a/app/src/main/java/com/gophillygo/app/activities/EventsListActivity.java +++ b/app/src/main/java/com/gophillygo/app/activities/EventsListActivity.java @@ -116,6 +116,7 @@ protected FilterButtonBarBinding setupDataBinding() { @Override protected void loadData() { + if (eventsListView == null) return; List filteredEvents = getFilteredEvents(); TextView noDataView = findViewById(R.id.empty_events_list); @@ -162,6 +163,7 @@ public boolean onOptionsItemSelected(MenuItem item) { @NonNull private List getFilteredEvents() { + if (events == null) return new ArrayList<>(0); List filteredEvents = new ArrayList<>(events.size()); for (EventInfo info : events) { if (filter.matches(info)) { diff --git a/app/src/main/java/com/gophillygo/app/activities/FilterableListActivity.java b/app/src/main/java/com/gophillygo/app/activities/FilterableListActivity.java index e3f958b3..e5435fd9 100644 --- a/app/src/main/java/com/gophillygo/app/activities/FilterableListActivity.java +++ b/app/src/main/java/com/gophillygo/app/activities/FilterableListActivity.java @@ -22,11 +22,12 @@ public abstract class FilterableListActivity extends BaseAttractionActivity implements FilterDialog.FilterChangeListener, ToolbarFilterListener { + public final static String FILTER_KEY = "filter"; + private final int toolbarId; private Button filterButton; private Drawable filterIcon; - private Toolbar toolbar; protected Filter filter; private FilterButtonBarBinding filterBinding; @@ -44,10 +45,11 @@ protected void onCreate(Bundle savedInstanceState) { filterBinding = setupDataBinding(); filterBinding.setListener(this); + filter = new Filter(); filterBinding.setFilter(filter); // set up toolbar - toolbar = findViewById(toolbarId); + Toolbar toolbar = findViewById(toolbarId); setSupportActionBar(toolbar); //noinspection ConstantConditions getSupportActionBar().setDisplayHomeAsUpEnabled(true); @@ -55,7 +57,6 @@ protected void onCreate(Bundle savedInstanceState) { filterIcon = ContextCompat.getDrawable(this, R.drawable.ic_filter_list_white_24dp); // set up filter button - filter = new Filter(); filterButton = findViewById(R.id.filter_bar_filter_button); filterButton.setOnClickListener(v -> { // Need to give the filter dialog a copy of the filter, or toggling the @@ -63,10 +64,22 @@ protected void onCreate(Bundle savedInstanceState) { FilterDialog filterDialog = FilterDialog.newInstance(new Filter(filter)); filterDialog.show(getSupportFragmentManager(), filterDialog.getTag()); }); + + if (getIntent().hasExtra(FILTER_KEY)) { + setFilter((Filter) getIntent().getSerializableExtra(FILTER_KEY)); + } else if (savedInstanceState != null && savedInstanceState.containsKey(FILTER_KEY)) { + setFilter((Filter) savedInstanceState.getSerializable(FILTER_KEY)); + } + } + + @Override + protected void onSaveInstanceState(Bundle outState) { + outState.putSerializable(FILTER_KEY, filter); + super.onSaveInstanceState(outState); } @Override - public void filterChanged(Filter filter) { + public void setFilter(Filter filter) { this.filter = filter; filterBinding.setFilter(filter); filterBinding.notifyPropertyChanged(BR.filter); @@ -91,13 +104,13 @@ public void filterChanged(Filter filter) { @Override public void toggleLiked() { filter.setLiked(filterBinding.filterBarLikedButton.isChecked()); - filterChanged(filter); + setFilter(filter); } @Override public void toggleWantToGo() { filter.setWantToGo(filterBinding.filterBarWantToGoButton.isChecked()); - filterChanged(filter); + setFilter(filter); } } diff --git a/app/src/main/java/com/gophillygo/app/activities/HomeActivity.java b/app/src/main/java/com/gophillygo/app/activities/HomeActivity.java index c41373ad..8a0a79ea 100644 --- a/app/src/main/java/com/gophillygo/app/activities/HomeActivity.java +++ b/app/src/main/java/com/gophillygo/app/activities/HomeActivity.java @@ -1,42 +1,61 @@ package com.gophillygo.app.activities; +import android.arch.lifecycle.LiveData; import android.content.Intent; import android.os.Bundle; +import android.support.v7.widget.GridLayoutManager; +import android.support.v7.widget.RecyclerView; import android.support.v7.widget.Toolbar; import android.util.Log; import android.view.Gravity; import android.view.Menu; import android.view.MenuItem; -import android.widget.GridView; import com.gophillygo.app.CarouselViewListener; import com.gophillygo.app.R; import com.gophillygo.app.adapters.PlaceCategoryGridAdapter; +import com.gophillygo.app.data.DestinationRepository; +import com.gophillygo.app.data.models.CategoryAttraction; import com.gophillygo.app.data.models.Destination; +import com.gophillygo.app.data.models.Filter; import com.synnapps.carouselview.CarouselView; +import java.util.ArrayList; +import java.util.List; -public class HomeActivity extends BaseAttractionActivity { +import static com.gophillygo.app.activities.FilterableListActivity.FILTER_KEY; + + +public class HomeActivity extends BaseAttractionActivity implements DestinationRepository.CategoryAttractionCallback, +PlaceCategoryGridAdapter.GridViewHolder.PlaceGridItemClickListener { private static final String LOG_LABEL = "HomeActivity"; - private CarouselView carouselView; + // how many columns to display in grid of filter buttons + private static final int NUM_COLUMNS = 2; + private CarouselView carouselView; + RecyclerView recyclerView; + PlaceCategoryGridAdapter gridAdapter; + List categories; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_home); Log.d(LOG_LABEL, "onCreate"); + categories = new ArrayList<>(CategoryAttraction.PlaceCategories.size()); Toolbar toolbar = findViewById(R.id.home_toolbar); // disable default app name title display toolbar.setTitle(""); setSupportActionBar(toolbar); - GridView gridView = findViewById(R.id.home_grid_view); - gridView.setAdapter(new PlaceCategoryGridAdapter(this)); - gridView.setOnItemClickListener((parent, v, position, id) -> clickedGridItem(position)); + recyclerView = findViewById(R.id.home_grid_view); + GridLayoutManager layoutManager = new GridLayoutManager(this, NUM_COLUMNS); + recyclerView.setLayoutManager(layoutManager); + gridAdapter = new PlaceCategoryGridAdapter(this, this); + recyclerView.setAdapter(gridAdapter); carouselView = findViewById(R.id.home_carousel); carouselView.setIndicatorGravity(Gravity.CENTER_HORIZONTAL|Gravity.BOTTOM); @@ -45,7 +64,6 @@ protected void onCreate(Bundle savedInstanceState) { // initialize carousel if destinations already loaded locationOrDestinationsChanged(); - } @Override @@ -54,6 +72,8 @@ public void locationOrDestinationsChanged() { // set up carousel with nearest destinations if (getNearestDestinationSize() > 0) { setUpCarousel(); + // request random images for the filter grid categories, and notify the adapter + viewModel.getCategoryAttractions(this); } else { Log.w(LOG_LABEL, "No nearest destinations yet in locationOrDestinationChanged"); } @@ -123,7 +143,8 @@ protected void onPostResume() { carouselView.playCarousel(); } - private void clickedGridItem(int position) { + @Override + public void clickedGridItem(int position) { Log.d(LOG_LABEL, "clicked grid view item: " + position); switch (position) { @@ -133,8 +154,70 @@ private void clickedGridItem(int position) { break; default: // go to places list - // TODO: #18 filter list based on selected grid item - startActivity(new Intent(this, PlacesListActivity.class)); + if (categories == null || position >= categories.size()) { + Log.e(LOG_LABEL, "Cannot go to filtered list because categories are missing"); + startActivity(new Intent(this, PlacesListActivity.class)); + } + CategoryAttraction attraction = categories.get(position); + goToFilteredPlacesList(attraction.getCategory()); } } + + private void goToFilteredPlacesList(CategoryAttraction.PlaceCategories category) { + Filter filter = new Filter(); + switch (category) { + case WantToGo: + filter.setWantToGo(true); + break; + case Liked: + filter.setLiked(true); + break; + case Nature: + filter.setNature(true); + break; + case Exercise: + filter.setExercise(true); + break; + case Educational: + filter.setEducational(true); + break; + default: + Log.e(LOG_LABEL, "Unrecognized place category " + category.displayName); + } + Intent intent = new Intent(this, PlacesListActivity.class); + intent.putExtra(FILTER_KEY, filter); + startActivity(intent); + } + + @Override + public void gotCategoryAttractions(LiveData> categoryAttractions) { + Log.d(LOG_LABEL, "Getting category attractions"); + categoryAttractions.observe(this, data -> { + recyclerView.post(() -> { + Log.d(LOG_LABEL, "Got category attractions"); + if (data == null || data.isEmpty()) { + Log.e(LOG_LABEL, "Category attractions are missing"); + return; + } + + // Submit `categories` list managed by this activity, rather than the `data` list + // owned by the DAO, in order to be able to remove categories with no entries + // (empty image). Do not destroy/recreate list here, so list differ will + // correctly recognize that the reference hasn't changed. + + categories.clear(); + categories.addAll(data); + gridAdapter.submitList(categories); + gridAdapter.notifyDataSetChanged(); + + for (CategoryAttraction attraction: data) { + if (attraction.getImage().isEmpty()) { + categories.remove(attraction); + gridAdapter.submitList(categories); + gridAdapter.notifyItemRemoved(attraction.getCategory().code); + } + } + }); + }); + } } diff --git a/app/src/main/java/com/gophillygo/app/activities/PlaceDetailActivity.java b/app/src/main/java/com/gophillygo/app/activities/PlaceDetailActivity.java index d4f9e6c0..334e301e 100644 --- a/app/src/main/java/com/gophillygo/app/activities/PlaceDetailActivity.java +++ b/app/src/main/java/com/gophillygo/app/activities/PlaceDetailActivity.java @@ -32,7 +32,6 @@ public class PlaceDetailActivity extends AttractionDetailActivity { private ActivityPlaceDetailBinding binding; private CarouselView carouselView; - private Toolbar toolbar; @SuppressWarnings("WeakerAccess") @Inject @@ -46,7 +45,7 @@ protected void onCreate(Bundle savedInstanceState) { binding = DataBindingUtil.setContentView(this, R.layout.activity_place_detail); binding.setActivity(this); - toolbar = findViewById(R.id.place_detail_toolbar); + Toolbar toolbar = findViewById(R.id.place_detail_toolbar); // disable default app name title display toolbar.setTitle(""); setSupportActionBar(toolbar); @@ -101,7 +100,6 @@ private void displayDestination() { .getQuantityString(R.plurals.place_upcoming_activities_count, eventCount, eventCount); upcomingEventsView.setText(upcomingEventsText); - // TODO: #18 go to filtered event list with events for destination on click upcomingEventsView.setOnClickListener(v -> Log.d(LOG_LABEL, "Clicked upcoming events for destination " + destinationInfo.getDestination().getName())); diff --git a/app/src/main/java/com/gophillygo/app/activities/PlacesListActivity.java b/app/src/main/java/com/gophillygo/app/activities/PlacesListActivity.java index d7f85c0d..56c27b46 100644 --- a/app/src/main/java/com/gophillygo/app/activities/PlacesListActivity.java +++ b/app/src/main/java/com/gophillygo/app/activities/PlacesListActivity.java @@ -16,12 +16,9 @@ import com.gophillygo.app.adapters.PlacesListAdapter; import com.gophillygo.app.data.models.AttractionFlag; import com.gophillygo.app.data.models.AttractionInfo; -import com.gophillygo.app.data.models.Destination; import com.gophillygo.app.data.models.DestinationInfo; import com.gophillygo.app.databinding.ActivityPlacesListBinding; import com.gophillygo.app.databinding.FilterButtonBarBinding; -import com.gophillygo.app.tasks.AddGeofencesBroadcastReceiver; -import com.gophillygo.app.tasks.RemoveGeofenceWorker; import java.util.ArrayList; import java.util.List; @@ -31,19 +28,11 @@ public class PlacesListActivity extends FilterableListActivity implements private static final String LOG_LABEL = "PlacesList"; - private LinearLayoutManager layoutManager; private RecyclerView placesListView; PlacesListAdapter placesListAdapter; public PlacesListActivity() { super(R.id.places_list_toolbar); - - // If destinations were loaded before this activity showed, use them immediately. - if (destinationInfos != null && !destinationInfos.isEmpty()) { - loadData(); - } else { - Log.d(LOG_LABEL, "Have no destinations for the places list"); - } } @Override @@ -52,7 +41,7 @@ public void locationOrDestinationsChanged() { if (destinationInfos != null && !destinationInfos.isEmpty()) { loadData(); } else { - Log.d(LOG_LABEL, "Have no destinations for the places list in locationOrDestinationsChanged"); + Log.w(LOG_LABEL, "Have no destinations for the places list in locationOrDestinationsChanged"); } } @@ -86,8 +75,16 @@ protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); // set up list of places - layoutManager = new LinearLayoutManager(this); + LinearLayoutManager layoutManager = new LinearLayoutManager(this); placesListView = findViewById(R.id.places_list_recycler_view); + placesListView.setLayoutManager(layoutManager); + + // If destinations were loaded before this activity showed, use them immediately. + if (destinationInfos != null && !destinationInfos.isEmpty()) { + loadData(); + } else { + Log.d(LOG_LABEL, "Have no destinations for the places list"); + } } @Override @@ -98,6 +95,7 @@ protected FilterButtonBarBinding setupDataBinding() { @Override protected void loadData() { + if (placesListView == null) return; Log.d(LOG_LABEL, "loadData"); List filteredDestinations = getFilteredDestinations(); @@ -110,13 +108,14 @@ protected void loadData() { // must set the list before the adapter for the differ to initialize properly placesListAdapter.submitList(filteredDestinations); placesListView.setAdapter(placesListAdapter); - placesListView.setLayoutManager(layoutManager); } else { Log.d(LOG_LABEL, "submit list for diff"); // Let the AsyncListDiffer find which have changed, and only update their view holders // https://developer.android.com/reference/android/support/v7/recyclerview/extensions/ListAdapter placesListAdapter.submitList(filteredDestinations); } + placesListAdapter.notifyDataSetChanged(); + placesListView.requestLayout(); } @Override @@ -148,6 +147,9 @@ public boolean onOptionsItemSelected(MenuItem item) { @NonNull private ArrayList getFilteredDestinations() { + if (destinationInfos == null) { + return new ArrayList<>(0); + } ArrayList filteredDestinations = new ArrayList<>(destinationInfos.size()); for (DestinationInfo info : destinationInfos) { if (filter.matches(info)) { diff --git a/app/src/main/java/com/gophillygo/app/adapters/AttractionListAdapter.java b/app/src/main/java/com/gophillygo/app/adapters/AttractionListAdapter.java index 9deb494a..728253f4 100644 --- a/app/src/main/java/com/gophillygo/app/adapters/AttractionListAdapter.java +++ b/app/src/main/java/com/gophillygo/app/adapters/AttractionListAdapter.java @@ -102,8 +102,8 @@ public void optionsButtonClick(View view, T info, Integer position) { @Override public void submitList(List list) { - super.submitList(list); this.attractionList = list; + super.submitList(list); } @NonNull diff --git a/app/src/main/java/com/gophillygo/app/adapters/PlaceCategoryGridAdapter.java b/app/src/main/java/com/gophillygo/app/adapters/PlaceCategoryGridAdapter.java index 3741e5e8..7ed792cb 100644 --- a/app/src/main/java/com/gophillygo/app/adapters/PlaceCategoryGridAdapter.java +++ b/app/src/main/java/com/gophillygo/app/adapters/PlaceCategoryGridAdapter.java @@ -1,97 +1,119 @@ package com.gophillygo.app.adapters; import android.content.Context; +import android.databinding.DataBindingUtil; +import android.databinding.ViewDataBinding; +import android.support.annotation.NonNull; +import android.support.v7.recyclerview.extensions.ListAdapter; +import android.support.v7.util.DiffUtil; +import android.support.v7.widget.RecyclerView; +import android.util.Log; import android.view.LayoutInflater; -import android.view.View; import android.view.ViewGroup; -import android.widget.BaseAdapter; -import android.widget.GridView; -import android.widget.ImageView; -import android.widget.TextView; -import com.bumptech.glide.Glide; +import com.gophillygo.app.BR; import com.gophillygo.app.R; +import com.gophillygo.app.data.models.CategoryAttraction; +import java.util.List; +import java.util.Objects; -public class PlaceCategoryGridAdapter extends BaseAdapter { - private final Context context; - private final LayoutInflater inflater; +public class PlaceCategoryGridAdapter extends ListAdapter { - private static class ViewHolder { - ImageView imageView; - TextView categoryNameView; + private static final String LOG_LABEL = "GridAdapter"; + + private List categoryAttractions; + + private LayoutInflater inflater; + private GridViewHolder.PlaceGridItemClickListener listener; + + public static class GridViewHolder extends RecyclerView.ViewHolder { + private final ViewDataBinding binding; + + public interface PlaceGridItemClickListener { + void clickedGridItem(int position); + } + + GridViewHolder(ViewDataBinding binding, final PlaceGridItemClickListener listener) { + super(binding.getRoot()); + binding.getRoot().setOnClickListener(v -> listener.clickedGridItem(getAdapterPosition())); + this.binding = binding; + } + public void bind(CategoryAttraction info) { + binding.setVariable(BR.category, info); + binding.setVariable(BR.position, getAdapterPosition()); + binding.executePendingBindings(); + } + } + + private PlaceCategoryGridAdapter() { + super(new DiffUtil.ItemCallback() { + @Override + public boolean areItemsTheSame(CategoryAttraction oldItem, CategoryAttraction newItem) { + if (oldItem == null) { + return newItem == null; + } else { + return newItem != null && oldItem.getCategory().equals(newItem.getCategory()); + } + } + + @Override + public boolean areContentsTheSame(CategoryAttraction oldItem, CategoryAttraction newItem) { + return Objects.equals(oldItem, newItem); + } + }); } - private static final int CATEGORIES_COUNT = 6; - - private static final String[] placeCategoryNames = { - "Upcoming events", - "Want to go", - "Nature", - "Places you like", - "Exercise", - "Educational" - }; - - private static final String[] placeCategoryImages = { - "https://cleanair-images-prod.s3.amazonaws.com/destinations/4c5f9e802b89495da7e485a4449df220.jpg", - "https://cleanair-images-prod.s3.amazonaws.com/destinations/60a86b43597a4b8f8e129f9d6435960a.jpg", - "https://cleanair-images-prod.s3.amazonaws.com/destinations/e6aa6bc0891247c4a4d651f22c028fe6.jpg", - "https://cleanair-images-prod.s3.amazonaws.com/destinations/874f2bd93b5f4bc692cf39d1aaba5ead.jpg", - "https://cleanair-images-prod.s3.amazonaws.com/destinations/4c5f9e802b89495da7e485a4449df220.jpg", - "https://cleanair-images-prod.s3.amazonaws.com/destinations/ad72d3d20dfb4197b76c7b4d211a8eef.jpg" - }; - - public PlaceCategoryGridAdapter(Context context) { - this.context = context; + public PlaceCategoryGridAdapter(Context context, GridViewHolder.PlaceGridItemClickListener listener) { + this(); this.inflater = LayoutInflater.from(context); + this.listener = listener; } + @NonNull @Override - public int getCount() { - return CATEGORIES_COUNT; + public GridViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + Log.d(LOG_LABEL, "onCreateViewHolder"); + + ViewDataBinding binding = DataBindingUtil.inflate(inflater, + R.layout.place_category_grid_item, parent, false); + binding.setVariable(BR.adapter, this); + return new GridViewHolder(binding, this.listener); } @Override - public Object getItem(int position) { - return null; + public void onBindViewHolder(@NonNull GridViewHolder holder, int position) { + CategoryAttraction item = getItem(position); + holder.bind(item); + holder.itemView.setTag(item); } @Override - public long getItemId(int position) { - return 0; + protected CategoryAttraction getItem(int position) { + if (categoryAttractions == null) { + return null; + } + return categoryAttractions.get(position); } @Override - public View getView(int position, View convertView, ViewGroup parent) { - - ViewHolder viewHolder; - - if (convertView == null) { - convertView = inflater.inflate(R.layout.place_category_grid_item, parent, false); - viewHolder = new ViewHolder(); - viewHolder.imageView = convertView.findViewById(R.id.place_category_grid_item_image); - viewHolder.categoryNameView = convertView.findViewById(R.id.place_category_grid_item_name); - - // size the image to fill its square grid box - ViewGroup.LayoutParams params = viewHolder.imageView.getLayoutParams(); - int columnSize = ((GridView) parent).getColumnWidth(); - params.width = columnSize; - params.height = columnSize; - - convertView.setTag(viewHolder); - } else { - viewHolder = (ViewHolder) convertView.getTag(); + public long getItemId(int position) { + CategoryAttraction attraction = getItem(position); + if (attraction == null) { + return -1; } + return attraction.getCategory().code; + } - Glide.with(context) - .load(placeCategoryImages[position]) - .into(viewHolder.imageView); - - viewHolder.imageView.setContentDescription(placeCategoryNames[position]); - viewHolder.categoryNameView.setText(placeCategoryNames[position]); + @Override + public void submitList(List list) { + super.submitList(list); + this.categoryAttractions = list; + } - return convertView; + @Override + public int getItemCount() { + return categoryAttractions == null ? 0 : categoryAttractions.size(); } } diff --git a/app/src/main/java/com/gophillygo/app/data/DestinationDao.java b/app/src/main/java/com/gophillygo/app/data/DestinationDao.java index 007a96ed..3301f970 100644 --- a/app/src/main/java/com/gophillygo/app/data/DestinationDao.java +++ b/app/src/main/java/com/gophillygo/app/data/DestinationDao.java @@ -1,14 +1,21 @@ package com.gophillygo.app.data; import android.arch.lifecycle.LiveData; +import android.arch.lifecycle.MediatorLiveData; import android.arch.persistence.room.Dao; import android.arch.persistence.room.Query; import android.arch.persistence.room.Transaction; +import android.util.Log; +import com.gophillygo.app.data.models.Attraction; import com.gophillygo.app.data.models.AttractionFlag; +import com.gophillygo.app.data.models.CategoryAttraction; +import com.gophillygo.app.data.models.CategoryAttraction.PlaceCategories; +import com.gophillygo.app.data.models.CategoryImage; import com.gophillygo.app.data.models.Destination; import com.gophillygo.app.data.models.DestinationInfo; +import java.util.ArrayList; import java.util.List; /** @@ -17,11 +24,15 @@ @Dao public abstract class DestinationDao implements AttractionDao { + + private static final String LOG_LABEL = "DestinationDao"; + + @Transaction @Query("SELECT destination.*, COUNT(event.id) AS eventCount, attractionflag.option " + "FROM destination " + "LEFT JOIN event ON destination.id = event.destination " + "LEFT JOIN attractionflag " + - "ON destination.id = attractionflag.attractionID AND attractionflag.is_event = 0 " + + "ON destination.id = attractionflag.attraction_id AND attractionflag.is_event = 0 " + "GROUP BY destination.id " + "ORDER BY distance ASC") public abstract LiveData> getAll(); @@ -30,7 +41,7 @@ public abstract class DestinationDao implements AttractionDao { "FROM destination " + "LEFT JOIN event ON destination.id = event.destination " + "LEFT JOIN attractionflag " + - "ON destination.id = attractionflag.attractionID AND attractionflag.is_event = 0 " + + "ON destination.id = attractionflag.attraction_id AND attractionflag.is_event = 0 " + "WHERE destination.id = :destinationId " + "GROUP BY destination.id") abstract LiveData getDestination(long destinationId); @@ -45,6 +56,98 @@ public void bulkUpdate(List destinations) { } } + @Query("SELECT id AS attractionID, 1 AS is_event, image FROM event ORDER BY random() LIMIT 1") + protected abstract LiveData getEventCategoryImage(); + + @Query("SELECT id AS attractionID, 0 AS is_event, destination.image AS image " + + "FROM destination " + + "LEFT JOIN attractionflag " + + "ON destination.id = attractionflag.attraction_id AND attractionflag.is_event = 0 " + + "WHERE attractionflag.option = :flag " + + "ORDER BY random() " + + "LIMIT 1") + public abstract LiveData getRandomImagesForFlag(int flag); + + @Query("SELECT id AS attractionID, 0 AS is_event, destination.image AS image " + + "FROM destination " + + "WHERE nature = 1 " + + "ORDER BY random() " + + "LIMIT 1") + public abstract LiveData getRandomNatureImages(); + + @Query("SELECT id AS attractionID, 0 AS is_event, destination.image AS image " + + "FROM destination " + + "WHERE exercise = 1 " + + "ORDER BY random() " + + "LIMIT 1") + public abstract LiveData getRandomExerciseImages(); + + /** + * Find random representative images for each of the filter buttons on the home screen. + * Must be called from background thread. + * + * One category is events, fetched here instead of {@link EventDao} for convenience and to + * keep requests to a single transaction. The others are based on either destination category + * or user flag. + * + * Gets a randomized set of {@link Attraction} for each filter button. May return duplicates. + * + * @return One randomized image for each of the home page grid of filter categories + */ + @Transaction + public LiveData> getCategoryImages() { + ArrayList categoryAttractions = new ArrayList<>(CategoryAttraction.PlaceCategories.size()); + for (CategoryAttraction.PlaceCategories placeCategory : CategoryAttraction.PlaceCategories.values()) { + categoryAttractions.add(placeCategory.code, new CategoryAttraction(placeCategory.code, "")); + } + Log.d(LOG_LABEL, "created category grid adapter"); + + MediatorLiveData> data = new MediatorLiveData<>(); + LiveData event = getEventCategoryImage(); + addSourceToRandomImagesLiveData(event, PlaceCategories.Events, categoryAttractions, data); + + LiveData wantToGo = getRandomImagesForFlag(AttractionFlag.Option.WantToGo.code); + addSourceToRandomImagesLiveData(wantToGo, PlaceCategories.WantToGo, categoryAttractions, data); + + LiveData liked = getRandomImagesForFlag(AttractionFlag.Option.Liked.code); + addSourceToRandomImagesLiveData(liked, PlaceCategories.Liked, categoryAttractions, data); + + LiveData nature = getRandomNatureImages(); + addSourceToRandomImagesLiveData(nature, PlaceCategories.Nature, categoryAttractions, data); + + LiveData exercise = getRandomExerciseImages(); + addSourceToRandomImagesLiveData(exercise, PlaceCategories.Exercise, categoryAttractions, data); + + LiveData educational = getRandomExerciseImages(); + addSourceToRandomImagesLiveData(educational, PlaceCategories.Educational, categoryAttractions, data); + return data; + } + + /** + * Helper method to build a synthesized `LiveData` object containing all categories + * for the home page grid of random images. + * + * @param source A `LiveData` result to add to the set of `LiveData` + * @param category Which category this is for + * @param categoryAttractions Set of results being built + * @param data The `MediatorLiveData` to attach this source to + */ + private void addSourceToRandomImagesLiveData(LiveData source, + PlaceCategories category, + ArrayList categoryAttractions, + MediatorLiveData> data) { + + data.addSource(source, categoryImage -> { + if (categoryImage == null) { + return; + } + CategoryAttraction attraction = new CategoryAttraction(category.code, categoryImage.getImage()); + categoryAttractions.set(category.code, attraction); + data.postValue(categoryAttractions); + data.removeSource(source); + }); + } + /** * Find those destinations with a given flag set, which are those that should be geofenced. * @@ -54,7 +157,7 @@ public void bulkUpdate(List destinations) { */ @Query(value = "SELECT destination.* FROM destination INNER JOIN attractionflag " + - "ON destination.id = attractionflag.attractionID AND attractionflag.is_event = 0 " + + "ON destination.id = attractionflag.attraction_id AND attractionflag.is_event = 0 " + "WHERE attractionflag.option = :geofenceFlagCode") public abstract List getGeofenceDestinations(int geofenceFlagCode); diff --git a/app/src/main/java/com/gophillygo/app/data/DestinationRepository.java b/app/src/main/java/com/gophillygo/app/data/DestinationRepository.java index c950c5df..11429b0f 100644 --- a/app/src/main/java/com/gophillygo/app/data/DestinationRepository.java +++ b/app/src/main/java/com/gophillygo/app/data/DestinationRepository.java @@ -7,6 +7,7 @@ import android.util.Log; import com.gophillygo.app.data.models.AttractionFlag; +import com.gophillygo.app.data.models.CategoryAttraction; import com.gophillygo.app.data.models.Destination; import com.gophillygo.app.data.models.DestinationInfo; import com.gophillygo.app.data.models.Event; @@ -32,7 +33,12 @@ * https://developer.android.com/topic/libraries/architecture/guide.html */ -class DestinationRepository { +public class DestinationRepository { + + public interface CategoryAttractionCallback { + void gotCategoryAttractions(LiveData> categoryAttractions); + } + private static final String LOG_LABEL = "DestinationRepository"; private final DestinationWebservice webservice; @@ -108,7 +114,6 @@ public void onFailure(@NonNull Call call, @NonNull Throwab Log.e(LOG_LABEL, "Request to POST user flag failed: " + t.toString()); } }); - } @SuppressLint("StaticFieldLeak") @@ -151,4 +156,21 @@ protected LiveData> loadFromDb() { } }.getAsLiveData(); } + + @SuppressLint("StaticFieldLeak") + public void loadCategoryAttractions(CategoryAttractionCallback callback) { + Log.d(LOG_LABEL, "going to load category attractions"); + new AsyncTask>>() { + @Override + protected void onPostExecute(LiveData> categories) { + Log.d(LOG_LABEL, "finished loading category attractions"); + callback.gotCategoryAttractions(categories); + } + + @Override + protected LiveData> doInBackground(Void... voids) { + return destinationDao.getCategoryImages(); + } + }.execute(); + } } diff --git a/app/src/main/java/com/gophillygo/app/data/DestinationViewModel.java b/app/src/main/java/com/gophillygo/app/data/DestinationViewModel.java index b65c2556..1c2852c8 100644 --- a/app/src/main/java/com/gophillygo/app/data/DestinationViewModel.java +++ b/app/src/main/java/com/gophillygo/app/data/DestinationViewModel.java @@ -3,6 +3,7 @@ import android.arch.lifecycle.LiveData; import com.gophillygo.app.data.models.AttractionFlag; +import com.gophillygo.app.data.models.CategoryAttraction; import com.gophillygo.app.data.models.Destination; import com.gophillygo.app.data.models.DestinationInfo; import com.gophillygo.app.data.networkresource.Resource; @@ -42,4 +43,8 @@ public void updateMultipleDestinations(List infos) { public LiveData>> getDestinations() { return destinations; } + + public void getCategoryAttractions(DestinationRepository.CategoryAttractionCallback listener) { + destinationRepository.loadCategoryAttractions(listener); + } } diff --git a/app/src/main/java/com/gophillygo/app/data/EventDao.java b/app/src/main/java/com/gophillygo/app/data/EventDao.java index 2dc4b029..62baedb1 100644 --- a/app/src/main/java/com/gophillygo/app/data/EventDao.java +++ b/app/src/main/java/com/gophillygo/app/data/EventDao.java @@ -3,6 +3,7 @@ import android.arch.lifecycle.LiveData; import android.arch.persistence.room.Dao; +import android.arch.persistence.room.Delete; import android.arch.persistence.room.Query; import android.arch.persistence.room.Transaction; @@ -17,13 +18,14 @@ @Dao public abstract class EventDao implements AttractionDao { + @Transaction @Query("SELECT event.*, destination.name AS destinationName, NULL AS distance, " + "destination.categories AS destinationCategories, attractionflag.option, " + "destination.x, destination.y, destination.distance " + "FROM event " + "LEFT JOIN destination ON destination.id = event.destination " + "LEFT JOIN attractionflag " + - "ON event.id = attractionflag.attractionID AND attractionflag.is_event = 1 " + + "ON event.id = attractionflag.attraction_id AND attractionflag.is_event = 1 " + "ORDER BY event.start_date ASC;") public abstract LiveData> getAll(); @@ -33,7 +35,7 @@ public abstract class EventDao implements AttractionDao { "FROM event " + "LEFT JOIN destination ON destination.id = event.destination " + "LEFT JOIN attractionflag " + - "ON event.id = attractionflag.attractionID AND attractionflag.is_event = 1 " + + "ON event.id = attractionflag.attraction_id AND attractionflag.is_event = 1 " + "WHERE event.id = :eventId") public abstract LiveData getEvent(long eventId); @@ -62,7 +64,7 @@ public void bulkUpdate(List events) { "FROM event " + "INNER JOIN destination ON destination.id = event.destination " + "LEFT JOIN attractionflag " + - "ON event.id = attractionflag.attractionID AND attractionflag.is_event = 1 " + + "ON event.id = attractionflag.attraction_id AND attractionflag.is_event = 1 " + "WHERE attractionflag.option = :geofenceFlagCode") public abstract List getGeofenceEvents(int geofenceFlagCode); @@ -78,7 +80,7 @@ public void bulkUpdate(List events) { "FROM event " + "LEFT JOIN destination ON destination.id = event.destination " + "LEFT JOIN attractionflag " + - "ON event.id = attractionflag.attractionID AND attractionflag.is_event = 1 " + + "ON event.id = attractionflag.attraction_id AND attractionflag.is_event = 1 " + "WHERE event.id = :eventId") public abstract EventInfo getEventInBackground(long eventId); } diff --git a/app/src/main/java/com/gophillygo/app/data/GpgDatabase.java b/app/src/main/java/com/gophillygo/app/data/GpgDatabase.java index f78c5752..e922d8ec 100644 --- a/app/src/main/java/com/gophillygo/app/data/GpgDatabase.java +++ b/app/src/main/java/com/gophillygo/app/data/GpgDatabase.java @@ -8,7 +8,7 @@ import com.gophillygo.app.data.models.Destination; import com.gophillygo.app.data.models.Event; -@Database(version=10, entities={AttractionFlag.class, Destination.class, Event.class}) +@Database(version=11, entities={AttractionFlag.class, Destination.class, Event.class}) @TypeConverters({RoomConverters.class}) public abstract class GpgDatabase extends RoomDatabase { abstract public DestinationDao destinationDao(); diff --git a/app/src/main/java/com/gophillygo/app/data/models/AttractionFlag.java b/app/src/main/java/com/gophillygo/app/data/models/AttractionFlag.java index 6116d3b0..698e729f 100644 --- a/app/src/main/java/com/gophillygo/app/data/models/AttractionFlag.java +++ b/app/src/main/java/com/gophillygo/app/data/models/AttractionFlag.java @@ -2,6 +2,7 @@ import android.arch.persistence.room.ColumnInfo; import android.arch.persistence.room.Entity; +import android.arch.persistence.room.Index; import android.arch.persistence.room.TypeConverter; import android.arch.persistence.room.TypeConverters; import android.support.annotation.DrawableRes; @@ -13,7 +14,8 @@ import java.util.Objects; -@Entity(primaryKeys = {"attractionID", "is_event"}) +@Entity(primaryKeys = {"attraction_id", "is_event"}, + indices = {@Index(value = {"is_event", "option"})}) public class AttractionFlag { public enum Option { @@ -47,7 +49,7 @@ public static Option valueOf(int code) { } } - @ColumnInfo(index = true) + @ColumnInfo(name = "attraction_id", index = true) private final int attractionID; @ColumnInfo(name = "is_event", index = true) @@ -55,6 +57,7 @@ public static Option valueOf(int code) { private final boolean isEvent; @TypeConverters(OptionConverter.class) + @ColumnInfo(index = true) private final Option option; public AttractionFlag(int attractionID, boolean isEvent, Option option) { @@ -99,7 +102,6 @@ public boolean equals(Object o) { @Override public int hashCode() { - return Objects.hash(attractionID, isEvent, option); } } diff --git a/app/src/main/java/com/gophillygo/app/data/models/CategoryAttraction.java b/app/src/main/java/com/gophillygo/app/data/models/CategoryAttraction.java new file mode 100644 index 00000000..b5f14f56 --- /dev/null +++ b/app/src/main/java/com/gophillygo/app/data/models/CategoryAttraction.java @@ -0,0 +1,99 @@ +package com.gophillygo.app.data.models; + +import android.content.Context; +import android.support.annotation.IdRes; +import android.support.annotation.IntDef; +import android.util.SparseArray; + +import com.gophillygo.app.R; + +import java.util.Objects; + +import static com.gophillygo.app.data.models.Filter.EDUCATIONAL_CATEGORY; +import static com.gophillygo.app.data.models.Filter.EXERCISE_CATEGORY; +import static com.gophillygo.app.data.models.Filter.NATURE_CATEGORY; + +/** + * Model for the home screen categories, which display a random representative image from + * places of a given category. + */ +public class CategoryAttraction { + + // category grid cards that are not in the destination category string: two user flags, + // and events + public static final String EVENTS_DB_NAME = "events"; + public static final String NATURE_DB_NAME = "nature"; + public static final String EXERCISE_DB_NAME = "exercise"; + public static final String EDUCATIONAL_DB_NAME = "educational"; + + + // FIXME: reference user-facing strings (displayName) as strings resources + + public enum PlaceCategories { + Events(0, R.string.home_grid_events, EVENTS_DB_NAME), + WantToGo(1, R.string.place_want_to_go_option, AttractionFlag.Option.WantToGo.api_name), + Liked(2, R.string.place_liked_option, AttractionFlag.Option.Liked.api_name), + Nature(3, R.string.nature_category_label, NATURE_DB_NAME), + Exercise(4, R.string.exercise_category_label, EXERCISE_DB_NAME), + Educational(5, R.string.educational_category_label, EDUCATIONAL_DB_NAME); + + private static final SparseArray map = new SparseArray<>(); + static { + for (PlaceCategories category : PlaceCategories.values()) { + map.put(category.code, category); + } + } + + public final int code; + public final @IdRes Integer displayName; + public final String dbName; + + PlaceCategories(int code, Integer displayName, String dbName) { + this.code = code; + this.displayName = displayName; + this.dbName = dbName; + } + + public static int size() { + return map.size(); + } + + public static PlaceCategories valueOf(int code) { + return map.get(code); + } + } + + private final PlaceCategories category; + private final String image; + + public CategoryAttraction(int code, String image) { + this.category = PlaceCategories.valueOf(code); + this.image = image; + } + + public String getImage() { + return image; + } + + public PlaceCategories getCategory() { + return category; + } + + public Integer getDisplayName() { + return category.displayName; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof CategoryAttraction)) return false; + CategoryAttraction that = (CategoryAttraction) o; + return category == that.category && + Objects.equals(image, that.image); + } + + @Override + public int hashCode() { + return Objects.hash(category, image); + } +} diff --git a/app/src/main/java/com/gophillygo/app/data/models/CategoryImage.java b/app/src/main/java/com/gophillygo/app/data/models/CategoryImage.java new file mode 100644 index 00000000..f7a2435c --- /dev/null +++ b/app/src/main/java/com/gophillygo/app/data/models/CategoryImage.java @@ -0,0 +1,35 @@ +package com.gophillygo.app.data.models; + +import android.arch.persistence.room.ColumnInfo; + +import com.google.gson.annotations.SerializedName; + +/** + * Minimal model for representative images chosen from a filter category. + */ +public class CategoryImage { + + private final int attractionID; + @ColumnInfo(name = "is_event") + private final boolean isEvent; + private final String image; + + public CategoryImage(int attractionID, boolean isEvent, String image) { + + this.attractionID = attractionID; + this.isEvent = isEvent; + this.image = image; + } + + public int getAttractionID() { + return attractionID; + } + + public boolean isEvent() { + return isEvent; + } + + public String getImage() { + return image; + } +} diff --git a/app/src/main/java/com/gophillygo/app/data/models/Destination.java b/app/src/main/java/com/gophillygo/app/data/models/Destination.java index c2e21595..ccafaf94 100644 --- a/app/src/main/java/com/gophillygo/app/data/models/Destination.java +++ b/app/src/main/java/com/gophillygo/app/data/models/Destination.java @@ -4,6 +4,7 @@ import android.arch.persistence.room.Embedded; import android.arch.persistence.room.Entity; import android.arch.persistence.room.Ignore; +import android.arch.persistence.room.Index; import com.google.gson.annotations.SerializedName; @@ -12,9 +13,11 @@ import java.util.Objects; -@Entity(inheritSuperIndices = true) +@Entity(inheritSuperIndices = true, + indices = {@Index("educational"), @Index("nature"), @Index("exercise")}) public class Destination extends Attraction { + private static final String LOG_LABEL = "DestinationModel"; private static final NumberFormat numberFormatter = NumberFormat.getNumberInstance(); static { numberFormatter.setMinimumFractionDigits(0); @@ -27,10 +30,14 @@ public class Destination extends Attraction { private final ArrayList categories; + // convenience boolean flags for whether categories are set + @Embedded + private DestinationCategories categoryFlags; + @Embedded private final DestinationLocation location; - @ColumnInfo(name = "watershed_alliance") + @ColumnInfo(name = "watershed_alliance", index = true) @SerializedName("watershed_alliance") private final boolean watershedAlliance; @@ -42,6 +49,7 @@ public class Destination extends Attraction { private final String zipCode; // convenience property to track distance to each destination + @ColumnInfo(index = true) private float distance; @Ignore private String formattedDistance; @@ -70,9 +78,14 @@ public Destination(int id, int placeID, String name, boolean accessible, String public void setDistance(float distance) { this.distance = distance; + // FIXME: move string constant to resources this.formattedDistance = numberFormatter.format(distance) + " mi"; } + public void setCategoryFlags(DestinationCategories categoryFlags) { + this.categoryFlags = categoryFlags; + } + public String getCity() { return city; } @@ -113,6 +126,10 @@ public ArrayList getCategories() { return categories; } + public DestinationCategories getCategoryFlags() { + return categoryFlags; + } + @Override public boolean equals(Object o) { if (this == o) return true; diff --git a/app/src/main/java/com/gophillygo/app/data/models/DestinationCategories.java b/app/src/main/java/com/gophillygo/app/data/models/DestinationCategories.java new file mode 100644 index 00000000..2ec7074a --- /dev/null +++ b/app/src/main/java/com/gophillygo/app/data/models/DestinationCategories.java @@ -0,0 +1,54 @@ +package com.gophillygo.app.data.models; + +import java.util.Objects; + +public class DestinationCategories { + private boolean nature; + private boolean exercise; + private boolean educational; + + public DestinationCategories(boolean nature, boolean exercise, boolean educational) { + this.nature = nature; + this.exercise = exercise; + this.educational = educational; + } + + public boolean isNature() { + return nature; + } + + public boolean isExercise() { + return exercise; + } + + public boolean isEducational() { + return educational; + } + + public void setNature(boolean nature) { + this.nature = nature; + } + + public void setExercise(boolean exercise) { + this.exercise = exercise; + } + + public void setEducational(boolean educational) { + this.educational = educational; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof DestinationCategories)) return false; + DestinationCategories that = (DestinationCategories) o; + return nature == that.nature && + exercise == that.exercise && + educational == that.educational; + } + + @Override + public int hashCode() { + return Objects.hash(nature, exercise, educational); + } +} diff --git a/app/src/main/java/com/gophillygo/app/data/models/EventInfo.java b/app/src/main/java/com/gophillygo/app/data/models/EventInfo.java index 8a9fee01..7567ee86 100644 --- a/app/src/main/java/com/gophillygo/app/data/models/EventInfo.java +++ b/app/src/main/java/com/gophillygo/app/data/models/EventInfo.java @@ -1,10 +1,12 @@ package com.gophillygo.app.data.models; +import android.arch.persistence.room.ColumnInfo; import android.arch.persistence.room.Embedded; import android.arch.persistence.room.Ignore; import java.text.NumberFormat; import java.util.ArrayList; +import java.util.List; import java.util.Objects; public class EventInfo extends AttractionInfo { @@ -20,10 +22,17 @@ public class EventInfo extends AttractionInfo { // fetch fields of related destination from database into these properties private final String destinationName; + private final ArrayList destinationCategories; + @Embedded + @Ignore + private final DestinationCategories categories; + @Embedded private final DestinationLocation location; + + @ColumnInfo(index = true) private final Float distance; @Ignore private final String formattedDistance; @@ -41,6 +50,14 @@ public EventInfo(Event event, String destinationName, ArrayList destinat } else { this.formattedDistance = ""; } + + if (destinationCategories != null && !destinationCategories.isEmpty()) { + this.categories = new DestinationCategories(destinationCategories.contains(Filter.NATURE_CATEGORY), + destinationCategories.contains(Filter.EXERCISE_CATEGORY), + destinationCategories.contains(Filter.EDUCATIONAL_CATEGORY)); + } else { + this.categories = new DestinationCategories(false, false, false); + } } @Override @@ -79,6 +96,10 @@ public ArrayList getDestinationCategories() { return destinationCategories; } + public DestinationCategories getCategories() { + return categories; + } + @Override public boolean equals(Object o) { if (this == o) return true; diff --git a/app/src/main/java/com/gophillygo/app/data/models/Filter.java b/app/src/main/java/com/gophillygo/app/data/models/Filter.java index 24b60d64..42965657 100644 --- a/app/src/main/java/com/gophillygo/app/data/models/Filter.java +++ b/app/src/main/java/com/gophillygo/app/data/models/Filter.java @@ -2,15 +2,19 @@ import android.databinding.BaseObservable; import android.databinding.Bindable; +import android.util.Log; + +import com.gophillygo.app.BR; import java.io.Serializable; import java.util.ArrayList; import java.util.List; import java.util.Objects; -import com.gophillygo.app.BR; - public class Filter extends BaseObservable implements Serializable { + + private static final String LOG_LABEL = "Filter"; + public static final String NATURE_CATEGORY = "Nature"; public static final String EXERCISE_CATEGORY = "Exercise"; public static final String EDUCATIONAL_CATEGORY = "Educational"; @@ -76,7 +80,12 @@ public void reset() { } public boolean matches(DestinationInfo info) { - boolean categoryMatches = categoryMatches(info.getDestination().getCategories()); + DestinationCategories flags = info.getDestination().getCategoryFlags(); + if (flags == null) { + Log.e(LOG_LABEL, "Category flags are missing for destination " + info.getDestination().getName()); + return false; + } + boolean categoryMatches = categoryMatches(flags); boolean flagMatches = flagMatches(info.getFlag()); boolean accessibleMatches = accessibleMatches(info.getDestination().isAccessible()); @@ -84,25 +93,21 @@ public boolean matches(DestinationInfo info) { } public boolean matches(EventInfo info) { - boolean categoryMatches = categoryMatches(info.getDestinationCategories()); + boolean categoryMatches = categoryMatches(info.getCategories()); boolean flagMatches = flagMatches(info.getFlag()); boolean accessibleMatches = accessibleMatches(info.getEvent().isAccessible()); return categoryMatches && flagMatches && accessibleMatches; } - private boolean categoryMatches(List destCategories) { - boolean categoryMatches = categories().isEmpty(); - if (destCategories == null) { - return categoryMatches; - } - - for (String category : categories()) { - if (destCategories.contains(category)) { - categoryMatches = true; - } + private boolean categoryMatches(DestinationCategories categories) { + // match all if not filtering by category + if (!nature && !exercise && !educational) { + return true; } - return categoryMatches; + // match on any filter category + return ((nature && categories.isNature()) || (educational && categories.isEducational()) || + (exercise && categories.isExercise())); } private boolean flagMatches(AttractionFlag flag) { @@ -123,21 +128,6 @@ private boolean accessibleMatches(boolean isAccessible) { return accessibleMatches; } - private List categories() { - List categories = new ArrayList<>(); - if (nature) { - categories.add(NATURE_CATEGORY); - } - if (exercise) { - categories.add(EXERCISE_CATEGORY); - } - if (educational) { - categories.add(EDUCATIONAL_CATEGORY); - } - return categories; - - } - private List flags() { List flags = new ArrayList<>(); if (been) { diff --git a/app/src/main/java/com/gophillygo/app/data/networkresource/AttractionNetworkBoundResource.java b/app/src/main/java/com/gophillygo/app/data/networkresource/AttractionNetworkBoundResource.java index 59fa4436..f78020e0 100644 --- a/app/src/main/java/com/gophillygo/app/data/networkresource/AttractionNetworkBoundResource.java +++ b/app/src/main/java/com/gophillygo/app/data/networkresource/AttractionNetworkBoundResource.java @@ -10,8 +10,10 @@ import com.gophillygo.app.data.models.Attraction; import com.gophillygo.app.data.models.AttractionInfo; import com.gophillygo.app.data.models.Destination; +import com.gophillygo.app.data.models.DestinationCategories; import com.gophillygo.app.data.models.DestinationQueryResponse; import com.gophillygo.app.data.models.Event; +import com.gophillygo.app.data.models.Filter; import java.util.HashSet; import java.util.List; @@ -54,6 +56,14 @@ protected void saveCallResult(@NonNull DestinationQueryResponse response) { Set destinationIds = new HashSet<>(destinations.size()); for (Destination item : destinations) { item.setTimestamp(timestamp); + List categories = item.getCategories(); + if (categories != null && !categories.isEmpty()) { + item.setCategoryFlags(new DestinationCategories(categories.contains(Filter.NATURE_CATEGORY), + categories.contains(Filter.EXERCISE_CATEGORY), + categories.contains(Filter.EDUCATIONAL_CATEGORY))); + } else { + item.setCategoryFlags(new DestinationCategories(false, false, false)); + } destinationIds.add(item.getId()); destinationDao.save(item); } diff --git a/app/src/main/java/com/gophillygo/app/GlideImageDataBinding.java b/app/src/main/java/com/gophillygo/app/utils/GlideImageDataBinding.java similarity index 93% rename from app/src/main/java/com/gophillygo/app/GlideImageDataBinding.java rename to app/src/main/java/com/gophillygo/app/utils/GlideImageDataBinding.java index 66591263..5b118b21 100644 --- a/app/src/main/java/com/gophillygo/app/GlideImageDataBinding.java +++ b/app/src/main/java/com/gophillygo/app/utils/GlideImageDataBinding.java @@ -1,4 +1,4 @@ -package com.gophillygo.app; +package com.gophillygo.app.utils; import android.content.Context; import android.databinding.BindingAdapter; diff --git a/app/src/main/res/layout/activity_events_list.xml b/app/src/main/res/layout/activity_events_list.xml index ae7b3a62..fc872f92 100644 --- a/app/src/main/res/layout/activity_events_list.xml +++ b/app/src/main/res/layout/activity_events_list.xml @@ -16,6 +16,7 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_alignParentTop="true" + android:minHeight="?attr/actionBarSize" android:fitsSystemWindows="true" android:elevation="0dp" android:background="@color/color_primary" @@ -41,8 +42,11 @@ android:id="@+id/empty_events_list" android:layout_width="match_parent" android:layout_height="match_parent" - android:gravity="center" android:visibility="gone" + android:layout_marginTop="20dp" + android:textAlignment="center" + android:textAppearance="@style/Base.TextAppearance.AppCompat.Title" + android:layout_below="@+id/events_list_filter_button_bar" android:text="@string/filter_no_results" /> diff --git a/app/src/main/res/layout/activity_home.xml b/app/src/main/res/layout/activity_home.xml index d89f771c..9353f768 100644 --- a/app/src/main/res/layout/activity_home.xml +++ b/app/src/main/res/layout/activity_home.xml @@ -1,51 +1,54 @@ - - - + tools:context="com.gophillygo.app.activities.HomeActivity" + android:id="@+id/relativeLayout"> - + android:layout_width="match_parent" > - + + + - + + + diff --git a/app/src/main/res/layout/activity_places_list.xml b/app/src/main/res/layout/activity_places_list.xml index 0cdd2081..0def699b 100644 --- a/app/src/main/res/layout/activity_places_list.xml +++ b/app/src/main/res/layout/activity_places_list.xml @@ -3,47 +3,62 @@ xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" tools:context="com.gophillygo.app.activities.PlacesListActivity"> + - + android:layout_height="wrap_content"> - + app:layout_constraintBottom_toTopOf="@+id/places_list_recycler_view" + app:layout_constraintTop_toBottomOf="@+id/places_list_filter_button_bar" /> + - - + - + + - diff --git a/app/src/main/res/layout/place_category_grid_item.xml b/app/src/main/res/layout/place_category_grid_item.xml index a870c292..d9d334ab 100644 --- a/app/src/main/res/layout/place_category_grid_item.xml +++ b/app/src/main/res/layout/place_category_grid_item.xml @@ -1,27 +1,44 @@ - + - + + + + + + + android:gravity="center" + android:id="@+id/place_category_grid_relative_layout"> + + - + - + + diff --git a/app/src/main/res/layout/place_list_item.xml b/app/src/main/res/layout/place_list_item.xml index 2fa66591..c983857e 100644 --- a/app/src/main/res/layout/place_list_item.xml +++ b/app/src/main/res/layout/place_list_item.xml @@ -1,5 +1,7 @@ @@ -16,11 +18,15 @@ android:paddingBottom="10dp" android:layout_margin="10dp"> + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 817df928..7ea3f318 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -90,4 +90,5 @@ Please set location mode to \"High Accuracy\" to receive notifications when places you want to go are nearby. Nearby GoPhillyGo destinations you want to go visit Are you visiting %1$s now? Learn more! + Upcoming events