diff options
23 files changed, 1470 insertions, 43 deletions
diff --git a/AndroidManifest.xml b/AndroidManifest.xml index 3c675c3a8..f5bd34fa2 100644 --- a/AndroidManifest.xml +++ b/AndroidManifest.xml @@ -63,7 +63,6 @@ <activity android:name=".DeskClock" - android:icon="@mipmap/ic_launcher_alarmclock" android:label="@string/app_label" android:launchMode="singleTask" android:theme="@style/DeskClockTheme" @@ -78,7 +77,6 @@ <activity-alias android:name=".DockClock" android:enabled="false" - android:icon="@mipmap/ic_launcher_alarmclock" android:label="@string/app_label" android:launchMode="singleTask" android:targetActivity="DeskClock" @@ -113,6 +111,17 @@ </activity> <activity + android:name=".worldclock.WidgetCitySelectionActivity" + android:excludeFromRecents="true" + android:label="@string/cities_activity_title" + android:taskAffinity="" + android:theme="@style/CitiesTheme"> + <intent-filter> + <action android:name="android.appwidget.action.APPWIDGET_CONFIGURE"/> + </intent-filter> + </activity> + + <activity android:name=".alarms.AlarmActivity" android:excludeFromRecents="true" android:showOnLockScreen="true" @@ -259,7 +268,6 @@ <receiver android:name="com.android.alarmclock.AnalogAppWidgetProvider" - android:icon="@mipmap/ic_launcher_alarmclock" android:label="@string/analog_gadget"> <intent-filter> <action android:name="android.appwidget.action.APPWIDGET_UPDATE" /> @@ -275,7 +283,6 @@ <receiver android:name="com.android.alarmclock.DigitalAppWidgetProvider" - android:icon="@mipmap/ic_launcher_alarmclock" android:label="@string/digital_gadget"> <intent-filter> <action android:name="android.intent.action.SCREEN_ON" /> @@ -290,6 +297,22 @@ android:resource="@xml/digital_appwidget" /> </receiver> + <receiver + android:name="com.android.alarmclock.CityAppWidgetProvider" + android:label="@string/city_gadget"> + <intent-filter> + <action android:name="android.intent.action.TIME_SET" /> + <action android:name="android.intent.action.SCREEN_ON" /> + <action android:name="android.intent.action.DATE_CHANGED" /> + <action android:name="android.intent.action.LOCALE_CHANGED" /> + <action android:name="android.intent.action.TIMEZONE_CHANGED" /> + <action android:name="android.appwidget.action.APPWIDGET_UPDATE" /> + </intent-filter> + <meta-data + android:name="android.appwidget.provider" + android:resource="@xml/city_appwidget" /> + </receiver> + <!-- Dream (screensaver) implementation --> <service android:name=".Screensaver" diff --git a/res/drawable-nodpi/appwidget_city_clock_preview.png b/res/drawable-nodpi/appwidget_city_clock_preview.png Binary files differnew file mode 100644 index 000000000..176b547f3 --- /dev/null +++ b/res/drawable-nodpi/appwidget_city_clock_preview.png diff --git a/res/drawable-nodpi/appwidget_digital_clock_preview.png b/res/drawable-nodpi/appwidget_digital_clock_preview.png Binary files differindex 88c84a3c1..e0922a14a 100644 --- a/res/drawable-nodpi/appwidget_digital_clock_preview.png +++ b/res/drawable-nodpi/appwidget_digital_clock_preview.png diff --git a/res/layout/city_widget.xml b/res/layout/city_widget.xml new file mode 100644 index 000000000..40592b91f --- /dev/null +++ b/res/layout/city_widget.xml @@ -0,0 +1,65 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- Copyright (C) 2016 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. +--> + +<LinearLayout + xmlns:android="http://schemas.android.com/apk/res/android" + android:id="@+id/city_widget" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:gravity="center" + android:orientation="vertical"> + + <TextClock + android:id="@+id/clock" + style="@style/widget_big_thin" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_gravity="center_horizontal|top" + android:ellipsize="none" + android:includeFontPadding="false" + android:singleLine="true" + android:textColor="@color/clock_white" /> + + <LinearLayout + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_gravity="center_horizontal|top"> + + <TextView + android:id="@+id/city_name" + style="@style/widget_label" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:ellipsize="end" + android:includeFontPadding="false" + android:singleLine="true" + android:textAllCaps="true" + android:textColor="@color/clock_white" /> + + <TextClock + android:id="@+id/city_day" + style="@style/widget_label" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:ellipsize="none" + android:includeFontPadding="false" + android:singleLine="true" + android:textAllCaps="true" + android:textColor="@color/clock_white" /> + + </LinearLayout> + +</LinearLayout>
\ No newline at end of file diff --git a/res/layout/widget_city_list_item.xml b/res/layout/widget_city_list_item.xml new file mode 100644 index 000000000..98704821b --- /dev/null +++ b/res/layout/widget_city_list_item.xml @@ -0,0 +1,52 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- Copyright (C) 2016 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. +--> + +<LinearLayout + xmlns:android="http://schemas.android.com/apk/res/android" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:background="?android:attr/selectableItemBackground" + android:gravity="center_vertical" + android:minHeight="@dimen/cities_list_item_height"> + + <TextView + android:id="@+id/index" + android:layout_width="74dp" + android:layout_height="match_parent" + android:gravity="center" + android:textColor="@color/clock_white" + android:textSize="@dimen/label_text_size" + android:textStyle="bold" /> + + <TextView + android:id="@+id/city_name" + android:layout_width="0dp" + android:layout_height="wrap_content" + android:layout_weight="1" + android:ellipsize="end" + android:paddingEnd="10dp" + android:paddingStart="16dp" + android:singleLine="true" + android:textAppearance="@style/PrimaryLabelTextAppearance" /> + + <TextView + android:id="@+id/city_time" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginEnd="8dp" + android:textAppearance="@style/SecondaryLabelTextAppearance" /> + +</LinearLayout>
\ No newline at end of file diff --git a/res/values-sw600dp/dimens.xml b/res/values-sw600dp/dimens.xml index a1ffa8c02..b59447268 100644 --- a/res/values-sw600dp/dimens.xml +++ b/res/values-sw600dp/dimens.xml @@ -90,5 +90,8 @@ <dimen name="min_digital_widget_width">300dp</dimen> <dimen name="min_digital_widget_height">170dp</dimen> + <!-- The fixed size of the font for the city name / day of week in the city widget. --> + <dimen name="city_widget_name_font_size">20dp</dimen> + <dimen name="footer_button_size">80dip</dimen> </resources> diff --git a/res/values-v23/styles.xml b/res/values-v23/styles.xml index 68e892d50..f55c40e46 100644 --- a/res/values-v23/styles.xml +++ b/res/values-v23/styles.xml @@ -9,6 +9,7 @@ </style> <style name="widget_label" parent="label"> + <item name="android:fontFamily">sans-serif-light</item> <item name="android:textAllCaps">true</item> <item name="android:letterSpacing">0.15</item> <item name="android:shadowRadius">@dimen/digital_widget_shadow_radius</item> diff --git a/res/values/dimens.xml b/res/values/dimens.xml index d97da2aed..a77b77bb7 100644 --- a/res/values/dimens.xml +++ b/res/values/dimens.xml @@ -131,6 +131,11 @@ <dimen name="min_digital_widget_width">206dp</dimen> <dimen name="min_digital_widget_height">129dp</dimen> + <!-- The fixed size of the font for the city name / day of week in the city widget. --> + <dimen name="city_widget_name_font_size">12dp</dimen> + <!-- The maximum size of the font for the time in the city widget. --> + <dimen name="city_widget_max_clock_font_size">104dp</dimen> + <!-- shadow styles for digital widget text --> <item name="digital_widget_shadow_radius" format="float" type="dimen">2.75</item> <item name="digital_widget_shadow_dy" format="float" type="dimen">2.0</item> diff --git a/res/values/donottranslate.xml b/res/values/donottranslate.xml index 29c61e44c..4fb3d0685 100644 --- a/res/values/donottranslate.xml +++ b/res/values/donottranslate.xml @@ -15,6 +15,8 @@ --> <resources> + <!-- Format for displaying the day of week with a preceding slash divider. --> + <string name="abbrev_wday"> / EEE</string> <!-- String matching the lock screen format for displaying the date. --> <string name="abbrev_wday_month_day_no_year">EEEMMMd</string> <!-- Format for describing the date, for accessibility. --> diff --git a/res/values/donottranslate_events.xml b/res/values/donottranslate_events.xml index 5e641622b..d7f203965 100644 --- a/res/values/donottranslate_events.xml +++ b/res/values/donottranslate_events.xml @@ -19,6 +19,9 @@ <string name="category_clock">Clock</string> <string name="category_timer">Timer</string> <string name="category_stopwatch">Stopwatch</string> + <string name="category_city_widget">City Widget</string> + <string name="category_analog_widget">Analog Widget</string> + <string name="category_digital_widget">Digital Widget</string> <!-- Action names for events describe what type of manipulation was performed. --> <string name="action_dismiss">Dismiss</string> diff --git a/res/values/strings.xml b/res/values/strings.xml index 10716ed4e..a5189f22d 100644 --- a/res/values/strings.xml +++ b/res/values/strings.xml @@ -184,9 +184,15 @@ this character, i.e. "Mon, Tue, Wed" --> <string name="day_concat">", "</string> - <!-- Label for analog clock gadget displayed on-screen when that gadget is represented to the user. --> + <!-- Label for analog clock gadget displayed in the widget picker. [CHAR LIMIT=18] --> <string name="analog_gadget">Analog clock</string> + <!-- Label for digital clock gadget displayed in the widget picker. [CHAR LIMIT=18] --> + <string name="digital_gadget">Digital clock</string> + + <!-- Label for city clock gadget displayed in the widget picker. [CHAR LIMIT=18] --> + <string name="city_gadget">World clock</string> + <!-- Settings activity name --> <!-- Label for the Settings activity displayed on-screen when that activity must be represented to the user. --> <string name="settings">Settings</string> @@ -748,9 +754,6 @@ <!-- Description of field showing the next alarm time in the clock page, for accessibility. --> <string name="next_alarm_description">Next alarm: <xliff:g id="alarm_time" example="Wed 8:00am">%s</xliff:g></string> - <!-- Label for digital clock gadget displayed on-screen when that gadget is represented to the user. --> - <string name="digital_gadget">Digital clock</string> - <!-- String for no alarms --> <string name="no_alarms">No Alarms</string> diff --git a/res/xml/city_appwidget.xml b/res/xml/city_appwidget.xml new file mode 100644 index 000000000..3dbbc88fe --- /dev/null +++ b/res/xml/city_appwidget.xml @@ -0,0 +1,28 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- Copyright (C) 2016 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. +--> + +<appwidget-provider + xmlns:android="http://schemas.android.com/apk/res/android" + android:configure="com.android.deskclock.worldclock.WidgetCitySelectionActivity" + android:initialLayout="@layout/city_widget" + android:minHeight="59dp" + android:minWidth="136dp" + android:minResizeHeight="59dp" + android:minResizeWidth="136dp" + android:previewImage="@drawable/appwidget_city_clock_preview" + android:resizeMode="horizontal|vertical" + android:updatePeriodMillis="0" + android:widgetCategory="keyguard|home_screen" />
\ No newline at end of file diff --git a/src/com/android/alarmclock/AnalogAppWidgetProvider.java b/src/com/android/alarmclock/AnalogAppWidgetProvider.java index 1a252a82d..80bc22f37 100644 --- a/src/com/android/alarmclock/AnalogAppWidgetProvider.java +++ b/src/com/android/alarmclock/AnalogAppWidgetProvider.java @@ -16,35 +16,60 @@ package com.android.alarmclock; -import com.android.deskclock.HandleDeskClockApiCalls; -import com.android.deskclock.R; - import android.app.PendingIntent; import android.appwidget.AppWidgetManager; -import android.content.BroadcastReceiver; +import android.appwidget.AppWidgetProvider; +import android.content.ComponentName; import android.content.Context; import android.content.Intent; import android.widget.RemoteViews; +import com.android.deskclock.DeskClock; +import com.android.deskclock.R; +import com.android.deskclock.Utils; +import com.android.deskclock.data.DataModel; + /** * Simple widget to show an analog clock. */ -public class AnalogAppWidgetProvider extends BroadcastReceiver { +public class AnalogAppWidgetProvider extends AppWidgetProvider { + @Override public void onReceive(Context context, Intent intent) { - if (!AppWidgetManager.ACTION_APPWIDGET_UPDATE.equals(intent.getAction())) { + super.onReceive(context, intent); + + final AppWidgetManager wm = AppWidgetManager.getInstance(context); + if (wm == null) { return; } - final String packageName = context.getPackageName(); - final RemoteViews views = new RemoteViews(packageName, R.layout.analog_appwidget); + // Send events for newly created/deleted widgets. + final ComponentName provider = new ComponentName(context, getClass()); + final int widgetCount = wm.getAppWidgetIds(provider).length; - final Intent showClock = new Intent(HandleDeskClockApiCalls.ACTION_SHOW_CLOCK) - .putExtra(HandleDeskClockApiCalls.EXTRA_EVENT_LABEL, R.string.label_widget); - final PendingIntent pendingIntent = PendingIntent.getActivity(context, 0, showClock, 0); - views.setOnClickPendingIntent(R.id.analog_appwidget, pendingIntent); + final DataModel dm = DataModel.getDataModel(); + dm.updateWidgetCount(getClass(), widgetCount, R.string.category_analog_widget); + } + + /** + * Called when widgets must provide remote views. + */ + @Override + public void onUpdate(Context context, AppWidgetManager wm, int[] widgetIds) { + super.onUpdate(context, wm, widgetIds); - final int[] appWidgetIds = intent.getIntArrayExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS); - AppWidgetManager.getInstance(context).updateAppWidget(appWidgetIds, views); + for (int widgetId : widgetIds) { + final String packageName = context.getPackageName(); + final RemoteViews widget = new RemoteViews(packageName, R.layout.analog_appwidget); + + // Tapping on the widget opens the app (if not on the lock screen). + if (Utils.isWidgetClickable(wm, widgetId)) { + final Intent openApp = new Intent(context, DeskClock.class); + final PendingIntent pi = PendingIntent.getActivity(context, 0, openApp, 0); + widget.setOnClickPendingIntent(R.id.analog_appwidget, pi); + } + + wm.updateAppWidget(widgetId, widget); + } } }
\ No newline at end of file diff --git a/src/com/android/alarmclock/CityAppWidgetProvider.java b/src/com/android/alarmclock/CityAppWidgetProvider.java new file mode 100644 index 000000000..213aec76f --- /dev/null +++ b/src/com/android/alarmclock/CityAppWidgetProvider.java @@ -0,0 +1,526 @@ +/* + * Copyright (C) 2016 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.alarmclock; + +import android.annotation.SuppressLint; +import android.app.AlarmManager; +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.content.res.Resources; +import android.os.Bundle; +import android.support.annotation.NonNull; +import android.text.format.DateFormat; +import android.util.ArraySet; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.RemoteViews; +import android.widget.TextClock; +import android.widget.TextView; + +import com.android.deskclock.DeskClock; +import com.android.deskclock.LogUtils; +import com.android.deskclock.R; +import com.android.deskclock.Utils; +import com.android.deskclock.data.City; +import com.android.deskclock.data.DataModel; + +import java.util.Calendar; +import java.util.Date; +import java.util.Locale; +import java.util.Set; +import java.util.TimeZone; + +import static android.app.PendingIntent.FLAG_NO_CREATE; +import static android.app.PendingIntent.FLAG_UPDATE_CURRENT; +import static android.app.PendingIntent.getBroadcast; +import static android.appwidget.AppWidgetManager.ACTION_APPWIDGET_UPDATE; +import static android.appwidget.AppWidgetManager.EXTRA_APPWIDGET_IDS; +import static android.appwidget.AppWidgetManager.OPTION_APPWIDGET_MAX_HEIGHT; +import static android.appwidget.AppWidgetManager.OPTION_APPWIDGET_MAX_WIDTH; +import static android.appwidget.AppWidgetManager.OPTION_APPWIDGET_MIN_HEIGHT; +import static android.appwidget.AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH; +import static android.content.Context.ALARM_SERVICE; +import static android.content.Intent.ACTION_DATE_CHANGED; +import static android.content.Intent.ACTION_LOCALE_CHANGED; +import static android.content.Intent.ACTION_SCREEN_ON; +import static android.content.Intent.ACTION_TIMEZONE_CHANGED; +import static android.content.Intent.ACTION_TIME_CHANGED; +import static android.content.res.Configuration.ORIENTATION_PORTRAIT; +import static android.util.TypedValue.COMPLEX_UNIT_PX; +import static android.view.View.MeasureSpec.UNSPECIFIED; +import static java.lang.Math.max; +import static java.lang.Math.min; + +/** + * <p>This provider produces a widget resembling:</p> + * <pre> + * 12:59 AM + * ADELAIDE / THU + * </pre> + * + * This widget is scaling the font sizes to fit within the widget bounds chosen by the user without + * any clipping. To do so it measures layouts offscreen using a range of font sizes in order to + * choose optimal values. + */ +public class CityAppWidgetProvider extends AppWidgetProvider { + + private static final LogUtils.Logger LOGGER = new LogUtils.Logger("CityWidgetProvider"); + + @Override + public void onReceive(@NonNull Context context, @NonNull Intent intent) { + LOGGER.i("City Widget processing action %s", intent.getAction()); + super.onReceive(context, intent); + + final AppWidgetManager wm = AppWidgetManager.getInstance(context); + if (wm == null) { + return; + } + + final ComponentName provider = new ComponentName(context, getClass()); + final int[] widgetIds = wm.getAppWidgetIds(provider); + + switch (intent.getAction()) { + case ACTION_SCREEN_ON: + case ACTION_TIME_CHANGED: + case ACTION_DATE_CHANGED: + case ACTION_LOCALE_CHANGED: + case ACTION_TIMEZONE_CHANGED: + for (int widgetId : widgetIds) { + updateWidget(context, wm, widgetId, wm.getAppWidgetOptions(widgetId)); + } + } + + updateNextDayBroadcast(context, widgetIds); + + final DataModel dm = DataModel.getDataModel(); + dm.updateWidgetCount(getClass(), widgetIds.length, R.string.category_city_widget); + } + + /** + * Called when widgets must provide remote views. + */ + @Override + public void onUpdate(Context context, AppWidgetManager wm, int[] widgetIds) { + super.onUpdate(context, wm, widgetIds); + + for (int widgetId : widgetIds) { + updateWidget(context, wm, widgetId, wm.getAppWidgetOptions(widgetId)); + } + } + + /** + * Called when a widget changes sizes. + */ + @Override + public void onAppWidgetOptionsChanged(Context context, AppWidgetManager wm, int widgetId, + Bundle options) { + super.onAppWidgetOptionsChanged(context, wm, widgetId, options); + + updateWidget(context, AppWidgetManager.getInstance(context), widgetId, options); + } + + /** + * Called when widgets have been removed. + */ + @Override + public void onDeleted(Context context, int[] widgetIds) { + super.onDeleted(context, widgetIds); + + for (int widgetId : widgetIds) { + DataModel.getDataModel().setWidgetCity(widgetId, null); + } + } + + /** + * Called when widgets have been restored from backup. Remaps cities associated with old widget + * ids to new replacement widget ids. + */ + @Override + public void onRestored(Context context, int[] oldWidgetIds, int[] newWidgetIds) { + super.onRestored(context, oldWidgetIds, newWidgetIds); + + for (int i = 0; i < oldWidgetIds.length; i++) { + final int oldWidgetId = oldWidgetIds[i]; + final int newWidgetId = newWidgetIds[i]; + + // Get the city mapped to the old widget id. + final City city = DataModel.getDataModel().getWidgetCity(oldWidgetId); + + // Remove the old widget id mapping. + DataModel.getDataModel().setWidgetCity(oldWidgetId, null); + + // Create the new widget id mapping. + DataModel.getDataModel().setWidgetCity(newWidgetId, city); + } + } + + /** + * Compute optimal font and icon sizes offscreen using the last known widget size and apply them + * to the remote views displayed in the widget. + */ + private static void updateWidget(Context context, AppWidgetManager wm, int widgetId, + Bundle options) { + // Fetch the city to display in this widget. + final City city = DataModel.getDataModel().getWidgetCity(widgetId); + + // Return early if there is no city data; occurs while configuration is not yet complete. + if (city == null) { + return; + } + + if (options == null) { + options = wm.getAppWidgetOptions(widgetId); + } + + // Create a size template that describes the widget bounds. + final Resources resources = context.getResources(); + final float density = resources.getDisplayMetrics().density; + final int minWidthPx = (int) (density * options.getInt(OPTION_APPWIDGET_MIN_WIDTH)); + final int minHeightPx = (int) (density * options.getInt(OPTION_APPWIDGET_MIN_HEIGHT)); + final int maxWidthPx = (int) (density * options.getInt(OPTION_APPWIDGET_MAX_WIDTH)); + final int maxHeightPx = (int) (density * options.getInt(OPTION_APPWIDGET_MAX_HEIGHT)); + final boolean portrait = resources.getConfiguration().orientation == ORIENTATION_PORTRAIT; + final int targetWidthPx = portrait ? minWidthPx : maxWidthPx; + final int targetHeightPx = portrait ? maxHeightPx : minHeightPx; + final int fontSizePx = resources.getDimensionPixelSize(R.dimen.city_widget_name_font_size); + final int largestClockFontSizePx = + resources.getDimensionPixelSize(R.dimen.city_widget_max_clock_font_size); + final Sizes template = new Sizes(city, targetWidthPx, targetHeightPx, fontSizePx, + largestClockFontSizePx); + + // Create a remote view for the city widget. + final String packageName = context.getPackageName(); + final RemoteViews widget = new RemoteViews(packageName, R.layout.city_widget); + + // Tapping on the widget opens the app (if not on the lock screen). + if (Utils.isWidgetClickable(wm, widgetId)) { + final Intent openApp = new Intent(context, DeskClock.class); + final PendingIntent pi = PendingIntent.getActivity(context, 0, openApp, 0); + widget.setOnClickPendingIntent(R.id.city_widget, pi); + } + + // Configure child views of the remote view. + widget.setCharSequence(R.id.clock, "setFormat12Hour", get12HourFormat()); + widget.setCharSequence(R.id.clock, "setFormat24Hour", Utils.get24ModeFormat()); + + final CharSequence dateFormat = getDayFormat(context); + widget.setCharSequence(R.id.city_day, "setFormat12Hour", dateFormat); + widget.setCharSequence(R.id.city_day, "setFormat24Hour", dateFormat); + + widget.setTextViewText(R.id.city_name, template.getCityName()); + widget.setString(R.id.clock, "setTimeZone", template.getTimeZoneId()); + widget.setString(R.id.city_day, "setTimeZone", template.getTimeZoneId()); + + // Compute optimal font sizes to fit within the widget bounds. + final Sizes sizes = optimizeSizes(context, template); + if (LOGGER.isVerboseLoggable()) { + LOGGER.v(sizes.toString()); + } + + // Apply the computed sizes to the remote views. + widget.setTextViewTextSize(R.id.clock, COMPLEX_UNIT_PX, sizes.mClockFontSizePx); + widget.setTextViewTextSize(R.id.city_day, COMPLEX_UNIT_PX, sizes.mFontSizePx); + widget.setTextViewTextSize(R.id.city_name, COMPLEX_UNIT_PX, sizes.mFontSizePx); + widget.setInt(R.id.city_name, "setMaxWidth", sizes.mCityNameMaxWidthPx); + wm.updateAppWidget(widgetId, widget); + } + + /** + * Inflate an offscreen copy of the widget views. Binary search through the range of font sizes + * until the optimal font sizes that fit within the widget bounds are located. + */ + private static Sizes optimizeSizes(Context context, Sizes template) { + // Inflate a test layout to compute sizes at different font sizes. + final LayoutInflater inflater = LayoutInflater.from(context); + @SuppressLint("InflateParams") + final View sizer = inflater.inflate(R.layout.city_widget, null /* root */); + + // Configure the clock to display the preferred time formats. + final TextClock clock = (TextClock) sizer.findViewById(R.id.clock); + clock.setFormat12Hour(get12HourFormat()); + clock.setFormat24Hour(Utils.get24ModeFormat()); + + // Configure the date to display the preferred day format. + final CharSequence dateFormat = getDayFormat(context); + final TextClock cityDay = (TextClock) sizer.findViewById(R.id.city_day); + cityDay.setFormat12Hour(dateFormat); + cityDay.setFormat24Hour(dateFormat); + + // Measure the widget at the largest possible size. + Sizes high = measure(template, template.getLargestClockFontSizePx(), sizer); + if (!high.hasViolations()) { + return high; + } + + // Measure the widget at the smallest possible size. + Sizes low = measure(template, template.getSmallestClockFontSizePx(), sizer); + if (low.hasViolations()) { + return low; + } + + // Binary search between the smallest and largest sizes until an optimum size is found. + while (low.getClockFontSizePx() != high.getClockFontSizePx()) { + final int midFontSize = (low.getClockFontSizePx() + high.getClockFontSizePx()) / 2; + if (midFontSize == low.getClockFontSizePx()) { + return low; + } + + final Sizes midSize = measure(template, midFontSize, sizer); + if (midSize.hasViolations()) { + high = midSize; + } else { + low = midSize; + } + } + + return low; + } + + /** + * Compute all font sizes based on the given {@code clockFontSizePx} and apply them to the + * offscreen {@code sizer} view. Measure the {@code sizer} view and return the resulting size + * measurements. Since the localized strings for "AM" and "PM" may be different, layouts for + * morning and afternoon times are measured and the largest dimensions are reported as the + * required size. + */ + private static Sizes measure(Sizes template, int clockFontSizePx, View sizer) { + final TextClock clock = (TextClock) sizer.findViewById(R.id.clock); + clock.setTimeZone(template.getTimeZoneId()); + + final CharSequence amTime = getLongestAMTimeString(clock); + final CharSequence pmTime = getLongestPMTimeString(clock); + + final TextClock cityDay = (TextClock) sizer.findViewById(R.id.city_day); + cityDay.setTimeZone(template.getTimeZoneId()); + // The city name will be elided to fit in its bounds. Don't set a city name which could + // trigger a false layout violation. + + // Measure the size of the widget at 11:59AM and 11:59PM. + final Sizes amSizes = measure(template, clockFontSizePx, amTime, sizer); + final Sizes pmSizes = measure(template, clockFontSizePx, pmTime, sizer); + + // Report the largest dimensions between the two different times. + final Sizes merged = amSizes.newSize(); + merged.setClockFontSizePx(clockFontSizePx); + merged.mMeasuredWidthPx = max(amSizes.mMeasuredWidthPx, pmSizes.mMeasuredWidthPx); + merged.mMeasuredHeightPx = max(amSizes.mMeasuredHeightPx, pmSizes.mMeasuredHeightPx); + merged.mMeasuredTextClockWidthPx = + max(amSizes.mMeasuredTextClockWidthPx, pmSizes.mMeasuredTextClockWidthPx); + merged.mMeasuredTextClockHeightPx = + max(amSizes.mMeasuredTextClockHeightPx, pmSizes.mMeasuredTextClockHeightPx); + merged.mCityNameMaxWidthPx = min(amSizes.mCityNameMaxWidthPx, pmSizes.mCityNameMaxWidthPx); + return merged; + } + + /** + * Compute all font and icon sizes based on the given {@code clockFontSizePx} at the given + * {@code time} and apply them to the offscreen {@code sizer} view. Measure the {@code sizer} + * view and return the resulting size measurements. + */ + private static Sizes measure(Sizes template, int clockFontSizePx, CharSequence time, View sizer) { + // Create a copy of the given template sizes. + final Sizes measuredSizes = template.newSize(); + + // Configure the clock to display the time string. + final TextClock clock = (TextClock) sizer.findViewById(R.id.clock); + final TextClock cityDay = (TextClock) sizer.findViewById(R.id.city_day); + final TextView cityName = (TextView) sizer.findViewById(R.id.city_name); + + // Adjust the font sizes. + measuredSizes.setClockFontSizePx(clockFontSizePx); + clock.setText(time); + clock.setTextSize(COMPLEX_UNIT_PX, measuredSizes.mClockFontSizePx); + cityDay.setTextSize(COMPLEX_UNIT_PX, measuredSizes.mFontSizePx); + cityName.setTextSize(COMPLEX_UNIT_PX, measuredSizes.mFontSizePx); + + // Measure and layout the sizer. + final int widthSize = View.MeasureSpec.getSize(measuredSizes.mTargetWidthPx); + final int heightSize = View.MeasureSpec.getSize(measuredSizes.mTargetHeightPx); + final int widthMeasureSpec = View.MeasureSpec.makeMeasureSpec(widthSize, UNSPECIFIED); + final int heightMeasureSpec = View.MeasureSpec.makeMeasureSpec(heightSize, UNSPECIFIED); + sizer.measure(widthMeasureSpec, heightMeasureSpec); + sizer.layout(0, 0, sizer.getMeasuredWidth(), sizer.getMeasuredHeight()); + + // Copy the measurements into the result object. + measuredSizes.mMeasuredWidthPx = sizer.getMeasuredWidth(); + measuredSizes.mMeasuredHeightPx = sizer.getMeasuredHeight(); + measuredSizes.mMeasuredTextClockWidthPx = clock.getMeasuredWidth(); + measuredSizes.mMeasuredTextClockHeightPx = clock.getMeasuredHeight(); + measuredSizes.mCityNameMaxWidthPx = template.mTargetWidthPx - cityDay.getMeasuredWidth(); + + return measuredSizes; + } + + /** + * @return "11:59AM" or "11:59" in the current locale + */ + private static CharSequence getLongestAMTimeString(TextClock clock) { + final CharSequence format = clock.is24HourModeEnabled() + ? clock.getFormat24Hour() + : clock.getFormat12Hour(); + final Calendar longestAMTime = Calendar.getInstance(); + longestAMTime.set(0, 0, 0, 11, 59); + return DateFormat.format(format, longestAMTime); + } + + /** + * @return "11:59PM" or "23:59" in the current locale + */ + private static CharSequence getLongestPMTimeString(TextClock clock) { + final CharSequence format = clock.is24HourModeEnabled() + ? clock.getFormat24Hour() + : clock.getFormat12Hour(); + final Calendar longestPMTime = Calendar.getInstance(); + longestPMTime.set(0, 0, 0, 23, 59); + return DateFormat.format(format, longestPMTime); + } + + /** + * @return the locale-specific date pattern + */ + private static String getDayFormat(Context context) { + return context.getString(R.string.abbrev_wday); + } + + /** + * @return the locale-specific 12-hour time format with the AM/PM string scaled to 40% of the + * normal font height + */ + private static CharSequence get12HourFormat() { + return Utils.get12ModeFormat(0.4f /* amPmRatio */); + } + + /** + * Schedule or cancel the next-day broadcast as necessary. This broadcast is necessary because + * the week day displayed in the city widget can vary in width for some locales and thus a + * layout refresh must be computed. + */ + private static void updateNextDayBroadcast(Context context, int[] widgetIds) { + // Fetch all time zones represented by all city widgets. + final Set<TimeZone> zones = new ArraySet<>(widgetIds.length); + for (int widgetId : widgetIds) { + final City city = DataModel.getDataModel().getWidgetCity(widgetId); + if (city != null) { + zones.add(city.getTimeZone()); + } + } + + // Build an intent that will update all city widgets. + final Intent update = + new Intent(ACTION_APPWIDGET_UPDATE, null, context, CityAppWidgetProvider.class) + .putExtra(EXTRA_APPWIDGET_IDS, widgetIds); + final AlarmManager am = (AlarmManager) context.getSystemService(ALARM_SERVICE); + + if (zones.isEmpty()) { + // No city widgets exist so cancel the next-day broadcast. + final PendingIntent pi = getBroadcast(context, 0, update, FLAG_NO_CREATE); + if (pi != null) { + am.cancel(pi); + pi.cancel(); + } + } else { + // Compute the next time at which the day changes in any of the time zones. + final Date nextMidnight = Utils.getNextDay(new Date(), zones); + + // Schedule an intent to update all city widgets on next day change. + final PendingIntent pi = getBroadcast(context, 0, update, FLAG_UPDATE_CURRENT); + am.setExact(AlarmManager.RTC, nextMidnight.getTime(), pi); + } + } + + /** + * This class stores the target size of the widget as well as the measured size using a given + * clock font size. All other fonts and icons are scaled proportional to the clock font. + */ + private static final class Sizes { + + private final City mCity; + private final int mTargetWidthPx; + private final int mTargetHeightPx; + private final int mLargestClockFontSizePx; + private final int mSmallestClockFontSizePx; + + private int mMeasuredWidthPx; + private int mMeasuredHeightPx; + private int mMeasuredTextClockWidthPx; + private int mMeasuredTextClockHeightPx; + + /** The size of the font to use on the city name / day of week fields. */ + private final int mFontSizePx; + + /** The size of the font to use on the clock field. */ + private int mClockFontSizePx; + + /** If the city name requires more width that this threshold the text is elided. */ + private int mCityNameMaxWidthPx; + + private Sizes(City city, int targetWidthPx, int targetHeightPx, int fontSizePx, + int largestClockFontSizePx) { + mCity = city; + mTargetWidthPx = targetWidthPx; + mTargetHeightPx = targetHeightPx; + mFontSizePx = fontSizePx; + mLargestClockFontSizePx = largestClockFontSizePx; + mSmallestClockFontSizePx = 0; + } + + private String getCityName() { return mCity.getName(); } + private String getTimeZoneId() { return mCity.getTimeZone().getID(); } + private int getClockFontSizePx() { return mClockFontSizePx; } + private void setClockFontSizePx(int clockFontSizePx) { mClockFontSizePx = clockFontSizePx; } + private int getLargestClockFontSizePx() { return mLargestClockFontSizePx; } + private int getSmallestClockFontSizePx() { return mSmallestClockFontSizePx; } + + private boolean hasViolations() { + return mMeasuredWidthPx > mTargetWidthPx || mMeasuredHeightPx > mTargetHeightPx; + } + + private Sizes newSize() { + return new Sizes(mCity, mTargetWidthPx, mTargetHeightPx, mFontSizePx, + mLargestClockFontSizePx); + } + + @Override + public String toString() { + final StringBuilder builder = new StringBuilder(1000); + builder.append("\n"); + append(builder, "Target dimensions: %dpx x %dpx\n", mTargetWidthPx, mTargetHeightPx); + append(builder, "Last valid widget container measurement: %dpx x %dpx\n", + mMeasuredWidthPx, mMeasuredHeightPx); + append(builder, "Last text clock measurement: %dpx x %dpx\n", + mMeasuredTextClockWidthPx, mMeasuredTextClockHeightPx); + if (mMeasuredWidthPx > mTargetWidthPx) { + append(builder, "Measured width %dpx exceeded widget width %dpx\n", + mMeasuredWidthPx, mTargetWidthPx); + } + if (mMeasuredHeightPx > mTargetHeightPx) { + append(builder, "Measured height %dpx exceeded widget height %dpx\n", + mMeasuredHeightPx, mTargetHeightPx); + } + append(builder, "Clock font: %dpx\n", mClockFontSizePx); + return builder.toString(); + } + + private static void append(StringBuilder builder, String format, Object... args) { + builder.append(String.format(Locale.ENGLISH, format, args)); + } + } +}
\ No newline at end of file diff --git a/src/com/android/alarmclock/DigitalAppWidgetProvider.java b/src/com/android/alarmclock/DigitalAppWidgetProvider.java index 57c349745..76c1c903c 100644 --- a/src/com/android/alarmclock/DigitalAppWidgetProvider.java +++ b/src/com/android/alarmclock/DigitalAppWidgetProvider.java @@ -36,9 +36,11 @@ import android.widget.RemoteViews; import android.widget.TextClock; import android.widget.TextView; +import com.android.deskclock.DeskClock; import com.android.deskclock.LogUtils; import com.android.deskclock.R; import com.android.deskclock.Utils; +import com.android.deskclock.data.DataModel; import java.util.Calendar; import java.util.Locale; @@ -57,8 +59,6 @@ import static android.util.TypedValue.COMPLEX_UNIT_PX; import static android.view.View.GONE; import static android.view.View.MeasureSpec.UNSPECIFIED; import static android.view.View.VISIBLE; -import static com.android.deskclock.HandleDeskClockApiCalls.ACTION_SHOW_CLOCK; -import static com.android.deskclock.HandleDeskClockApiCalls.EXTRA_EVENT_LABEL; import static com.android.deskclock.alarms.AlarmStateManager.SYSTEM_ALARM_CHANGE_ACTION; import static java.lang.Math.max; @@ -77,18 +77,14 @@ import static java.lang.Math.max; * WED, FEB 3 * </pre> * - * This widget is scaling the font sizes up to fit within the widget bounds chosen by the user - * without any clipping. To do so it measures layouts offscreen using a range of font sizes in order - * to choose optimal values. + * This widget is scaling the font sizes to fit within the widget bounds chosen by the user without + * any clipping. To do so it measures layouts offscreen using a range of font sizes in order to + * choose optimal values. */ public class DigitalAppWidgetProvider extends AppWidgetProvider { private static final LogUtils.Logger LOGGER = new LogUtils.Logger("DigitalWidgetProvider"); - /** Intent used to open the application when tapping on the widget. */ - private static final Intent SHOW_CLOCK_INTENT = new Intent(ACTION_SHOW_CLOCK) - .putExtra(EXTRA_EVENT_LABEL, R.string.label_widget); - /** A custom font containing a special glyph that draws a clock icon with proper drop shadow. */ private static Typeface sAlarmIconTypeface; @@ -102,6 +98,9 @@ public class DigitalAppWidgetProvider extends AppWidgetProvider { return; } + final ComponentName provider = new ComponentName(context, getClass()); + final int[] widgetIds = wm.getAppWidgetIds(provider); + switch (intent.getAction()) { case ACTION_SCREEN_ON: case ACTION_DATE_CHANGED: @@ -109,24 +108,24 @@ public class DigitalAppWidgetProvider extends AppWidgetProvider { case ACTION_TIMEZONE_CHANGED: case SYSTEM_ALARM_CHANGE_ACTION: case ACTION_NEXT_ALARM_CLOCK_CHANGED: - final ComponentName provider = new ComponentName(context, getClass()); - final int[] widgetIds = wm.getAppWidgetIds(provider); - for (int widgetId : widgetIds) { - updateRemoteViews(context, wm, widgetId, wm.getAppWidgetOptions(widgetId)); + updateWidget(context, wm, widgetId, wm.getAppWidgetOptions(widgetId)); } } + + final DataModel dm = DataModel.getDataModel(); + dm.updateWidgetCount(getClass(), widgetIds.length, R.string.category_digital_widget); } /** - * Called when this widget must provide remote views. + * Called when widgets must provide remote views. */ @Override public void onUpdate(Context context, AppWidgetManager wm, int[] widgetIds) { super.onUpdate(context, wm, widgetIds); for (int widgetId : widgetIds) { - updateRemoteViews(context, wm, widgetId, wm.getAppWidgetOptions(widgetId)); + updateWidget(context, wm, widgetId, wm.getAppWidgetOptions(widgetId)); } } @@ -139,14 +138,14 @@ public class DigitalAppWidgetProvider extends AppWidgetProvider { super.onAppWidgetOptionsChanged(context, wm, widgetId, options); // scale the fonts of the clock to fit inside the new size - updateRemoteViews(context, AppWidgetManager.getInstance(context), widgetId, options); + updateWidget(context, AppWidgetManager.getInstance(context), widgetId, options); } /** * Compute optimal font and icon sizes offscreen using the last known widget size and apply them - * to the remote views displayed in the widget. + * to the widget. */ - private static void updateRemoteViews(Context context, AppWidgetManager wm, int widgetId, + private static void updateWidget(Context context, AppWidgetManager wm, int widgetId, Bundle options) { // Create a remote view for the digital clock. final String packageName = context.getPackageName(); @@ -154,7 +153,8 @@ public class DigitalAppWidgetProvider extends AppWidgetProvider { // Tapping on the widget opens the app (if not on the lock screen). if (Utils.isWidgetClickable(wm, widgetId)) { - final PendingIntent pi = PendingIntent.getActivity(context, 0, SHOW_CLOCK_INTENT, 0); + final Intent openApp = new Intent(context, DeskClock.class); + final PendingIntent pi = PendingIntent.getActivity(context, 0, openApp, 0); widget.setOnClickPendingIntent(R.id.digital_widget, pi); } @@ -177,7 +177,7 @@ public class DigitalAppWidgetProvider extends AppWidgetProvider { } if (options == null) { - options = AppWidgetManager.getInstance(context).getAppWidgetOptions(widgetId); + options = wm.getAppWidgetOptions(widgetId); } // Fetch the widget size selected by the user. diff --git a/src/com/android/deskclock/DeskClockApplication.java b/src/com/android/deskclock/DeskClockApplication.java index fa574dc2b..b88448ff2 100644 --- a/src/com/android/deskclock/DeskClockApplication.java +++ b/src/com/android/deskclock/DeskClockApplication.java @@ -23,6 +23,7 @@ import android.content.Context; import android.content.Intent; import android.content.res.Configuration; +import com.android.alarmclock.CityAppWidgetProvider; import com.android.alarmclock.DigitalAppWidgetProvider; import com.android.deskclock.data.DataModel; import com.android.deskclock.events.Events; @@ -53,6 +54,7 @@ public class DeskClockApplication extends Application { public void onConfigurationChanged(Configuration newConfig) { super.onConfigurationChanged(newConfig); + updateWidgets(CityAppWidgetProvider.class); updateWidgets(DigitalAppWidgetProvider.class); } diff --git a/src/com/android/deskclock/Utils.java b/src/com/android/deskclock/Utils.java index 7eb69c740..9ff74b42c 100644 --- a/src/com/android/deskclock/Utils.java +++ b/src/com/android/deskclock/Utils.java @@ -425,6 +425,38 @@ public class Utils { } /** + * Given a point in time, return the subsequent moment any of the time zones changes days. + * e.g. Given 8:00pm on 1/1/2016 and time zones in LA and NY this method would return a Date for + * midnight on 1/2/2016 in the NY timezone since it changes days first. + * + * @param time a point in time from which to compute midnight on the subsequent day + * @param zones a collection of time zones + * @return the nearest point in the future at which any of the time zones changes days + */ + public static Date getNextDay(Date time, Collection<TimeZone> zones) { + Calendar next = null; + for (TimeZone tz : zones) { + final Calendar c = Calendar.getInstance(tz); + c.setTime(time); + + // Advance to the next day. + c.add(Calendar.DAY_OF_YEAR, 1); + + // Reset the time to midnight. + c.set(Calendar.HOUR_OF_DAY, 0); + c.set(Calendar.MINUTE, 0); + c.set(Calendar.SECOND, 0); + c.set(Calendar.MILLISECOND, 0); + + if (next == null || c.compareTo(next) < 0) { + next = c; + } + } + + return next == null ? null : next.getTime(); + } + + /** * Convenience method for retrieving a themed color value. * * @param context the {@link Context} to resolve the theme attribute against diff --git a/src/com/android/deskclock/data/CityModel.java b/src/com/android/deskclock/data/CityModel.java index 0b345e74d..195189fe7 100644 --- a/src/com/android/deskclock/data/CityModel.java +++ b/src/com/android/deskclock/data/CityModel.java @@ -217,6 +217,14 @@ final class CityModel { mUnselectedCities = null; } + /** + * @param cityId identifies a city + * @return the corresponding city + */ + City getCityById(String cityId) { + return getCityMap().get(cityId); + } + private Map<String, City> getCityMap() { if (mCityMap == null) { mCityMap = CityDAO.getCities(mContext); diff --git a/src/com/android/deskclock/data/DataModel.java b/src/com/android/deskclock/data/DataModel.java index ff98c4dff..ad0e226bc 100644 --- a/src/com/android/deskclock/data/DataModel.java +++ b/src/com/android/deskclock/data/DataModel.java @@ -59,6 +59,9 @@ public final class DataModel { /** The model from which alarm data are fetched. */ private AlarmModel mAlarmModel; + /** The model from which widget data are fetched. */ + private WidgetModel mWidgetModel; + /** The model from which stopwatch data are fetched. */ private StopwatchModel mStopwatchModel; @@ -83,6 +86,7 @@ public final class DataModel { mSettingsModel = new SettingsModel(mContext); mNotificationModel = new NotificationModel(); mCityModel = new CityModel(mContext, mSettingsModel); + mWidgetModel = new WidgetModel(mContext, mCityModel); mAlarmModel = new AlarmModel(mContext, mSettingsModel); mStopwatchModel = new StopwatchModel(mContext, mNotificationModel); mTimerModel = new TimerModel(mContext, mSettingsModel, mNotificationModel); @@ -589,6 +593,38 @@ public final class DataModel { } // + // Widgets + // + + /** + * @param widgetId identifies a city widget in the launcher + * @return the City data to display in the widget + */ + public City getWidgetCity(int widgetId) { + enforceMainLooper(); + return mWidgetModel.getWidgetCity(widgetId); + } + + /** + * @param widgetId identifies a city widget in the launcher + * @param city the City to display in the widget; {@code null} implies remove City + */ + public void setWidgetCity(int widgetId, City city) { + enforceMainLooper(); + mWidgetModel.setWidgetCity(widgetId, city); + } + + /** + * @param widgetClass indicates the type of widget being counted + * @param count the number of widgets of the given type + * @param eventCategoryId identifies the category of event to send + */ + public void updateWidgetCount(Class widgetClass, int count, @StringRes int eventCategoryId) { + enforceMainLooper(); + mWidgetModel.updateWidgetCount(widgetClass, count, eventCategoryId); + } + + // // Settings // diff --git a/src/com/android/deskclock/data/WidgetDAO.java b/src/com/android/deskclock/data/WidgetDAO.java new file mode 100644 index 000000000..7068f50c6 --- /dev/null +++ b/src/com/android/deskclock/data/WidgetDAO.java @@ -0,0 +1,86 @@ +/* + * 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.data; + +import android.content.Context; +import android.content.SharedPreferences; +import android.preference.PreferenceManager; + +/** + * This class encapsulates the transfer of data between widget objects and their permanent storage + * in {@link SharedPreferences}. + */ +final class WidgetDAO { + + /** Prefix for a key to a preference that stores the id of a city displayed in a widget. */ + private static final String WIDGET_CITY_ID_PREFIX = "widget_city_id_"; + + /** Suffix for a key to a preference that stores the instance count for a given widget type. */ + private static final String WIDGET_COUNT_SUFFIX = "_widget_count"; + + /** Lazily instantiated and cached for the life of the application. */ + private static SharedPreferences sPrefs; + + private WidgetDAO() {} + + /** + * @param widgetId identifies a city widget in the launcher + * @return the id of the City to display in the widget + */ + public static String getWidgetCityId(Context context, int widgetId) { + final SharedPreferences prefs = getSharedPreferences(context); + return prefs.getString(WIDGET_CITY_ID_PREFIX + widgetId, null); + } + + /** + * @param widgetId identifies a city widget in the launcher + * @param cityId identifies the City to display in the widget; {@code null} implies remove City + */ + public static void setWidgetCityId(Context context, int widgetId, String cityId) { + final SharedPreferences prefs = getSharedPreferences(context); + if (cityId == null) { + prefs.edit().remove(WIDGET_CITY_ID_PREFIX + widgetId).apply(); + } else { + prefs.edit().putString(WIDGET_CITY_ID_PREFIX + widgetId, cityId).apply(); + } + } + + /** + * @param widgetProviderClass indicates the type of widget being counted + * @param count the number of widgets of the given type + * @return the delta between the new count and the old count + */ + public static int updateWidgetCount(Context context, Class widgetProviderClass, int count) { + final SharedPreferences prefs = getSharedPreferences(context); + final String key = widgetProviderClass.getSimpleName() + WIDGET_COUNT_SUFFIX; + final int oldCount = prefs.getInt(key, 0); + if (count == 0) { + prefs.edit().remove(key).apply(); + } else { + prefs.edit().putInt(key, count).apply(); + } + return count - oldCount; + } + + private static SharedPreferences getSharedPreferences(Context context) { + if (sPrefs == null) { + sPrefs = PreferenceManager.getDefaultSharedPreferences(context.getApplicationContext()); + } + + return sPrefs; + } +}
\ No newline at end of file diff --git a/src/com/android/deskclock/data/WidgetModel.java b/src/com/android/deskclock/data/WidgetModel.java new file mode 100644 index 000000000..6ccd1f4cb --- /dev/null +++ b/src/com/android/deskclock/data/WidgetModel.java @@ -0,0 +1,88 @@ +/* + * 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.data; + +import android.content.Context; +import android.support.annotation.StringRes; +import android.util.ArrayMap; + +import com.android.deskclock.R; +import com.android.deskclock.events.Events; + +import java.util.Map; + +/** + * All widget data is accessed via this model. + */ +final class WidgetModel { + + private final Context mContext; + + /** The model from which city data are fetched. */ + private final CityModel mCityModel; + + /** Maps widget ID to city ID; items are loaded individually as widgets request data. */ + private final Map<Integer, String> mWidgetCityMap = new ArrayMap<>(); + + WidgetModel(Context context, CityModel cityModel) { + mContext = context; + mCityModel = cityModel; + } + + /** + * @param widgetId identifies a city widget in the launcher + * @return the City data to display in the widget + */ + City getWidgetCity(int widgetId) { + String cityId = mWidgetCityMap.get(widgetId); + if (cityId == null) { + cityId = WidgetDAO.getWidgetCityId(mContext, widgetId); + mWidgetCityMap.put(widgetId, cityId); + } + + return mCityModel.getCityById(cityId); + } + + /** + * @param widgetId identifies a city widget in the launcher + * @param city the City to display in the widget; {@code null} implies remove City + */ + void setWidgetCity(int widgetId, City city) { + final String cityId = city == null ? null : city.getId(); + WidgetDAO.setWidgetCityId(mContext, widgetId, cityId); + if (cityId == null) { + mWidgetCityMap.remove(widgetId); + } else { + mWidgetCityMap.put(widgetId, cityId); + } + } + + /** + * @param widgetClass indicates the type of widget being counted + * @param count the number of widgets of the given type + * @param eventCategoryId identifies the category of event to send + */ + void updateWidgetCount(Class widgetClass, int count, @StringRes int eventCategoryId) { + int delta = WidgetDAO.updateWidgetCount(mContext, widgetClass, count); + for (; delta > 0; delta--) { + Events.sendEvent(eventCategoryId, R.string.action_create, 0); + } + for (; delta < 0; delta++) { + Events.sendEvent(eventCategoryId, R.string.action_delete, 0); + } + } +}
\ No newline at end of file diff --git a/src/com/android/deskclock/worldclock/CitySelectionActivity.java b/src/com/android/deskclock/worldclock/CitySelectionActivity.java index 1bba2cb7e..9a09d2d66 100644 --- a/src/com/android/deskclock/worldclock/CitySelectionActivity.java +++ b/src/com/android/deskclock/worldclock/CitySelectionActivity.java @@ -296,7 +296,7 @@ public final class CitySelectionActivity extends BaseActivity { } @Override - public synchronized View getView(int position, View view, ViewGroup parent) { + public View getView(int position, View view, ViewGroup parent) { final int itemViewType = getItemViewType(position); switch (itemViewType) { case VIEW_TYPE_SELECTED_CITIES_HEADER: diff --git a/src/com/android/deskclock/worldclock/WidgetCitySelectionActivity.java b/src/com/android/deskclock/worldclock/WidgetCitySelectionActivity.java new file mode 100644 index 000000000..a9e548214 --- /dev/null +++ b/src/com/android/deskclock/worldclock/WidgetCitySelectionActivity.java @@ -0,0 +1,439 @@ +/* + * Copyright (C) 2016 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.content.Intent; +import android.os.Bundle; +import android.support.v7.widget.SearchView; +import android.text.TextUtils; +import android.text.format.DateFormat; +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.ListView; +import android.widget.SectionIndexer; +import android.widget.TextView; + +import com.android.alarmclock.CityAppWidgetProvider; +import com.android.deskclock.BaseActivity; +import com.android.deskclock.DropShadowController; +import com.android.deskclock.R; +import com.android.deskclock.actionbarmenu.ActionBarMenuManager; +import com.android.deskclock.actionbarmenu.NavUpMenuItemController; +import com.android.deskclock.actionbarmenu.SearchMenuItemController; +import com.android.deskclock.data.City; +import com.android.deskclock.data.DataModel; + +import java.util.ArrayList; +import java.util.Calendar; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.Locale; +import java.util.TimeZone; + +import static android.appwidget.AppWidgetManager.ACTION_APPWIDGET_UPDATE; +import static android.appwidget.AppWidgetManager.EXTRA_APPWIDGET_ID; +import static android.appwidget.AppWidgetManager.EXTRA_APPWIDGET_IDS; +import static android.appwidget.AppWidgetManager.INVALID_APPWIDGET_ID; + +/** + * This activity allows the user to select a single city for display in a widget. + */ +public final class WidgetCitySelectionActivity extends BaseActivity { + + /** Manages all action bar menu display and click handling. */ + private final ActionBarMenuManager mActionBarMenuManager = new ActionBarMenuManager(); + + /** The list of all cities, indexed and possibly filtered. */ + private ListView mCitiesList; + + /** The adapter that presents all of the cities. */ + private CityAdapter mCitiesAdapter; + + /** Menu item controller for search view. */ + private SearchMenuItemController mSearchMenuItemController; + + /** The controller that shows the drop shadow when content is not scrolled to the top. */ + private DropShadowController mDropShadowController; + + /** Identifies the widget in which the selected city will be displayed. */ + private int mWidgetId = INVALID_APPWIDGET_ID; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + // Set the result to CANCELED. This will cause the widget host to cancel + // out of the widget placement if they press the back button. + setResult(RESULT_CANCELED); + + final Bundle extras = getIntent().getExtras(); + if (extras != null) { + mWidgetId = extras.getInt(EXTRA_APPWIDGET_ID, INVALID_APPWIDGET_ID); + } + + 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); + mActionBarMenuManager.addMenuItemController(new NavUpMenuItemController(this)) + .addMenuItemController(mSearchMenuItemController); + 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(); + + final View dropShadow = findViewById(R.id.drop_shadow); + mDropShadowController = new DropShadowController(dropShadow, mCitiesList); + } + + @Override + public void onPause() { + super.onPause(); + + mDropShadowController.stop(); + } + + @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 like so: + * + * <pre> + * 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) + * ... + * </pre> + */ + private final class CityAdapter extends BaseAdapter implements View.OnClickListener, + SectionIndexer { + + private final Context mContext; + + private final LayoutInflater mInflater; + + /** Orders the cities by name for presentation in the UI. */ + private final Comparator<City> mNameIndexComparator = new City.NameIndexComparator(); + + /** 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<City> mFilteredCities = Collections.emptyList(); + + /** The precomputed section headers. */ + private String[] mSectionHeaders; + + /** The corresponding location of each precomputed section header. */ + private Integer[] mSectionHeaderPositions; + + public CityAdapter(Context context) { + mContext = context; + 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() { + return mFilteredCities.size(); + } + + @Override + public City getItem(int position) { + return mFilteredCities.get(position); + } + + @Override + public long getItemId(int position) { + return position; + } + + @Override + public View getView(int position, View view, ViewGroup parent) { + final City city = getItem(position); + final TimeZone timeZone = city.getTimeZone(); + + // Inflate a new view if necessary. + if (view == null) { + view = mInflater.inflate(R.layout.widget_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); + view.setTag(new CityItemHolder(index, name, time)); + } + + // Bind data into the child views. + final CityItemHolder holder = (CityItemHolder) view.getTag(); + 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) { + holder.index.setText(city.getIndexString()); + holder.index.setTextSize(TypedValue.COMPLEX_UNIT_SP, 24); + } + + // skip checkbox and other animations + view.jumpDrawablesToCurrentState(); + view.setOnClickListener(this); + return view; + } + + @Override + public void onClick(View v) { + final int position = mCitiesList.getPositionForView(v); + if (position >= 0) { + final City selectedCity = getItem(position); + + // Associate the widget id to the selected city. + DataModel.getDataModel().setWidgetCity(mWidgetId, selectedCity); + + // Broadcast an intent to update the instance of the widget now that data exists. + final Intent intent = new Intent(ACTION_APPWIDGET_UPDATE, null, mContext, + CityAppWidgetProvider.class); + intent.putExtra(EXTRA_APPWIDGET_IDS, new int[] {mWidgetId}); + sendBroadcast(intent); + + // Indicate successful configuration of the app widget. + final Intent result = new Intent().putExtra(EXTRA_APPWIDGET_ID, mWidgetId); + setResult(RESULT_OK, result); + finish(); + } + } + + @Override + public Object[] getSections() { + if (mSectionHeaders == null) { + // Make an educated guess at the expected number of sections. + final int approximateSectionCount = getCount() / 5; + final List<String> sections = new ArrayList<>(approximateSectionCount); + final List<Integer> positions = new ArrayList<>(approximateSectionCount); + + 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); + sections.add(city.getIndexString()); + 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); + + // 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 = City.removeSpecialCharacters(queryText.toUpperCase()); + final List<City> cities = DataModel.getDataModel().getAllCities(); + + // Compute the filtered list of cities. + final List<City> filteredCities; + if (TextUtils.isEmpty(query)) { + filteredCities = cities; + } else { + filteredCities = new ArrayList<>(cities.size()); + for (City city : cities) { + if (city.matches(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 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; + } + + // The first entry is always a header. + if (position == 0) { + 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 mNameIndexComparator.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; + + public CityItemHolder(TextView index, TextView name, TextView time) { + this.index = index; + this.name = name; + this.time = time; + } + } +}
\ No newline at end of file |