diff --git a/CODEOWNERS b/CODEOWNERS new file mode 100644 index 00000000..2a6bcb28 --- /dev/null +++ b/CODEOWNERS @@ -0,0 +1 @@ +* @udacity/active-public-content \ No newline at end of file diff --git a/app/build.gradle b/app/build.gradle index 4d476dfe..79af6450 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -18,6 +18,9 @@ android { proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' } } + buildTypes.each { + it.buildConfigField 'String', 'OPEN_WEATHER_MAP_API_KEY', MyOpenWeatherMapApiKey + } } dependencies { @@ -29,5 +32,7 @@ dependencies { compile 'com.android.support:appcompat-v7:22.2.0' compile 'com.android.support:design:22.2.0' compile 'com.android.support:recyclerview-v7:22.2.0' + compile 'com.google.android.apps.muzei:muzei-api:2.0' compile 'com.google.android.gms:play-services-gcm:7.5.0' + compile 'com.google.android.gms:play-services-location:7.5.0' } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 4a106a1c..a8893d32 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -39,6 +39,9 @@ android:protectionLevel="signature" /> + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/com/example/android/sunshine/app/ForecastAdapter.java b/app/src/main/java/com/example/android/sunshine/app/ForecastAdapter.java index 7c8b3705..8bcb4b93 100644 --- a/app/src/main/java/com/example/android/sunshine/app/ForecastAdapter.java +++ b/app/src/main/java/com/example/android/sunshine/app/ForecastAdapter.java @@ -127,13 +127,16 @@ public void onBindViewHolder(ForecastAdapterViewHolder forecastAdapterViewHolder mCursor.moveToPosition(position); int weatherId = mCursor.getInt(ForecastFragment.COL_WEATHER_CONDITION_ID); int defaultImage; + boolean useLongToday; switch (getItemViewType(position)) { case VIEW_TYPE_TODAY: defaultImage = Utility.getArtResourceForWeatherCondition(weatherId); + useLongToday = true; break; default: defaultImage = Utility.getIconResourceForWeatherCondition(weatherId); + useLongToday = false; } if ( Utility.usingLocalGraphics(mContext) ) { @@ -154,7 +157,7 @@ public void onBindViewHolder(ForecastAdapterViewHolder forecastAdapterViewHolder long dateInMillis = mCursor.getLong(ForecastFragment.COL_WEATHER_DATE); // Find TextView and set formatted date on it - forecastAdapterViewHolder.mDateView.setText(Utility.getFriendlyDayString(mContext, dateInMillis)); + forecastAdapterViewHolder.mDateView.setText(Utility.getFriendlyDayString(mContext, dateInMillis, useLongToday)); // Read weather forecast from cursor String description = Utility.getStringForWeatherCondition(mContext, weatherId); diff --git a/app/src/main/java/com/example/android/sunshine/app/ForecastFragment.java b/app/src/main/java/com/example/android/sunshine/app/ForecastFragment.java index 13ed1d2b..2c4537e3 100644 --- a/app/src/main/java/com/example/android/sunshine/app/ForecastFragment.java +++ b/app/src/main/java/com/example/android/sunshine/app/ForecastFragment.java @@ -26,7 +26,6 @@ import android.os.Bundle; import android.preference.PreferenceManager; import android.support.design.widget.AppBarLayout; -import android.support.design.widget.CoordinatorLayout; import android.support.v4.app.Fragment; import android.support.v4.app.LoaderManager; import android.support.v4.content.CursorLoader; @@ -55,12 +54,11 @@ public class ForecastFragment extends Fragment implements LoaderManager.LoaderCallbacks, SharedPreferences.OnSharedPreferenceChangeListener { public static final String LOG_TAG = ForecastFragment.class.getSimpleName(); private ForecastAdapter mForecastAdapter; - private RecyclerView mRecyclerView; - private int mPosition = RecyclerView.NO_POSITION; private boolean mUseTodayLayout, mAutoSelectView; private int mChoiceMode; private boolean mHoldForTransition; + private long mInitialSelectedDate = -1; private static final String SELECTED_KEY = "selected_position"; @@ -196,7 +194,6 @@ public void onClick(Long date, ForecastAdapter.ForecastAdapterViewHolder vh) { locationSetting, date), vh ); - mPosition = vh.getAdapterPosition(); } }, emptyView, mChoiceMode); @@ -246,11 +243,6 @@ public void onScrolled(RecyclerView recyclerView, int dx, int dy) { // or magically appeared to take advantage of room, but data or place in the app was never // actually *lost*. if (savedInstanceState != null) { - if (savedInstanceState.containsKey(SELECTED_KEY)) { - // The Recycler View probably hasn't even been populated yet. Actually perform the - // swapout in onLoadFinished. - mPosition = savedInstanceState.getInt(SELECTED_KEY); - } mForecastAdapter.onRestoreInstanceState(savedInstanceState); } @@ -304,11 +296,6 @@ private void openPreferredLocationInMap() { @Override public void onSaveInstanceState(Bundle outState) { // When tablets rotate, the currently selected list item needs to be saved. - // When no item is selected, mPosition will be set to RecyclerView.NO_POSITION, - // so check for that before storing. - if (mPosition != RecyclerView.NO_POSITION) { - outState.putInt(SELECTED_KEY, mPosition); - } mForecastAdapter.onSaveInstanceState(outState); super.onSaveInstanceState(outState); } @@ -340,11 +327,6 @@ public Loader onCreateLoader(int i, Bundle bundle) { @Override public void onLoadFinished(Loader loader, Cursor data) { mForecastAdapter.swapCursor(data); - if (mPosition != RecyclerView.NO_POSITION) { - // If we don't need to restart the loader, and there's a desired position to restore - // to, do so now. - mRecyclerView.smoothScrollToPosition(mPosition); - } updateEmptyView(); if ( data.getCount() == 0 ) { getActivity().supportStartPostponedEnterTransition(); @@ -356,11 +338,27 @@ public boolean onPreDraw() { // we see Children. if (mRecyclerView.getChildCount() > 0) { mRecyclerView.getViewTreeObserver().removeOnPreDrawListener(this); - int itemPosition = mForecastAdapter.getSelectedItemPosition(); - if ( RecyclerView.NO_POSITION == itemPosition ) itemPosition = 0; - RecyclerView.ViewHolder vh = mRecyclerView.findViewHolderForAdapterPosition(itemPosition); - if ( null != vh && mAutoSelectView ) { - mForecastAdapter.selectView( vh ); + int position = mForecastAdapter.getSelectedItemPosition(); + if (position == RecyclerView.NO_POSITION && + -1 != mInitialSelectedDate) { + Cursor data = mForecastAdapter.getCursor(); + int count = data.getCount(); + int dateColumn = data.getColumnIndex(WeatherContract.WeatherEntry.COLUMN_DATE); + for ( int i = 0; i < count; i++ ) { + data.moveToPosition(i); + if ( data.getLong(dateColumn) == mInitialSelectedDate ) { + position = i; + break; + } + } + } + if (position == RecyclerView.NO_POSITION) position = 0; + // If we don't need to restart the loader, and there's a desired position to restore + // to, do so now. + mRecyclerView.smoothScrollToPosition(position); + RecyclerView.ViewHolder vh = mRecyclerView.findViewHolderForAdapterPosition(position); + if (null != vh && mAutoSelectView) { + mForecastAdapter.selectView(vh); } if ( mHoldForTransition ) { getActivity().supportStartPostponedEnterTransition(); @@ -396,6 +394,10 @@ public void setUseTodayLayout(boolean useTodayLayout) { } } + public void setInitialSelectedDate(long initialSelectedDate) { + mInitialSelectedDate = initialSelectedDate; + } + /* Updates the empty list view with contextually relevant information that the user can use to determine why they aren't seeing weather. diff --git a/app/src/main/java/com/example/android/sunshine/app/LocationEditTextPreference.java b/app/src/main/java/com/example/android/sunshine/app/LocationEditTextPreference.java index eb84c05e..b91398c8 100644 --- a/app/src/main/java/com/example/android/sunshine/app/LocationEditTextPreference.java +++ b/app/src/main/java/com/example/android/sunshine/app/LocationEditTextPreference.java @@ -15,6 +15,7 @@ */ package com.example.android.sunshine.app; +import android.app.Activity; import android.app.AlertDialog; import android.app.Dialog; import android.content.Context; @@ -24,9 +25,17 @@ import android.text.Editable; import android.text.TextWatcher; import android.util.AttributeSet; +import android.view.View; +import android.view.ViewGroup; import android.widget.Button; import android.widget.EditText; +import com.google.android.gms.common.ConnectionResult; +import com.google.android.gms.common.GoogleApiAvailability; +import com.google.android.gms.common.GooglePlayServicesNotAvailableException; +import com.google.android.gms.common.GooglePlayServicesRepairableException; +import com.google.android.gms.location.places.ui.PlacePicker; + public class LocationEditTextPreference extends EditTextPreference { static final private int DEFAULT_MINIMUM_LOCATION_LENGTH = 2; private int mMinLength; @@ -42,8 +51,57 @@ public LocationEditTextPreference(Context context, AttributeSet attrs) { } finally { a.recycle(); } + + // Check to see if Google Play services is available. The Place Picker API is available + // through Google Play services, so if this is false, we'll just carry on as though this + // feature does not exist. If it is true, however, we can add a widget to our preference. + GoogleApiAvailability apiAvailability = GoogleApiAvailability.getInstance(); + int resultCode = apiAvailability.isGooglePlayServicesAvailable(getContext()); + if (resultCode == ConnectionResult.SUCCESS) { + // Add the get current location widget to our location preference + setWidgetLayoutResource(R.layout.pref_current_location); + } } + @Override + protected View onCreateView(ViewGroup parent) { + View view = super.onCreateView(parent); + View currentLocation = view.findViewById(R.id.current_location); + currentLocation.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + Context context = getContext(); + + // Launch the Place Picker so that the user can specify their location, and then + // return the result to SettingsActivity. + PlacePicker.IntentBuilder builder = new PlacePicker.IntentBuilder(); + + + // We are in a view right now, not an activity. So we need to get ourselves + // an activity that we can use to start our Place Picker intent. By using + // SettingsActivity in this way, we can ensure the result of the Place Picker + // intent comes to the right place for us to process it. + Activity settingsActivity = (SettingsActivity) context; + try { + settingsActivity.startActivityForResult( + builder.build(context), SettingsActivity.PLACE_PICKER_REQUEST); + + } catch (GooglePlayServicesNotAvailableException + | GooglePlayServicesRepairableException e) { + // What did you do?? This is why we check Google Play services in onResume!!! + // The difference in these exception types is the difference between pausing + // for a moment to prompt the user to update/install/enable Play services vs + // complete and utter failure. + // If you prefer to manage Google Play services dynamically, then you can do so + // by responding to these exceptions in the right moment. But I prefer a cleaner + // user experience, which is why you check all of this when the app resumes, + // and then disable/enable features based on that availability. + } + } + }); + + return view; + } @Override protected void showDialog(Bundle state) { diff --git a/app/src/main/java/com/example/android/sunshine/app/MainActivity.java b/app/src/main/java/com/example/android/sunshine/app/MainActivity.java index b7c718b9..a2f0b780 100644 --- a/app/src/main/java/com/example/android/sunshine/app/MainActivity.java +++ b/app/src/main/java/com/example/android/sunshine/app/MainActivity.java @@ -30,6 +30,7 @@ import android.view.MenuItem; import android.view.View; +import com.example.android.sunshine.app.data.WeatherContract; import com.example.android.sunshine.app.gcm.RegistrationIntentService; import com.example.android.sunshine.app.sync.SunshineSyncAdapter; import com.google.android.gms.common.ConnectionResult; @@ -49,6 +50,7 @@ public class MainActivity extends AppCompatActivity implements ForecastFragment. protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); mLocation = Utility.getPreferredLocation(this); + Uri contentUri = getIntent() != null ? getIntent().getData() : null; setContentView(R.layout.activity_main); Toolbar toolbar = (Toolbar)findViewById(R.id.toolbar); @@ -64,8 +66,14 @@ protected void onCreate(Bundle savedInstanceState) { // adding or replacing the detail fragment using a // fragment transaction. if (savedInstanceState == null) { + DetailFragment fragment = new DetailFragment(); + if (contentUri != null) { + Bundle args = new Bundle(); + args.putParcelable(DetailFragment.DETAIL_URI, contentUri); + fragment.setArguments(args); + } getSupportFragmentManager().beginTransaction() - .replace(R.id.weather_detail_container, new DetailFragment(), DETAILFRAGMENT_TAG) + .replace(R.id.weather_detail_container, fragment, DETAILFRAGMENT_TAG) .commit(); } } else { @@ -76,6 +84,10 @@ protected void onCreate(Bundle savedInstanceState) { ForecastFragment forecastFragment = ((ForecastFragment)getSupportFragmentManager() .findFragmentById(R.id.fragment_forecast)); forecastFragment.setUseTodayLayout(!mTwoPane); + if (contentUri != null) { + forecastFragment.setInitialSelectedDate( + WeatherContract.WeatherEntry.getDateFromUri(contentUri)); + } SunshineSyncAdapter.initializeSyncAdapter(this); diff --git a/app/src/main/java/com/example/android/sunshine/app/SettingsActivity.java b/app/src/main/java/com/example/android/sunshine/app/SettingsActivity.java index afd1cace..ae20b520 100644 --- a/app/src/main/java/com/example/android/sunshine/app/SettingsActivity.java +++ b/app/src/main/java/com/example/android/sunshine/app/SettingsActivity.java @@ -24,9 +24,16 @@ import android.preference.Preference; import android.preference.PreferenceActivity; import android.preference.PreferenceManager; - +import android.support.design.widget.Snackbar; +import android.text.TextUtils; +import android.util.Log; +import android.view.View; +import android.widget.ImageView; import com.example.android.sunshine.app.data.WeatherContract; import com.example.android.sunshine.app.sync.SunshineSyncAdapter; +import com.google.android.gms.location.places.Place; +import com.google.android.gms.location.places.ui.PlacePicker; +import com.google.android.gms.maps.model.LatLng; /** * A {@link PreferenceActivity} that presents a set of application settings. @@ -38,6 +45,8 @@ */ public class SettingsActivity extends PreferenceActivity implements Preference.OnPreferenceChangeListener, SharedPreferences.OnSharedPreferenceChangeListener { + protected final static int PLACE_PICKER_REQUEST = 9090; + private ImageView mAttribution; @Override public void onCreate(Bundle savedInstanceState) { @@ -50,6 +59,19 @@ public void onCreate(Bundle savedInstanceState) { bindPreferenceSummaryToValue(findPreference(getString(R.string.pref_location_key))); bindPreferenceSummaryToValue(findPreference(getString(R.string.pref_units_key))); bindPreferenceSummaryToValue(findPreference(getString(R.string.pref_art_pack_key))); + + + // If we are using a PlacePicker location, we need to show attributions. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB) { + mAttribution = new ImageView(this); + mAttribution.setImageResource(R.drawable.powered_by_google_light); + + if (!Utility.isLocationLatLonAvailable(this)) { + mAttribution.setVisibility(View.GONE); + } + + setListFooter(mAttribution); + } } // Registers a shared preference change listener that gets notified when preferences change @@ -133,7 +155,17 @@ public boolean onPreferenceChange(Preference preference, Object value) { public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key) { if ( key.equals(getString(R.string.pref_location_key)) ) { // we've changed the location - // first clear locationStatus + // Wipe out any potential PlacePicker latlng values so that we can use this text entry. + SharedPreferences.Editor editor = sharedPreferences.edit(); + editor.remove(getString(R.string.pref_location_latitude)); + editor.remove(getString(R.string.pref_location_longitude)); + editor.commit(); + + // Remove attributions for our any PlacePicker locations. + if (mAttribution != null) { + mAttribution.setVisibility(View.GONE); + } + Utility.resetLocationStatus(this); SunshineSyncAdapter.syncImmediately(this); } else if ( key.equals(getString(R.string.pref_units_key)) ) { @@ -154,4 +186,59 @@ public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, Strin public Intent getParentActivityIntent() { return super.getParentActivityIntent().addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP); } + + @Override + protected void onActivityResult(int requestCode, int resultCode, Intent data) { + // Check to see if the result is from our Place Picker intent + if (requestCode == PLACE_PICKER_REQUEST) { + // Make sure the request was successful + if (resultCode == RESULT_OK) { + Place place = PlacePicker.getPlace(data, this); + String address = place.getAddress().toString(); + LatLng latLong = place.getLatLng(); + + // If the provided place doesn't have an address, we'll form a display-friendly + // string from the latlng values. + if (TextUtils.isEmpty(address)) { + address = String.format("(%.2f, %.2f)",latLong.latitude, latLong.longitude); + } + + SharedPreferences sharedPreferences = + PreferenceManager.getDefaultSharedPreferences(this); + SharedPreferences.Editor editor = sharedPreferences.edit(); + editor.putString(getString(R.string.pref_location_key), address); + + // Also store the latitude and longitude so that we can use these to get a precise + // result from our weather service. We cannot expect the weather service to + // understand addresses that Google formats. + editor.putFloat(getString(R.string.pref_location_latitude), + (float) latLong.latitude); + editor.putFloat(getString(R.string.pref_location_longitude), + (float) latLong.longitude); + editor.commit(); + + // Tell the SyncAdapter that we've changed the location, so that we can update + // our UI with new values. We need to do this manually because we are responding + // to the PlacePicker widget result here instead of allowing the + // LocationEditTextPreference to handle these changes and invoke our callbacks. + Preference locationPreference = findPreference(getString(R.string.pref_location_key)); + setPreferenceSummary(locationPreference, address); + + // Add attributions for our new PlacePicker location. + if (mAttribution != null) { + mAttribution.setVisibility(View.VISIBLE); + } else { + // For pre-Honeycomb devices, we cannot add a footer, so we will use a snackbar + View rootView = findViewById(android.R.id.content); + Snackbar.make(rootView, getString(R.string.attribution_text), + Snackbar.LENGTH_LONG).show(); + } + + Utility.resetLocationStatus(this); + SunshineSyncAdapter.syncImmediately(this); + } + } else { + super.onActivityResult(requestCode, resultCode, data); + } + } } diff --git a/app/src/main/java/com/example/android/sunshine/app/Utility.java b/app/src/main/java/com/example/android/sunshine/app/Utility.java index 7e1dacbe..5f559620 100644 --- a/app/src/main/java/com/example/android/sunshine/app/Utility.java +++ b/app/src/main/java/com/example/android/sunshine/app/Utility.java @@ -30,6 +30,30 @@ import java.util.Locale; public class Utility { + // We'll default our latlong to 0. Yay, "Earth!" + public static float DEFAULT_LATLONG = 0F; + + public static boolean isLocationLatLonAvailable(Context context) { + SharedPreferences prefs + = PreferenceManager.getDefaultSharedPreferences(context); + return prefs.contains(context.getString(R.string.pref_location_latitude)) + && prefs.contains(context.getString(R.string.pref_location_longitude)); + } + + public static float getLocationLatitude(Context context) { + SharedPreferences prefs + = PreferenceManager.getDefaultSharedPreferences(context); + return prefs.getFloat(context.getString(R.string.pref_location_latitude), + DEFAULT_LATLONG); + } + + public static float getLocationLongitude(Context context) { + SharedPreferences prefs + = PreferenceManager.getDefaultSharedPreferences(context); + return prefs.getFloat(context.getString(R.string.pref_location_longitude), + DEFAULT_LATLONG); + } + public static String getPreferredLocation(Context context) { SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); return prefs.getString(context.getString(R.string.pref_location_key), @@ -72,7 +96,7 @@ static String formatDate(long dateInMilliseconds) { * @param dateInMillis The date in milliseconds * @return a user-friendly representation of the date. */ - public static String getFriendlyDayString(Context context, long dateInMillis) { + public static String getFriendlyDayString(Context context, long dateInMillis, boolean displayLongToday) { // The day string for forecast uses the following logic: // For today: "Today, June 8" // For tomorrow: "Tomorrow" @@ -87,7 +111,7 @@ public static String getFriendlyDayString(Context context, long dateInMillis) { // If the date we're building the String for is today's date, the format // is "Today, June 24" - if (julianDay == currentJulianDay) { + if (displayLongToday && julianDay == currentJulianDay) { String today = context.getString(R.string.today); int formatId = R.string.format_full_friendly_date; return String.format(context.getString( @@ -339,7 +363,7 @@ public static String getStringForWeatherCondition(Context context, int weatherId stringId = R.string.condition_2xx; } else if (weatherId >= 300 && weatherId <= 321) { stringId = R.string.condition_3xx; - } else switch(weatherId) { + } else switch (weatherId) { case 500: stringId = R.string.condition_500; break; @@ -502,6 +526,42 @@ public static String getStringForWeatherCondition(Context context, int weatherId return context.getString(stringId); } + /* + * Helper method to provide the correct image according to the weather condition id returned + * by the OpenWeatherMap call. + * + * @param weatherId from OpenWeatherMap API response + * @return A string URL to an appropriate image or null if no mapping is found + */ + public static String getImageUrlForWeatherCondition(int weatherId) { + // Based on weather code data found at: + // http://bugs.openweathermap.org/projects/api/wiki/Weather_Condition_Codes + if (weatherId >= 200 && weatherId <= 232) { + return "http://upload.wikimedia.org/wikipedia/commons/2/28/Thunderstorm_in_Annemasse,_France.jpg"; + } else if (weatherId >= 300 && weatherId <= 321) { + return "http://upload.wikimedia.org/wikipedia/commons/a/a0/Rain_on_leaf_504605006.jpg"; + } else if (weatherId >= 500 && weatherId <= 504) { + return "http://upload.wikimedia.org/wikipedia/commons/6/6c/Rain-on-Thassos.jpg"; + } else if (weatherId == 511) { + return "http://upload.wikimedia.org/wikipedia/commons/b/b8/Fresh_snow.JPG"; + } else if (weatherId >= 520 && weatherId <= 531) { + return "http://upload.wikimedia.org/wikipedia/commons/6/6c/Rain-on-Thassos.jpg"; + } else if (weatherId >= 600 && weatherId <= 622) { + return "http://upload.wikimedia.org/wikipedia/commons/b/b8/Fresh_snow.JPG"; + } else if (weatherId >= 701 && weatherId <= 761) { + return "http://upload.wikimedia.org/wikipedia/commons/e/e6/Westminster_fog_-_London_-_UK.jpg"; + } else if (weatherId == 761 || weatherId == 781) { + return "http://upload.wikimedia.org/wikipedia/commons/d/dc/Raised_dust_ahead_of_a_severe_thunderstorm_1.jpg"; + } else if (weatherId == 800) { + return "http://upload.wikimedia.org/wikipedia/commons/7/7e/A_few_trees_and_the_sun_(6009964513).jpg"; + } else if (weatherId == 801) { + return "http://upload.wikimedia.org/wikipedia/commons/e/e7/Cloudy_Blue_Sky_(5031259890).jpg"; + } else if (weatherId >= 802 && weatherId <= 804) { + return "http://upload.wikimedia.org/wikipedia/commons/5/54/Cloudy_hills_in_Elis,_Greece_2.jpg"; + } + return null; + } + /** * Returns true if the network is available or about to become available. * diff --git a/app/src/main/java/com/example/android/sunshine/app/muzei/WeatherMuzeiSource.java b/app/src/main/java/com/example/android/sunshine/app/muzei/WeatherMuzeiSource.java new file mode 100644 index 00000000..dcfc24cb --- /dev/null +++ b/app/src/main/java/com/example/android/sunshine/app/muzei/WeatherMuzeiSource.java @@ -0,0 +1,79 @@ +/* + * 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.example.android.sunshine.app.muzei; + +import android.content.Intent; +import android.database.Cursor; +import android.net.Uri; + +import com.example.android.sunshine.app.MainActivity; +import com.example.android.sunshine.app.Utility; +import com.example.android.sunshine.app.data.WeatherContract; +import com.example.android.sunshine.app.sync.SunshineSyncAdapter; +import com.google.android.apps.muzei.api.Artwork; +import com.google.android.apps.muzei.api.MuzeiArtSource; + +/** + * Muzei source that changes your background based on the current weather conditions + */ +public class WeatherMuzeiSource extends MuzeiArtSource { + private static final String[] FORECAST_COLUMNS = new String[]{ + WeatherContract.WeatherEntry.COLUMN_WEATHER_ID, + WeatherContract.WeatherEntry.COLUMN_SHORT_DESC + }; + // these indices must match the projection + private static final int INDEX_WEATHER_ID = 0; + private static final int INDEX_SHORT_DESC = 1; + + public WeatherMuzeiSource() { + super("WeatherMuzeiSource"); + } + + @Override + protected void onHandleIntent(Intent intent) { + super.onHandleIntent(intent); + boolean dataUpdated = intent != null && + SunshineSyncAdapter.ACTION_DATA_UPDATED.equals(intent.getAction()); + if (dataUpdated && isEnabled()) { + onUpdate(UPDATE_REASON_OTHER); + } + } + + @Override + protected void onUpdate(int reason) { + String location = Utility.getPreferredLocation(this); + Uri weatherForLocationUri = WeatherContract.WeatherEntry.buildWeatherLocationWithStartDate( + location, System.currentTimeMillis()); + Cursor cursor = getContentResolver().query(weatherForLocationUri, FORECAST_COLUMNS, null, + null, WeatherContract.WeatherEntry.COLUMN_DATE + " ASC"); + if (cursor.moveToFirst()) { + int weatherId = cursor.getInt(INDEX_WEATHER_ID); + String desc = cursor.getString(INDEX_SHORT_DESC); + + String imageUrl = Utility.getImageUrlForWeatherCondition(weatherId); + // Only publish a new wallpaper if we have a valid image + if (imageUrl != null) { + publishArtwork(new Artwork.Builder() + .imageUri(Uri.parse(imageUrl)) + .title(desc) + .byline(location) + .viewIntent(new Intent(this, MainActivity.class)) + .build()); + } + } + cursor.close(); + } +} diff --git a/app/src/main/java/com/example/android/sunshine/app/sync/SunshineSyncAdapter.java b/app/src/main/java/com/example/android/sunshine/app/sync/SunshineSyncAdapter.java index e2482357..9aa52c96 100644 --- a/app/src/main/java/com/example/android/sunshine/app/sync/SunshineSyncAdapter.java +++ b/app/src/main/java/com/example/android/sunshine/app/sync/SunshineSyncAdapter.java @@ -30,10 +30,12 @@ import android.util.Log; import com.bumptech.glide.Glide; +import com.example.android.sunshine.app.BuildConfig; import com.example.android.sunshine.app.MainActivity; import com.example.android.sunshine.app.R; import com.example.android.sunshine.app.Utility; import com.example.android.sunshine.app.data.WeatherContract; +import com.example.android.sunshine.app.muzei.WeatherMuzeiSource; import org.json.JSONArray; import org.json.JSONException; @@ -52,6 +54,8 @@ public class SunshineSyncAdapter extends AbstractThreadedSyncAdapter { public final String LOG_TAG = SunshineSyncAdapter.class.getSimpleName(); + public static final String ACTION_DATA_UPDATED = + "com.example.android.sunshine.app.ACTION_DATA_UPDATED"; // Interval at which to sync with the weather, in seconds. // 60 seconds (1 minute) * 180 = 3 hours public static final int SYNC_INTERVAL = 60 * 180; @@ -90,7 +94,13 @@ public SunshineSyncAdapter(Context context, boolean autoInitialize) { @Override public void onPerformSync(Account account, Bundle extras, String authority, ContentProviderClient provider, SyncResult syncResult) { Log.d(LOG_TAG, "Starting sync"); - String locationQuery = Utility.getPreferredLocation(getContext()); + + // We no longer need just the location String, but also potentially the latitude and + // longitude, in case we are syncing based on a new Place Picker API result. + Context context = getContext(); + String locationQuery = Utility.getPreferredLocation(context); + String locationLatitude = String.valueOf(Utility.getLocationLatitude(context)); + String locationLongitude = String.valueOf(Utility.getLocationLongitude(context)); // These two need to be declared outside the try/catch // so that they can be closed in the finally block. @@ -111,15 +121,32 @@ public void onPerformSync(Account account, Bundle extras, String authority, Cont final String FORECAST_BASE_URL = "http://api.openweathermap.org/data/2.5/forecast/daily?"; final String QUERY_PARAM = "q"; + final String LAT_PARAM = "lat"; + final String LON_PARAM = "lon"; final String FORMAT_PARAM = "mode"; final String UNITS_PARAM = "units"; final String DAYS_PARAM = "cnt"; + final String APPID_PARAM = "APPID"; + + Uri.Builder uriBuilder = Uri.parse(FORECAST_BASE_URL).buildUpon(); + + // Instead of always building the query based off of the location string, we want to + // potentially build a query using a lat/lon value. This will be the case when we are + // syncing based off of a new location from the Place Picker API. So we need to check + // if we have a lat/lon to work with, and use those when we do. Otherwise, the weather + // service may not understand the location address provided by the Place Picker API + // and the user could end up with no weather! The horror! + if (Utility.isLocationLatLonAvailable(context)) { + uriBuilder.appendQueryParameter(LAT_PARAM, locationLatitude) + .appendQueryParameter(LON_PARAM, locationLongitude); + } else { + uriBuilder.appendQueryParameter(QUERY_PARAM, locationQuery); + } - Uri builtUri = Uri.parse(FORECAST_BASE_URL).buildUpon() - .appendQueryParameter(QUERY_PARAM, locationQuery) - .appendQueryParameter(FORMAT_PARAM, format) + Uri builtUri = uriBuilder.appendQueryParameter(FORMAT_PARAM, format) .appendQueryParameter(UNITS_PARAM, units) .appendQueryParameter(DAYS_PARAM, Integer.toString(numDays)) + .appendQueryParameter(APPID_PARAM, BuildConfig.OPEN_WEATHER_MAP_API_KEY) .build(); URL url = new URL(builtUri.toString()); @@ -224,6 +251,7 @@ private void getWeatherDataFromJson(String forecastJsonStr, try { JSONObject forecastJson = new JSONObject(forecastJsonStr); + Context context = getContext(); // do we have an error? if ( forecastJson.has(OWM_MESSAGE_CODE) ) { @@ -338,6 +366,8 @@ private void getWeatherDataFromJson(String forecastJsonStr, WeatherContract.WeatherEntry.COLUMN_DATE + " <= ?", new String[] {Long.toString(dayTime.setJulianDay(julianStartDay-1))}); + updateWidgets(); + updateMuzei(); notifyWeather(); } Log.d(LOG_TAG, "Sync Complete. " + cVVector.size() + " Inserted"); @@ -350,6 +380,24 @@ private void getWeatherDataFromJson(String forecastJsonStr, } } + private void updateWidgets() { + Context context = getContext(); + // Setting the package ensures that only components in our app will receive the broadcast + Intent dataUpdatedIntent = new Intent(ACTION_DATA_UPDATED) + .setPackage(context.getPackageName()); + context.sendBroadcast(dataUpdatedIntent); + } + + private void updateMuzei() { + // Muzei is only compatible with Jelly Bean MR1+ devices, so there's no need to update the + // Muzei background on lower API level devices + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) { + Context context = getContext(); + context.startService(new Intent(ACTION_DATA_UPDATED) + .setClass(context, WeatherMuzeiSource.class)); + } + } + private void notifyWeather() { Context context = getContext(); //checking the last update and notify if it' the first of the day diff --git a/app/src/main/java/com/example/android/sunshine/app/widget/DetailWidgetProvider.java b/app/src/main/java/com/example/android/sunshine/app/widget/DetailWidgetProvider.java new file mode 100644 index 00000000..85896eba --- /dev/null +++ b/app/src/main/java/com/example/android/sunshine/app/widget/DetailWidgetProvider.java @@ -0,0 +1,89 @@ +package com.example.android.sunshine.app.widget; + +import android.annotation.TargetApi; +import android.app.PendingIntent; +import android.appwidget.AppWidgetManager; +import android.appwidget.AppWidgetProvider; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.os.Build; +import android.support.annotation.NonNull; +import android.support.v4.app.TaskStackBuilder; +import android.widget.RemoteViews; + +import com.example.android.sunshine.app.DetailActivity; +import com.example.android.sunshine.app.MainActivity; +import com.example.android.sunshine.app.R; +import com.example.android.sunshine.app.sync.SunshineSyncAdapter; + +/** + * Provider for a scrollable weather detail widget + */ +@TargetApi(Build.VERSION_CODES.HONEYCOMB) +public class DetailWidgetProvider extends AppWidgetProvider { + public void onUpdate(Context context, AppWidgetManager appWidgetManager, int[] appWidgetIds) { + // Perform this loop procedure for each App Widget that belongs to this provider + for (int appWidgetId : appWidgetIds) { + RemoteViews views = new RemoteViews(context.getPackageName(), R.layout.widget_detail); + + // Create an Intent to launch MainActivity + Intent intent = new Intent(context, MainActivity.class); + PendingIntent pendingIntent = PendingIntent.getActivity(context, 0, intent, 0); + views.setOnClickPendingIntent(R.id.widget, pendingIntent); + + // Set up the collection + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.ICE_CREAM_SANDWICH) { + setRemoteAdapter(context, views); + } else { + setRemoteAdapterV11(context, views); + } + boolean useDetailActivity = context.getResources() + .getBoolean(R.bool.use_detail_activity); + Intent clickIntentTemplate = useDetailActivity + ? new Intent(context, DetailActivity.class) + : new Intent(context, MainActivity.class); + PendingIntent clickPendingIntentTemplate = TaskStackBuilder.create(context) + .addNextIntentWithParentStack(clickIntentTemplate) + .getPendingIntent(0, PendingIntent.FLAG_UPDATE_CURRENT); + views.setPendingIntentTemplate(R.id.widget_list, clickPendingIntentTemplate); + views.setEmptyView(R.id.widget_list, R.id.widget_empty); + + // Tell the AppWidgetManager to perform an update on the current app widget + appWidgetManager.updateAppWidget(appWidgetId, views); + } + } + + @Override + public void onReceive(@NonNull Context context, @NonNull Intent intent) { + super.onReceive(context, intent); + if (SunshineSyncAdapter.ACTION_DATA_UPDATED.equals(intent.getAction())) { + AppWidgetManager appWidgetManager = AppWidgetManager.getInstance(context); + int[] appWidgetIds = appWidgetManager.getAppWidgetIds( + new ComponentName(context, getClass())); + appWidgetManager.notifyAppWidgetViewDataChanged(appWidgetIds, R.id.widget_list); + } + } + + /** + * Sets the remote adapter used to fill in the list items + * + * @param views RemoteViews to set the RemoteAdapter + */ + @TargetApi(Build.VERSION_CODES.ICE_CREAM_SANDWICH) + private void setRemoteAdapter(Context context, @NonNull final RemoteViews views) { + views.setRemoteAdapter(R.id.widget_list, + new Intent(context, DetailWidgetRemoteViewsService.class)); + } + + /** + * Sets the remote adapter used to fill in the list items + * + * @param views RemoteViews to set the RemoteAdapter + */ + @SuppressWarnings("deprecation") + private void setRemoteAdapterV11(Context context, @NonNull final RemoteViews views) { + views.setRemoteAdapter(0, R.id.widget_list, + new Intent(context, DetailWidgetRemoteViewsService.class)); + } +} diff --git a/app/src/main/java/com/example/android/sunshine/app/widget/DetailWidgetRemoteViewsService.java b/app/src/main/java/com/example/android/sunshine/app/widget/DetailWidgetRemoteViewsService.java new file mode 100644 index 00000000..f31c3ba9 --- /dev/null +++ b/app/src/main/java/com/example/android/sunshine/app/widget/DetailWidgetRemoteViewsService.java @@ -0,0 +1,175 @@ +package com.example.android.sunshine.app.widget; + +import android.annotation.TargetApi; +import android.content.Intent; +import android.database.Cursor; +import android.graphics.Bitmap; +import android.net.Uri; +import android.os.Binder; +import android.os.Build; +import android.util.Log; +import android.widget.AdapterView; +import android.widget.RemoteViews; +import android.widget.RemoteViewsService; + +import com.bumptech.glide.Glide; +import com.bumptech.glide.request.target.Target; +import com.example.android.sunshine.app.R; +import com.example.android.sunshine.app.Utility; +import com.example.android.sunshine.app.data.WeatherContract; + +import java.util.concurrent.ExecutionException; + +/** + * RemoteViewsService controlling the data being shown in the scrollable weather detail widget + */ +@TargetApi(Build.VERSION_CODES.HONEYCOMB) +public class DetailWidgetRemoteViewsService extends RemoteViewsService { + public final String LOG_TAG = DetailWidgetRemoteViewsService.class.getSimpleName(); + private static final String[] FORECAST_COLUMNS = { + WeatherContract.WeatherEntry.TABLE_NAME + "." + WeatherContract.WeatherEntry._ID, + WeatherContract.WeatherEntry.COLUMN_DATE, + WeatherContract.WeatherEntry.COLUMN_WEATHER_ID, + WeatherContract.WeatherEntry.COLUMN_SHORT_DESC, + WeatherContract.WeatherEntry.COLUMN_MAX_TEMP, + WeatherContract.WeatherEntry.COLUMN_MIN_TEMP + }; + // these indices must match the projection + static final int INDEX_WEATHER_ID = 0; + static final int INDEX_WEATHER_DATE = 1; + static final int INDEX_WEATHER_CONDITION_ID = 2; + static final int INDEX_WEATHER_DESC = 3; + static final int INDEX_WEATHER_MAX_TEMP = 4; + static final int INDEX_WEATHER_MIN_TEMP = 5; + + @Override + public RemoteViewsFactory onGetViewFactory(Intent intent) { + return new RemoteViewsFactory() { + private Cursor data = null; + + @Override + public void onCreate() { + // Nothing to do + } + + @Override + public void onDataSetChanged() { + if (data != null) { + data.close(); + } + // This method is called by the app hosting the widget (e.g., the launcher) + // However, our ContentProvider is not exported so it doesn't have access to the + // data. Therefore we need to clear (and finally restore) the calling identity so + // that calls use our process and permission + final long identityToken = Binder.clearCallingIdentity(); + String location = Utility.getPreferredLocation(DetailWidgetRemoteViewsService.this); + Uri weatherForLocationUri = WeatherContract.WeatherEntry + .buildWeatherLocationWithStartDate(location, System.currentTimeMillis()); + data = getContentResolver().query(weatherForLocationUri, + FORECAST_COLUMNS, + null, + null, + WeatherContract.WeatherEntry.COLUMN_DATE + " ASC"); + Binder.restoreCallingIdentity(identityToken); + } + + @Override + public void onDestroy() { + if (data != null) { + data.close(); + data = null; + } + } + + @Override + public int getCount() { + return data == null ? 0 : data.getCount(); + } + + @Override + public RemoteViews getViewAt(int position) { + if (position == AdapterView.INVALID_POSITION || + data == null || !data.moveToPosition(position)) { + return null; + } + RemoteViews views = new RemoteViews(getPackageName(), + R.layout.widget_detail_list_item); + int weatherId = data.getInt(INDEX_WEATHER_CONDITION_ID); + int weatherArtResourceId = Utility.getIconResourceForWeatherCondition(weatherId); + Bitmap weatherArtImage = null; + if ( !Utility.usingLocalGraphics(DetailWidgetRemoteViewsService.this) ) { + String weatherArtResourceUrl = Utility.getArtUrlForWeatherCondition( + DetailWidgetRemoteViewsService.this, weatherId); + try { + weatherArtImage = Glide.with(DetailWidgetRemoteViewsService.this) + .load(weatherArtResourceUrl) + .asBitmap() + .error(weatherArtResourceId) + .into(Target.SIZE_ORIGINAL, Target.SIZE_ORIGINAL).get(); + } catch (InterruptedException | ExecutionException e) { + Log.e(LOG_TAG, "Error retrieving large icon from " + weatherArtResourceUrl, e); + } + } + String description = data.getString(INDEX_WEATHER_DESC); + long dateInMillis = data.getLong(INDEX_WEATHER_DATE); + String formattedDate = Utility.getFriendlyDayString( + DetailWidgetRemoteViewsService.this, dateInMillis, false); + double maxTemp = data.getDouble(INDEX_WEATHER_MAX_TEMP); + double minTemp = data.getDouble(INDEX_WEATHER_MIN_TEMP); + String formattedMaxTemperature = + Utility.formatTemperature(DetailWidgetRemoteViewsService.this, maxTemp); + String formattedMinTemperature = + Utility.formatTemperature(DetailWidgetRemoteViewsService.this, minTemp); + if (weatherArtImage != null) { + views.setImageViewBitmap(R.id.widget_icon, weatherArtImage); + } else { + views.setImageViewResource(R.id.widget_icon, weatherArtResourceId); + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.ICE_CREAM_SANDWICH_MR1) { + setRemoteContentDescription(views, description); + } + views.setTextViewText(R.id.widget_date, formattedDate); + views.setTextViewText(R.id.widget_description, description); + views.setTextViewText(R.id.widget_high_temperature, formattedMaxTemperature); + views.setTextViewText(R.id.widget_low_temperature, formattedMinTemperature); + + final Intent fillInIntent = new Intent(); + String locationSetting = + Utility.getPreferredLocation(DetailWidgetRemoteViewsService.this); + Uri weatherUri = WeatherContract.WeatherEntry.buildWeatherLocationWithDate( + locationSetting, + dateInMillis); + fillInIntent.setData(weatherUri); + views.setOnClickFillInIntent(R.id.widget_list_item, fillInIntent); + return views; + } + + @TargetApi(Build.VERSION_CODES.ICE_CREAM_SANDWICH_MR1) + private void setRemoteContentDescription(RemoteViews views, String description) { + views.setContentDescription(R.id.widget_icon, description); + } + + @Override + public RemoteViews getLoadingView() { + return new RemoteViews(getPackageName(), R.layout.widget_detail_list_item); + } + + @Override + public int getViewTypeCount() { + return 1; + } + + @Override + public long getItemId(int position) { + if (data.moveToPosition(position)) + return data.getLong(INDEX_WEATHER_ID); + return position; + } + + @Override + public boolean hasStableIds() { + return true; + } + }; + } +} diff --git a/app/src/main/java/com/example/android/sunshine/app/widget/TodayWidgetIntentService.java b/app/src/main/java/com/example/android/sunshine/app/widget/TodayWidgetIntentService.java new file mode 100644 index 00000000..0ab88541 --- /dev/null +++ b/app/src/main/java/com/example/android/sunshine/app/widget/TodayWidgetIntentService.java @@ -0,0 +1,151 @@ +/* + * 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.example.android.sunshine.app.widget; + +import android.annotation.TargetApi; +import android.app.IntentService; +import android.app.PendingIntent; +import android.appwidget.AppWidgetManager; +import android.content.ComponentName; +import android.content.Intent; +import android.database.Cursor; +import android.net.Uri; +import android.os.Build; +import android.os.Bundle; +import android.util.DisplayMetrics; +import android.util.TypedValue; +import android.widget.RemoteViews; + +import com.example.android.sunshine.app.MainActivity; +import com.example.android.sunshine.app.R; +import com.example.android.sunshine.app.Utility; +import com.example.android.sunshine.app.data.WeatherContract; + +/** + * IntentService which handles updating all Today widgets with the latest data + */ +public class TodayWidgetIntentService extends IntentService { + private static final String[] FORECAST_COLUMNS = { + WeatherContract.WeatherEntry.COLUMN_WEATHER_ID, + WeatherContract.WeatherEntry.COLUMN_SHORT_DESC, + WeatherContract.WeatherEntry.COLUMN_MAX_TEMP, + WeatherContract.WeatherEntry.COLUMN_MIN_TEMP + }; + // these indices must match the projection + private static final int INDEX_WEATHER_ID = 0; + private static final int INDEX_SHORT_DESC = 1; + private static final int INDEX_MAX_TEMP = 2; + private static final int INDEX_MIN_TEMP = 3; + + public TodayWidgetIntentService() { + super("TodayWidgetIntentService"); + } + + @Override + protected void onHandleIntent(Intent intent) { + // Retrieve all of the Today widget ids: these are the widgets we need to update + AppWidgetManager appWidgetManager = AppWidgetManager.getInstance(this); + int[] appWidgetIds = appWidgetManager.getAppWidgetIds(new ComponentName(this, + TodayWidgetProvider.class)); + + // Get today's data from the ContentProvider + String location = Utility.getPreferredLocation(this); + Uri weatherForLocationUri = WeatherContract.WeatherEntry.buildWeatherLocationWithStartDate( + location, System.currentTimeMillis()); + Cursor data = getContentResolver().query(weatherForLocationUri, FORECAST_COLUMNS, null, + null, WeatherContract.WeatherEntry.COLUMN_DATE + " ASC"); + if (data == null) { + return; + } + if (!data.moveToFirst()) { + data.close(); + return; + } + + // Extract the weather data from the Cursor + int weatherId = data.getInt(INDEX_WEATHER_ID); + int weatherArtResourceId = Utility.getArtResourceForWeatherCondition(weatherId); + String description = data.getString(INDEX_SHORT_DESC); + double maxTemp = data.getDouble(INDEX_MAX_TEMP); + double minTemp = data.getDouble(INDEX_MIN_TEMP); + String formattedMaxTemperature = Utility.formatTemperature(this, maxTemp); + String formattedMinTemperature = Utility.formatTemperature(this, minTemp); + data.close(); + + // Perform this loop procedure for each Today widget + for (int appWidgetId : appWidgetIds) { + // Find the correct layout based on the widget's width + int widgetWidth = getWidgetWidth(appWidgetManager, appWidgetId); + int defaultWidth = getResources().getDimensionPixelSize(R.dimen.widget_today_default_width); + int largeWidth = getResources().getDimensionPixelSize(R.dimen.widget_today_large_width); + int layoutId; + if (widgetWidth >= largeWidth) { + layoutId = R.layout.widget_today_large; + } else if (widgetWidth >= defaultWidth) { + layoutId = R.layout.widget_today; + } else { + layoutId = R.layout.widget_today_small; + } + RemoteViews views = new RemoteViews(getPackageName(), layoutId); + + // Add the data to the RemoteViews + views.setImageViewResource(R.id.widget_icon, weatherArtResourceId); + // Content Descriptions for RemoteViews were only added in ICS MR1 + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.ICE_CREAM_SANDWICH_MR1) { + setRemoteContentDescription(views, description); + } + views.setTextViewText(R.id.widget_description, description); + views.setTextViewText(R.id.widget_high_temperature, formattedMaxTemperature); + views.setTextViewText(R.id.widget_low_temperature, formattedMinTemperature); + + // Create an Intent to launch MainActivity + Intent launchIntent = new Intent(this, MainActivity.class); + PendingIntent pendingIntent = PendingIntent.getActivity(this, 0, launchIntent, 0); + views.setOnClickPendingIntent(R.id.widget, pendingIntent); + + // Tell the AppWidgetManager to perform an update on the current app widget + appWidgetManager.updateAppWidget(appWidgetId, views); + } + } + + private int getWidgetWidth(AppWidgetManager appWidgetManager, int appWidgetId) { + // Prior to Jelly Bean, widgets were always their default size + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN) { + return getResources().getDimensionPixelSize(R.dimen.widget_today_default_width); + } + // For Jelly Bean and higher devices, widgets can be resized - the current size can be + // retrieved from the newly added App Widget Options + return getWidgetWidthFromOptions(appWidgetManager, appWidgetId); + } + + @TargetApi(Build.VERSION_CODES.JELLY_BEAN) + private int getWidgetWidthFromOptions(AppWidgetManager appWidgetManager, int appWidgetId) { + Bundle options = appWidgetManager.getAppWidgetOptions(appWidgetId); + if (options.containsKey(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH)) { + int minWidthDp = options.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH); + // The width returned is in dp, but we'll convert it to pixels to match the other widths + DisplayMetrics displayMetrics = getResources().getDisplayMetrics(); + return (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, minWidthDp, + displayMetrics); + } + return getResources().getDimensionPixelSize(R.dimen.widget_today_default_width); + } + + @TargetApi(Build.VERSION_CODES.ICE_CREAM_SANDWICH_MR1) + private void setRemoteContentDescription(RemoteViews views, String description) { + views.setContentDescription(R.id.widget_icon, description); + } +} diff --git a/app/src/main/java/com/example/android/sunshine/app/widget/TodayWidgetProvider.java b/app/src/main/java/com/example/android/sunshine/app/widget/TodayWidgetProvider.java index 04ef261b..81c1dfe6 100644 --- a/app/src/main/java/com/example/android/sunshine/app/widget/TodayWidgetProvider.java +++ b/app/src/main/java/com/example/android/sunshine/app/widget/TodayWidgetProvider.java @@ -15,55 +15,39 @@ */ package com.example.android.sunshine.app.widget; -import android.annotation.TargetApi; -import android.app.PendingIntent; import android.appwidget.AppWidgetManager; import android.appwidget.AppWidgetProvider; import android.content.Context; import android.content.Intent; -import android.os.Build; -import android.widget.RemoteViews; +import android.os.Bundle; +import android.support.annotation.NonNull; -import com.example.android.sunshine.app.MainActivity; -import com.example.android.sunshine.app.R; -import com.example.android.sunshine.app.Utility; +import com.example.android.sunshine.app.sync.SunshineSyncAdapter; /** - * Provider for a widget showing today's weather. + * Provider for a horizontally expandable widget showing today's weather. + * + * Delegates widget updating to {@link TodayWidgetIntentService} to ensure that + * data retrieval is done on a background thread */ public class TodayWidgetProvider extends AppWidgetProvider { + @Override public void onUpdate(Context context, AppWidgetManager appWidgetManager, int[] appWidgetIds) { - int weatherArtResourceId = R.drawable.art_clear; - String description = "Clear"; - double maxTemp = 24; - String formattedMaxTemperature = Utility.formatTemperature(context, maxTemp); - - // Perform this loop procedure for each Today widget - for (int appWidgetId : appWidgetIds) { - int layoutId = R.layout.widget_today_small; - RemoteViews views = new RemoteViews(context.getPackageName(), layoutId); - - // Add the data to the RemoteViews - views.setImageViewResource(R.id.widget_icon, weatherArtResourceId); - // Content Descriptions for RemoteViews were only added in ICS MR1 - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.ICE_CREAM_SANDWICH_MR1) { - setRemoteContentDescription(views, description); - } - views.setTextViewText(R.id.widget_high_temperature, formattedMaxTemperature); - - // Create an Intent to launch MainActivity - Intent launchIntent = new Intent(context, MainActivity.class); - PendingIntent pendingIntent = PendingIntent.getActivity(context, 0, launchIntent, 0); - views.setOnClickPendingIntent(R.id.widget, pendingIntent); + context.startService(new Intent(context, TodayWidgetIntentService.class)); + } - // Tell the AppWidgetManager to perform an update on the current app widget - appWidgetManager.updateAppWidget(appWidgetId, views); - } + @Override + public void onAppWidgetOptionsChanged(Context context, AppWidgetManager appWidgetManager, + int appWidgetId, Bundle newOptions) { + context.startService(new Intent(context, TodayWidgetIntentService.class)); } - @TargetApi(Build.VERSION_CODES.ICE_CREAM_SANDWICH_MR1) - private void setRemoteContentDescription(RemoteViews views, String description) { - views.setContentDescription(R.id.widget_icon, description); + @Override + public void onReceive(@NonNull Context context, @NonNull Intent intent) { + super.onReceive(context, intent); + if (SunshineSyncAdapter.ACTION_DATA_UPDATED.equals(intent.getAction())) { + context.startService(new Intent(context, TodayWidgetIntentService.class)); + } } } diff --git a/app/src/main/res/drawable-hdpi/ic_current_location.png b/app/src/main/res/drawable-hdpi/ic_current_location.png new file mode 100644 index 00000000..85e38726 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_current_location.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_current_location.png b/app/src/main/res/drawable-mdpi/ic_current_location.png new file mode 100644 index 00000000..5684aa7d Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_current_location.png differ diff --git a/app/src/main/res/drawable-nodpi/ic_muzei.png b/app/src/main/res/drawable-nodpi/ic_muzei.png new file mode 100755 index 00000000..b0fe3c49 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/ic_muzei.png differ diff --git a/app/src/main/res/drawable-nodpi/widget_preview_detail.png b/app/src/main/res/drawable-nodpi/widget_preview_detail.png new file mode 100644 index 00000000..7e5a7969 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/widget_preview_detail.png differ diff --git a/app/src/main/res/drawable-nodpi/widget_preview_today.png b/app/src/main/res/drawable-nodpi/widget_preview_today.png new file mode 100644 index 00000000..7be01f0d Binary files /dev/null and b/app/src/main/res/drawable-nodpi/widget_preview_today.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_current_location.png b/app/src/main/res/drawable-xhdpi/ic_current_location.png new file mode 100644 index 00000000..7faa3455 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_current_location.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_current_location.png b/app/src/main/res/drawable-xxhdpi/ic_current_location.png new file mode 100644 index 00000000..d3a1ab08 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_current_location.png differ diff --git a/app/src/main/res/layout/pref_current_location.xml b/app/src/main/res/layout/pref_current_location.xml new file mode 100644 index 00000000..a5d79382 --- /dev/null +++ b/app/src/main/res/layout/pref_current_location.xml @@ -0,0 +1,27 @@ + + + + + + + diff --git a/app/src/main/res/layout/widget_detail.xml b/app/src/main/res/layout/widget_detail.xml new file mode 100644 index 00000000..364d33c0 --- /dev/null +++ b/app/src/main/res/layout/widget_detail.xml @@ -0,0 +1,58 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/widget_detail_list_item.xml b/app/src/main/res/layout/widget_detail_list_item.xml new file mode 100644 index 00000000..2231b659 --- /dev/null +++ b/app/src/main/res/layout/widget_detail_list_item.xml @@ -0,0 +1,93 @@ + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/widget_today.xml b/app/src/main/res/layout/widget_today.xml new file mode 100644 index 00000000..caf01ed0 --- /dev/null +++ b/app/src/main/res/layout/widget_today.xml @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/widget_today_large.xml b/app/src/main/res/layout/widget_today_large.xml new file mode 100644 index 00000000..3838aafd --- /dev/null +++ b/app/src/main/res/layout/widget_today_large.xml @@ -0,0 +1,82 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values-sw600dp/bools.xml b/app/src/main/res/values-sw600dp/bools.xml new file mode 100644 index 00000000..a2b6639a --- /dev/null +++ b/app/src/main/res/values-sw600dp/bools.xml @@ -0,0 +1,4 @@ + + + false + \ No newline at end of file diff --git a/app/src/main/res/values-v11/bools.xml b/app/src/main/res/values-v11/bools.xml new file mode 100644 index 00000000..d187294a --- /dev/null +++ b/app/src/main/res/values-v11/bools.xml @@ -0,0 +1,4 @@ + + + true + \ No newline at end of file diff --git a/app/src/main/res/values/bools.xml b/app/src/main/res/values/bools.xml new file mode 100644 index 00000000..11c11a98 --- /dev/null +++ b/app/src/main/res/values/bools.xml @@ -0,0 +1,5 @@ + + + false + true + \ 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 12b07d17..b547465a 100644 --- a/app/src/main/res/values/dimens.xml +++ b/app/src/main/res/values/dimens.xml @@ -54,6 +54,24 @@ 360dp - 40dp + 110dp 40dp + 40dp + @dimen/widget_today_default_height + 220dp + + 250dp + 180dp + 220dp + @dimen/widget_detail_default_height + + + 24dp + + + 38dp + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5a7c9ff2..93ab7dea 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -45,9 +45,16 @@ loc-status + + loc-latitude + loc-longitude + 94043 + + Use my location + Invalid Location (%1$s)" Validating Location... (%1$s)" @@ -138,6 +145,10 @@ Sunshine Today + Sunshine Details + + + Today\'s weather No Weather Information Available @@ -222,4 +233,7 @@ Heads up: %1$s in %2$s! // TODO: Get the SenderID from the Developer Console + + Powered by Google + diff --git a/app/src/main/res/xml/widget_info_detail.xml b/app/src/main/res/xml/widget_info_detail.xml new file mode 100644 index 00000000..54ad27db --- /dev/null +++ b/app/src/main/res/xml/widget_info_detail.xml @@ -0,0 +1,29 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/xml/widget_info_today.xml b/app/src/main/res/xml/widget_info_today.xml index 9812f0b5..c36d5643 100644 --- a/app/src/main/res/xml/widget_info_today.xml +++ b/app/src/main/res/xml/widget_info_today.xml @@ -14,7 +14,13 @@ limitations under the License. --> \ No newline at end of file + android:previewImage="@drawable/widget_preview_today" + android:resizeMode="horizontal" + android:updatePeriodMillis="0" + tools:ignore="UnusedAttribute" />