/*
* Copyright (C) 2015 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not
* use this file except in compliance with the License. You may obtain a copy of
* the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations under
* the License.
*/
package com.android.deskclock.worldclock;
import android.content.Context;
import android.media.AudioManager;
import android.os.Bundle;
import android.support.v7.widget.SearchView;
import android.text.TextUtils;
import android.text.format.DateFormat;
import android.util.ArraySet;
import android.util.TypedValue;
import android.view.LayoutInflater;
import android.view.Menu;
import android.view.MenuItem;
import android.view.View;
import android.view.ViewGroup;
import android.widget.BaseAdapter;
import android.widget.CheckBox;
import android.widget.CompoundButton;
import android.widget.ListView;
import android.widget.SectionIndexer;
import android.widget.TextView;
import com.android.deskclock.BaseActivity;
import com.android.deskclock.R;
import com.android.deskclock.Utils;
import com.android.deskclock.actionbarmenu.AbstractMenuItemController;
import com.android.deskclock.actionbarmenu.ActionBarMenuManager;
import com.android.deskclock.actionbarmenu.MenuItemControllerFactory;
import com.android.deskclock.actionbarmenu.NavUpMenuItemController;
import com.android.deskclock.actionbarmenu.SearchMenuItemController;
import com.android.deskclock.actionbarmenu.SettingMenuItemController;
import com.android.deskclock.data.City;
import com.android.deskclock.data.DataModel;
import java.util.ArrayList;
import java.util.Calendar;
import java.util.Collection;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.TimeZone;
/**
* This activity allows the user to alter the cities selected for display.
*
* Note, it is possible for two instances of this Activity to exist simultaneously:
*
*
* - Clock Tab-> Tap Floating Action Button
* - Digital Widget -> Tap any city clock
*
*
* As a result, {@link #onResume()} conservatively refreshes itself from the backing
* {@link DataModel} which may have changed since this activity was last displayed.
*/
public final class CitySelectionActivity extends BaseActivity {
/** The list of all selected and unselected cities, indexed and possibly filtered. */
private ListView mCitiesList;
/** The adapter that presents all of the selected and unselected cities. */
private CityAdapter mCitiesAdapter;
/** Manages all action bar menu display and click handling. */
private final ActionBarMenuManager mActionBarMenuManager = new ActionBarMenuManager(this);
/** Menu item controller for search view. */
private SearchMenuItemController mSearchMenuItemController;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setVolumeControlStream(AudioManager.STREAM_ALARM);
setContentView(R.layout.cities_activity);
mSearchMenuItemController =
new SearchMenuItemController(new SearchView.OnQueryTextListener() {
@Override
public boolean onQueryTextSubmit(String query) {
return false;
}
@Override
public boolean onQueryTextChange(String query) {
mCitiesAdapter.filter(query);
updateFastScrolling();
return true;
}
}, savedInstanceState);
mCitiesAdapter = new CityAdapter(this, mSearchMenuItemController);
mActionBarMenuManager.addMenuItemController(new NavUpMenuItemController(this))
.addMenuItemController(mSearchMenuItemController)
.addMenuItemController(new SortOrderMenuItemController())
.addMenuItemController(new SettingMenuItemController(this))
.addMenuItemController(MenuItemControllerFactory.getInstance()
.buildMenuItemControllers(this));
mCitiesList = (ListView) findViewById(R.id.cities_list);
mCitiesList.setScrollBarStyle(View.SCROLLBARS_INSIDE_INSET);
mCitiesList.setAdapter(mCitiesAdapter);
updateFastScrolling();
}
@Override
public void onSaveInstanceState(Bundle bundle) {
super.onSaveInstanceState(bundle);
mSearchMenuItemController.saveInstance(bundle);
}
@Override
public void onResume() {
super.onResume();
// Recompute the contents of the adapter before displaying on screen.
mCitiesAdapter.refresh();
}
@Override
public void onPause() {
super.onPause();
// Save the selected cities.
DataModel.getDataModel().setSelectedCities(mCitiesAdapter.getSelectedCities());
}
@Override
public boolean onCreateOptionsMenu(Menu menu) {
mActionBarMenuManager.createOptionsMenu(menu, getMenuInflater());
return true;
}
@Override
public boolean onPrepareOptionsMenu(Menu menu) {
mActionBarMenuManager.prepareShowMenu(menu);
return true;
}
@Override
public boolean onOptionsItemSelected(MenuItem item) {
if (mActionBarMenuManager.handleMenuItemClick(item)) {
return true;
}
return super.onOptionsItemSelected(item);
}
/**
* Fast scrolling is only enabled while no filtering is happening.
*/
private void updateFastScrolling() {
final boolean enabled = !mCitiesAdapter.isFiltering();
mCitiesList.setFastScrollAlwaysVisible(enabled);
mCitiesList.setFastScrollEnabled(enabled);
}
/**
* This adapter presents data in 2 possible modes. If selected cities exist the format is:
*
*
* Selected Cities
* City 1 (alphabetically first)
* City 2 (alphabetically second)
* ...
* A City A1 (alphabetically first starting with A)
* City A2 (alphabetically second starting with A)
* ...
* B City B1 (alphabetically first starting with B)
* City B2 (alphabetically second starting with B)
* ...
*
*
* If selected cities do not exist, that section is removed and all that remains is:
*
*
* A City A1 (alphabetically first starting with A)
* City A2 (alphabetically second starting with A)
* ...
* B City B1 (alphabetically first starting with B)
* City B2 (alphabetically second starting with B)
* ...
*
*/
private static final class CityAdapter extends BaseAdapter implements View.OnClickListener,
CompoundButton.OnCheckedChangeListener, SectionIndexer {
/** The type of the single optional "Selected Cities" header entry. */
private static final int VIEW_TYPE_SELECTED_CITIES_HEADER = 0;
/** The type of each city entry. */
private static final int VIEW_TYPE_CITY = 1;
private final Context mContext;
private final LayoutInflater mInflater;
/** The 12-hour time pattern for the current locale. */
private final String mPattern12;
/** The 24-hour time pattern for the current locale. */
private final String mPattern24;
/** {@code true} time should honor {@link #mPattern24}; {@link #mPattern12} otherwise. */
private boolean mIs24HoursMode;
/** A calendar used to format time in a particular timezone. */
private final Calendar mCalendar;
/** The list of cities which may be filtered by a search term. */
private List mFilteredCities = Collections.emptyList();
/** A mutable set of cities currently selected by the user. */
private final Set mUserSelectedCities = new ArraySet<>();
/** The number of user selections at the top of the adapter to avoid indexing. */
private int mOriginalUserSelectionCount;
/** The precomputed section headers. */
private String[] mSectionHeaders;
/** The corresponding location of each precomputed section header. */
private Integer[] mSectionHeaderPositions;
/** Menu item controller for search. Search query is maintained here. */
private final SearchMenuItemController mSearchMenuItemController;
public CityAdapter(Context context, SearchMenuItemController searchMenuItemController) {
mContext = context;
mSearchMenuItemController = searchMenuItemController;
mInflater = LayoutInflater.from(context);
mCalendar = Calendar.getInstance();
mCalendar.setTimeInMillis(System.currentTimeMillis());
final Locale locale = Locale.getDefault();
mPattern24 = DateFormat.getBestDateTimePattern(locale, "Hm");
String pattern12 = DateFormat.getBestDateTimePattern(locale, "hma");
if (TextUtils.getLayoutDirectionFromLocale(locale) == View.LAYOUT_DIRECTION_RTL) {
// There's an RTL layout bug that causes jank when fast-scrolling through
// the list in 12-hour mode in an RTL locale. We can work around this by
// ensuring the strings are the same length by using "hh" instead of "h".
pattern12 = pattern12.replaceAll("h", "hh");
}
mPattern12 = pattern12;
}
@Override
public int getCount() {
final int headerCount = hasHeader() ? 1 : 0;
return headerCount + mFilteredCities.size();
}
@Override
public City getItem(int position) {
if (hasHeader()) {
final int itemViewType = getItemViewType(position);
switch (itemViewType) {
case VIEW_TYPE_SELECTED_CITIES_HEADER:
return null;
case VIEW_TYPE_CITY:
return mFilteredCities.get(position - 1);
}
throw new IllegalStateException("unexpected item view type: " + itemViewType);
}
return mFilteredCities.get(position);
}
@Override
public long getItemId(int position) {
return position;
}
@Override
public synchronized View getView(int position, View view, ViewGroup parent) {
final int itemViewType = getItemViewType(position);
switch (itemViewType) {
case VIEW_TYPE_SELECTED_CITIES_HEADER:
if (view == null) {
view = mInflater.inflate(R.layout.city_list_header, parent, false);
}
return view;
case VIEW_TYPE_CITY:
final City city = getItem(position);
final TimeZone timeZone = city.getTimeZone();
// Inflate a new view if necessary.
if (view == null) {
view = mInflater.inflate(R.layout.city_list_item, parent, false);
final TextView index = (TextView) view.findViewById(R.id.index);
final TextView name = (TextView) view.findViewById(R.id.city_name);
final TextView time = (TextView) view.findViewById(R.id.city_time);
final CheckBox selected = (CheckBox) view.findViewById(R.id.city_onoff);
view.setTag(new CityItemHolder(index, name, time, selected));
}
// Bind data into the child views.
final CityItemHolder holder = (CityItemHolder) view.getTag();
holder.selected.setTag(city);
holder.selected.setChecked(mUserSelectedCities.contains(city));
holder.selected.setContentDescription(city.getName());
holder.selected.setOnCheckedChangeListener(this);
holder.name.setText(city.getName(), TextView.BufferType.SPANNABLE);
holder.time.setText(getTimeCharSequence(timeZone));
final boolean showIndex = getShowIndex(position);
holder.index.setVisibility(showIndex ? View.VISIBLE : View.INVISIBLE);
if (showIndex) {
switch (getCitySort()) {
case NAME:
holder.index.setText(city.getIndexString());
holder.index.setTextSize(TypedValue.COMPLEX_UNIT_SP, 24);
break;
case UTC_OFFSET:
holder.index.setText(Utils.getGMTHourOffset(timeZone, false));
holder.index.setTextSize(TypedValue.COMPLEX_UNIT_SP, 14);
break;
}
}
// skip checkbox and other animations
view.jumpDrawablesToCurrentState();
view.setOnClickListener(this);
return view;
}
throw new IllegalStateException("unexpected item view type: " + itemViewType);
}
@Override
public int getViewTypeCount() {
return 2;
}
@Override
public int getItemViewType(int position) {
return hasHeader() && position == 0 ? VIEW_TYPE_SELECTED_CITIES_HEADER : VIEW_TYPE_CITY;
}
@Override
public void onCheckedChanged(CompoundButton b, boolean checked) {
final City city = (City) b.getTag();
if (checked) {
mUserSelectedCities.add(city);
b.announceForAccessibility(mContext.getString(R.string.city_checked,
city.getName()));
} else {
mUserSelectedCities.remove(city);
b.announceForAccessibility(mContext.getString(R.string.city_unchecked,
city.getName()));
}
}
@Override
public void onClick(View v) {
final CheckBox b = (CheckBox) v.findViewById(R.id.city_onoff);
b.setChecked(!b.isChecked());
}
@Override
public Object[] getSections() {
if (mSectionHeaders == null) {
// Make an educated guess at the expected number of sections.
final int approximateSectionCount = getCount() / 5;
final List sections = new ArrayList<>(approximateSectionCount);
final List positions = new ArrayList<>(approximateSectionCount);
// Add a section for the "Selected Cities" header if it exists.
if (hasHeader()) {
sections.add("+");
positions.add(0);
}
for (int position = 0; position < getCount(); position++) {
// Add a section if this position should show the section index.
if (getShowIndex(position)) {
final City city = getItem(position);
switch (getCitySort()) {
case NAME:
sections.add(city.getIndexString());
break;
case UTC_OFFSET:
final TimeZone timezone = city.getTimeZone();
sections.add(Utils.getGMTHourOffset(timezone, Utils.isPreL()));
break;
}
positions.add(position);
}
}
mSectionHeaders = sections.toArray(new String[sections.size()]);
mSectionHeaderPositions = positions.toArray(new Integer[positions.size()]);
}
return mSectionHeaders;
}
@Override
public int getPositionForSection(int sectionIndex) {
return getSections().length == 0 ? 0 : mSectionHeaderPositions[sectionIndex];
}
@Override
public int getSectionForPosition(int position) {
if (getSections().length == 0) {
return 0;
}
for (int i = 0; i < mSectionHeaderPositions.length - 2; i++) {
if (position < mSectionHeaderPositions[i]) continue;
if (position >= mSectionHeaderPositions[i + 1]) continue;
return i;
}
return mSectionHeaderPositions.length - 1;
}
/**
* Clear the section headers to force them to be recomputed if they are now stale.
*/
private void clearSectionHeaders() {
mSectionHeaders = null;
mSectionHeaderPositions = null;
}
/**
* Rebuilds all internal data structures from scratch.
*/
private void refresh() {
// Update the 12/24 hour mode.
mIs24HoursMode = DateFormat.is24HourFormat(mContext);
// Refresh the user selections.
final List selected = DataModel.getDataModel().getSelectedCities();
mUserSelectedCities.clear();
mUserSelectedCities.addAll(selected);
mOriginalUserSelectionCount = selected.size();
// Recompute section headers.
clearSectionHeaders();
// Recompute filtered cities.
filter(mSearchMenuItemController.getQueryText());
}
/**
* Filter the cities using the given {@code queryText}.
*/
private void filter(String queryText) {
mSearchMenuItemController.setQueryText(queryText);
final String query = queryText.trim().toUpperCase();
// Compute the filtered list of cities.
final List filteredCities;
if (TextUtils.isEmpty(query)) {
filteredCities = DataModel.getDataModel().getAllCities();
} else {
final List unselected = DataModel.getDataModel().getUnselectedCities();
filteredCities = new ArrayList<>(unselected.size());
for (City city : unselected) {
if (city.getNameUpperCase().startsWith(query)) {
filteredCities.add(city);
}
}
}
// Swap in the filtered list of cities and notify of the data change.
mFilteredCities = filteredCities;
notifyDataSetChanged();
}
private boolean isFiltering() {
return !TextUtils.isEmpty(mSearchMenuItemController.getQueryText().trim());
}
private Collection getSelectedCities() { return mUserSelectedCities; }
private boolean hasHeader() { return !isFiltering() && mOriginalUserSelectionCount > 0; }
private DataModel.CitySort getCitySort() {
return DataModel.getDataModel().getCitySort();
}
private Comparator getCitySortComparator() {
return DataModel.getDataModel().getCityIndexComparator();
}
private CharSequence getTimeCharSequence(TimeZone timeZone) {
mCalendar.setTimeZone(timeZone);
return DateFormat.format(mIs24HoursMode ? mPattern24 : mPattern12, mCalendar);
}
private boolean getShowIndex(int position) {
// Indexes are never displayed on filtered cities.
if (isFiltering()) {
return false;
}
if (hasHeader()) {
// None of the original user selections should show their index.
if (position <= mOriginalUserSelectionCount) {
return false;
}
// The first item after the original user selections must always show its index.
if (position == mOriginalUserSelectionCount + 1) {
return true;
}
} else {
// None of the original user selections should show their index.
if (position < mOriginalUserSelectionCount) {
return false;
}
// The first item after the original user selections must always show its index.
if (position == mOriginalUserSelectionCount) {
return true;
}
}
// Otherwise compare the city with its predecessor to test if it is a header.
final City priorCity = getItem(position - 1);
final City city = getItem(position);
return getCitySortComparator().compare(priorCity, city) != 0;
}
/**
* Cache the child views of each city item view.
*/
private static final class CityItemHolder {
private final TextView index;
private final TextView name;
private final TextView time;
private final CheckBox selected;
public CityItemHolder(TextView index, TextView name, TextView time, CheckBox selected) {
this.index = index;
this.name = name;
this.time = time;
this.selected = selected;
}
}
}
private final class SortOrderMenuItemController extends AbstractMenuItemController {
private static final int SORT_MENU_RES_ID = R.id.menu_item_sort;
@Override
public int getId() {
return SORT_MENU_RES_ID;
}
@Override
public void showMenuItem(Menu menu) {
final MenuItem sortMenuItem = menu.findItem(SORT_MENU_RES_ID);
final String title;
if (DataModel.getDataModel().getCitySort() == DataModel.CitySort.NAME) {
title = getString(R.string.menu_item_sort_by_gmt_offset);
} else {
title = getString(R.string.menu_item_sort_by_name);
}
sortMenuItem.setTitle(title);
sortMenuItem.setVisible(true);
}
@Override
public boolean handleMenuItemClick(MenuItem item) {
// Save the new sort order.
DataModel.getDataModel().toggleCitySort();
// Section headers are influenced by sort order and must be cleared.
mCitiesAdapter.clearSectionHeaders();
// Honor the new sort order in the adapter.
mCitiesAdapter.filter(mSearchMenuItemController.getQueryText());
return true;
}
}
}