1 /* 2 * Copyright (C) 2012 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17 package com.android.deskclock; 18 19 import android.app.Activity; 20 import android.app.AlarmManager; 21 import android.content.BroadcastReceiver; 22 import android.content.Context; 23 import android.content.Intent; 24 import android.content.IntentFilter; 25 import android.content.res.Resources; 26 import android.database.ContentObserver; 27 import android.net.Uri; 28 import android.os.Bundle; 29 import android.os.Handler; 30 import android.provider.Settings; 31 import androidx.annotation.NonNull; 32 import androidx.recyclerview.widget.LinearLayoutManager; 33 import androidx.recyclerview.widget.RecyclerView; 34 import android.text.format.DateUtils; 35 import android.view.GestureDetector; 36 import android.view.LayoutInflater; 37 import android.view.MotionEvent; 38 import android.view.View; 39 import android.view.ViewGroup; 40 import android.widget.Button; 41 import android.widget.ImageView; 42 import android.widget.TextClock; 43 import android.widget.TextView; 44 45 import com.android.deskclock.data.City; 46 import com.android.deskclock.data.CityListener; 47 import com.android.deskclock.data.DataModel; 48 import com.android.deskclock.events.Events; 49 import com.android.deskclock.uidata.UiDataModel; 50 import com.android.deskclock.worldclock.CitySelectionActivity; 51 52 import java.util.Calendar; 53 import java.util.List; 54 import java.util.TimeZone; 55 56 import static android.app.AlarmManager.ACTION_NEXT_ALARM_CLOCK_CHANGED; 57 import static android.view.View.GONE; 58 import static android.view.View.INVISIBLE; 59 import static android.view.View.VISIBLE; 60 import static com.android.deskclock.uidata.UiDataModel.Tab.CLOCKS; 61 import static java.util.Calendar.DAY_OF_WEEK; 62 63 /** 64 * Fragment that shows the clock (analog or digital), the next alarm info and the world clock. 65 */ 66 public final class ClockFragment extends DeskClockFragment { 67 68 // Updates dates in the UI on every quarter-hour. 69 private final Runnable mQuarterHourUpdater = new QuarterHourRunnable(); 70 71 // Updates the UI in response to changes to the scheduled alarm. 72 private BroadcastReceiver mAlarmChangeReceiver; 73 74 // Detects changes to the next scheduled alarm pre-L. 75 private ContentObserver mAlarmObserver; 76 77 private TextClock mDigitalClock; 78 private AnalogClock mAnalogClock; 79 private View mClockFrame; 80 private SelectedCitiesAdapter mCityAdapter; 81 private RecyclerView mCityList; 82 private String mDateFormat; 83 private String mDateFormatForAccessibility; 84 85 /** 86 * The public no-arg constructor required by all fragments. 87 */ ClockFragment()88 public ClockFragment() { 89 super(CLOCKS); 90 } 91 92 @Override onCreate(Bundle savedInstanceState)93 public void onCreate(Bundle savedInstanceState) { 94 super.onCreate(savedInstanceState); 95 96 mAlarmObserver = Utils.isPreL() ? new AlarmObserverPreL() : null; 97 mAlarmChangeReceiver = Utils.isLOrLater() ? new AlarmChangedBroadcastReceiver() : null; 98 } 99 100 @Override onCreateView(LayoutInflater inflater, ViewGroup container, Bundle icicle)101 public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle icicle) { 102 super.onCreateView(inflater, container, icicle); 103 104 final View fragmentView = inflater.inflate(R.layout.clock_fragment, container, false); 105 106 mDateFormat = getString(R.string.abbrev_wday_month_day_no_year); 107 mDateFormatForAccessibility = getString(R.string.full_wday_month_day_no_year); 108 109 mCityAdapter = new SelectedCitiesAdapter(getActivity(), mDateFormat, 110 mDateFormatForAccessibility); 111 112 mCityList = (RecyclerView) fragmentView.findViewById(R.id.cities); 113 mCityList.setLayoutManager(new LinearLayoutManager(getActivity())); 114 mCityList.setAdapter(mCityAdapter); 115 mCityList.setItemAnimator(null); 116 DataModel.getDataModel().addCityListener(mCityAdapter); 117 118 final ScrollPositionWatcher scrollPositionWatcher = new ScrollPositionWatcher(); 119 mCityList.addOnScrollListener(scrollPositionWatcher); 120 121 final Context context = container.getContext(); 122 mCityList.setOnTouchListener(new CityListOnLongClickListener(context)); 123 fragmentView.setOnLongClickListener(new StartScreenSaverListener()); 124 125 // On tablet landscape, the clock frame will be a distinct view. Otherwise, it'll be added 126 // on as a header to the main listview. 127 mClockFrame = fragmentView.findViewById(R.id.main_clock_left_pane); 128 if (mClockFrame != null) { 129 mDigitalClock = (TextClock) mClockFrame.findViewById(R.id.digital_clock); 130 mAnalogClock = (AnalogClock) mClockFrame.findViewById(R.id.analog_clock); 131 Utils.setClockIconTypeface(mClockFrame); 132 Utils.updateDate(mDateFormat, mDateFormatForAccessibility, mClockFrame); 133 Utils.setClockStyle(mDigitalClock, mAnalogClock); 134 Utils.setClockSecondsEnabled(mDigitalClock, mAnalogClock); 135 } 136 137 // Schedule a runnable to update the date every quarter hour. 138 UiDataModel.getUiDataModel().addQuarterHourCallback(mQuarterHourUpdater, 100); 139 140 return fragmentView; 141 } 142 143 @Override onResume()144 public void onResume() { 145 super.onResume(); 146 147 final Activity activity = getActivity(); 148 149 mDateFormat = getString(R.string.abbrev_wday_month_day_no_year); 150 mDateFormatForAccessibility = getString(R.string.full_wday_month_day_no_year); 151 152 // Watch for system events that effect clock time or format. 153 if (mAlarmChangeReceiver != null) { 154 final IntentFilter filter = new IntentFilter(ACTION_NEXT_ALARM_CLOCK_CHANGED); 155 activity.registerReceiver(mAlarmChangeReceiver, filter); 156 } 157 158 // Resume can be invoked after changing the clock style or seconds display. 159 if (mDigitalClock != null && mAnalogClock != null) { 160 Utils.setClockStyle(mDigitalClock, mAnalogClock); 161 Utils.setClockSecondsEnabled(mDigitalClock, mAnalogClock); 162 } 163 164 final View view = getView(); 165 if (view != null && view.findViewById(R.id.main_clock_left_pane) != null) { 166 // Center the main clock frame by hiding the world clocks when none are selected. 167 mCityList.setVisibility(mCityAdapter.getItemCount() == 0 ? GONE : VISIBLE); 168 } 169 170 refreshAlarm(); 171 172 // Alarm observer is null on L or later. 173 if (mAlarmObserver != null) { 174 @SuppressWarnings("deprecation") 175 final Uri uri = Settings.System.getUriFor(Settings.System.NEXT_ALARM_FORMATTED); 176 activity.getContentResolver().registerContentObserver(uri, false, mAlarmObserver); 177 } 178 } 179 180 @Override onPause()181 public void onPause() { 182 super.onPause(); 183 184 final Activity activity = getActivity(); 185 if (mAlarmChangeReceiver != null) { 186 activity.unregisterReceiver(mAlarmChangeReceiver); 187 } 188 if (mAlarmObserver != null) { 189 activity.getContentResolver().unregisterContentObserver(mAlarmObserver); 190 } 191 } 192 193 @Override onDestroyView()194 public void onDestroyView() { 195 super.onDestroyView(); 196 UiDataModel.getUiDataModel().removePeriodicCallback(mQuarterHourUpdater); 197 DataModel.getDataModel().removeCityListener(mCityAdapter); 198 } 199 200 @Override onFabClick(@onNull ImageView fab)201 public void onFabClick(@NonNull ImageView fab) { 202 startActivity(new Intent(getActivity(), CitySelectionActivity.class)); 203 } 204 205 @Override onUpdateFab(@onNull ImageView fab)206 public void onUpdateFab(@NonNull ImageView fab) { 207 fab.setVisibility(VISIBLE); 208 fab.setImageResource(R.drawable.ic_public); 209 fab.setContentDescription(fab.getResources().getString(R.string.button_cities)); 210 } 211 212 @Override onUpdateFabButtons(@onNull Button left, @NonNull Button right)213 public void onUpdateFabButtons(@NonNull Button left, @NonNull Button right) { 214 left.setVisibility(INVISIBLE); 215 right.setVisibility(INVISIBLE); 216 } 217 218 /** 219 * Refresh the next alarm time. 220 */ refreshAlarm()221 private void refreshAlarm() { 222 if (mClockFrame != null) { 223 Utils.refreshAlarm(getActivity(), mClockFrame); 224 } else { 225 mCityAdapter.refreshAlarm(); 226 } 227 } 228 229 /** 230 * Long pressing over the main clock starts the screen saver. 231 */ 232 private final class StartScreenSaverListener implements View.OnLongClickListener { 233 234 @Override onLongClick(View view)235 public boolean onLongClick(View view) { 236 startActivity(new Intent(getActivity(), ScreensaverActivity.class) 237 .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK) 238 .putExtra(Events.EXTRA_EVENT_LABEL, R.string.label_deskclock)); 239 return true; 240 } 241 } 242 243 /** 244 * Long pressing over the city list starts the screen saver. 245 */ 246 private final class CityListOnLongClickListener extends GestureDetector.SimpleOnGestureListener 247 implements View.OnTouchListener { 248 249 private final GestureDetector mGestureDetector; 250 CityListOnLongClickListener(Context context)251 private CityListOnLongClickListener(Context context) { 252 mGestureDetector = new GestureDetector(context, this); 253 } 254 255 @Override onLongPress(MotionEvent e)256 public void onLongPress(MotionEvent e) { 257 final View view = getView(); 258 if (view != null) { 259 view.performLongClick(); 260 } 261 } 262 263 @Override onDown(MotionEvent e)264 public boolean onDown(MotionEvent e) { 265 return true; 266 } 267 268 @Override onTouch(View v, MotionEvent event)269 public boolean onTouch(View v, MotionEvent event) { 270 return mGestureDetector.onTouchEvent(event); 271 } 272 } 273 274 /** 275 * This runnable executes at every quarter-hour (e.g. 1:00, 1:15, 1:30, 1:45, etc...) and 276 * updates the dates displayed within the UI. Quarter-hour increments were chosen to accommodate 277 * the "weirdest" timezones (e.g. Nepal is UTC/GMT +05:45). 278 */ 279 private final class QuarterHourRunnable implements Runnable { 280 @Override run()281 public void run() { 282 mCityAdapter.notifyDataSetChanged(); 283 } 284 } 285 286 /** 287 * Prior to L, a ContentObserver was used to monitor changes to the next scheduled alarm. 288 * In L and beyond this is accomplished via a system broadcast of 289 * {@link AlarmManager#ACTION_NEXT_ALARM_CLOCK_CHANGED}. 290 */ 291 private final class AlarmObserverPreL extends ContentObserver { AlarmObserverPreL()292 private AlarmObserverPreL() { 293 super(new Handler()); 294 } 295 296 @Override onChange(boolean selfChange)297 public void onChange(boolean selfChange) { 298 refreshAlarm(); 299 } 300 } 301 302 /** 303 * Update the display of the scheduled alarm as it changes. 304 */ 305 private final class AlarmChangedBroadcastReceiver extends BroadcastReceiver { 306 @Override onReceive(Context context, Intent intent)307 public void onReceive(Context context, Intent intent) { 308 refreshAlarm(); 309 } 310 } 311 312 /** 313 * Updates the vertical scroll state of this tab in the {@link UiDataModel} as the user scrolls 314 * the recyclerview or when the size/position of elements within the recyclerview changes. 315 */ 316 private final class ScrollPositionWatcher extends RecyclerView.OnScrollListener 317 implements View.OnLayoutChangeListener { 318 @Override onScrolled(RecyclerView recyclerView, int dx, int dy)319 public void onScrolled(RecyclerView recyclerView, int dx, int dy) { 320 setTabScrolledToTop(Utils.isScrolledToTop(mCityList)); 321 } 322 323 @Override onLayoutChange(View v, int left, int top, int right, int bottom, int oldLeft, int oldTop, int oldRight, int oldBottom)324 public void onLayoutChange(View v, int left, int top, int right, int bottom, 325 int oldLeft, int oldTop, int oldRight, int oldBottom) { 326 setTabScrolledToTop(Utils.isScrolledToTop(mCityList)); 327 } 328 } 329 330 /** 331 * This adapter lists all of the selected world clocks. Optionally, it also includes a clock at 332 * the top for the home timezone if "Automatic home clock" is turned on in settings and the 333 * current time at home does not match the current time in the timezone of the current location. 334 * If the phone is in portrait mode it will also include the main clock at the top. 335 */ 336 private static final class SelectedCitiesAdapter extends RecyclerView.Adapter 337 implements CityListener { 338 339 private final static int MAIN_CLOCK = R.layout.main_clock_frame; 340 private final static int WORLD_CLOCK = R.layout.world_clock_item; 341 342 private final LayoutInflater mInflater; 343 private final Context mContext; 344 private final boolean mIsPortrait; 345 private final boolean mShowHomeClock; 346 private final String mDateFormat; 347 private final String mDateFormatForAccessibility; 348 SelectedCitiesAdapter(Context context, String dateFormat, String dateFormatForAccessibility)349 private SelectedCitiesAdapter(Context context, String dateFormat, 350 String dateFormatForAccessibility) { 351 mContext = context; 352 mDateFormat = dateFormat; 353 mDateFormatForAccessibility = dateFormatForAccessibility; 354 mInflater = LayoutInflater.from(context); 355 mIsPortrait = Utils.isPortrait(context); 356 mShowHomeClock = DataModel.getDataModel().getShowHomeClock(); 357 } 358 359 @Override getItemViewType(int position)360 public int getItemViewType(int position) { 361 if (position == 0 && mIsPortrait) { 362 return MAIN_CLOCK; 363 } 364 return WORLD_CLOCK; 365 } 366 367 @Override onCreateViewHolder(ViewGroup parent, int viewType)368 public RecyclerView.ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { 369 final View view = mInflater.inflate(viewType, parent, false); 370 switch (viewType) { 371 case WORLD_CLOCK: 372 return new CityViewHolder(view); 373 case MAIN_CLOCK: 374 return new MainClockViewHolder(view); 375 default: 376 throw new IllegalArgumentException("View type not recognized"); 377 } 378 } 379 380 @Override onBindViewHolder(RecyclerView.ViewHolder holder, int position)381 public void onBindViewHolder(RecyclerView.ViewHolder holder, int position) { 382 final int viewType = getItemViewType(position); 383 switch (viewType) { 384 case WORLD_CLOCK: 385 // Retrieve the city to bind. 386 final City city; 387 // If showing home clock, put it at the top 388 if (mShowHomeClock && position == (mIsPortrait ? 1 : 0)) { 389 city = getHomeCity(); 390 } else { 391 final int positionAdjuster = (mIsPortrait ? 1 : 0) 392 + (mShowHomeClock ? 1 : 0); 393 city = getCities().get(position - positionAdjuster); 394 } 395 ((CityViewHolder) holder).bind(mContext, city, position, mIsPortrait); 396 break; 397 case MAIN_CLOCK: 398 ((MainClockViewHolder) holder).bind(mContext, mDateFormat, 399 mDateFormatForAccessibility, getItemCount() > 1); 400 break; 401 default: 402 throw new IllegalArgumentException("Unexpected view type: " + viewType); 403 } 404 } 405 406 @Override getItemCount()407 public int getItemCount() { 408 final int mainClockCount = mIsPortrait ? 1 : 0; 409 final int homeClockCount = mShowHomeClock ? 1 : 0; 410 final int worldClockCount = getCities().size(); 411 return mainClockCount + homeClockCount + worldClockCount; 412 } 413 getHomeCity()414 private City getHomeCity() { 415 return DataModel.getDataModel().getHomeCity(); 416 } 417 getCities()418 private List<City> getCities() { 419 return DataModel.getDataModel().getSelectedCities(); 420 } 421 refreshAlarm()422 private void refreshAlarm() { 423 if (mIsPortrait && getItemCount() > 0) { 424 notifyItemChanged(0); 425 } 426 } 427 428 @Override citiesChanged(List<City> oldCities, List<City> newCities)429 public void citiesChanged(List<City> oldCities, List<City> newCities) { 430 notifyDataSetChanged(); 431 } 432 433 private static final class CityViewHolder extends RecyclerView.ViewHolder { 434 435 private final TextView mName; 436 private final TextClock mDigitalClock; 437 private final AnalogClock mAnalogClock; 438 private final TextView mHoursAhead; 439 CityViewHolder(View itemView)440 private CityViewHolder(View itemView) { 441 super(itemView); 442 443 mName = (TextView) itemView.findViewById(R.id.city_name); 444 mDigitalClock = (TextClock) itemView.findViewById(R.id.digital_clock); 445 mAnalogClock = (AnalogClock) itemView.findViewById(R.id.analog_clock); 446 mHoursAhead = (TextView) itemView.findViewById(R.id.hours_ahead); 447 } 448 bind(Context context, City city, int position, boolean isPortrait)449 private void bind(Context context, City city, int position, boolean isPortrait) { 450 final String cityTimeZoneId = city.getTimeZone().getID(); 451 452 // Configure the digital clock or analog clock depending on the user preference. 453 if (DataModel.getDataModel().getClockStyle() == DataModel.ClockStyle.ANALOG) { 454 mDigitalClock.setVisibility(GONE); 455 mAnalogClock.setVisibility(VISIBLE); 456 mAnalogClock.setTimeZone(cityTimeZoneId); 457 mAnalogClock.enableSeconds(false); 458 } else { 459 mAnalogClock.setVisibility(GONE); 460 mDigitalClock.setVisibility(VISIBLE); 461 mDigitalClock.setTimeZone(cityTimeZoneId); 462 mDigitalClock.setFormat12Hour(Utils.get12ModeFormat(0.3f /* amPmRatio */, 463 false)); 464 mDigitalClock.setFormat24Hour(Utils.get24ModeFormat(false)); 465 } 466 467 // Supply top and bottom padding dynamically. 468 final Resources res = context.getResources(); 469 final int padding = res.getDimensionPixelSize(R.dimen.medium_space_top); 470 final int top = position == 0 && !isPortrait ? 0 : padding; 471 final int left = itemView.getPaddingLeft(); 472 final int right = itemView.getPaddingRight(); 473 final int bottom = itemView.getPaddingBottom(); 474 itemView.setPadding(left, top, right, bottom); 475 476 // Bind the city name. 477 mName.setText(city.getName()); 478 479 // Compute if the city week day matches the weekday of the current timezone. 480 final Calendar localCal = Calendar.getInstance(TimeZone.getDefault()); 481 final Calendar cityCal = Calendar.getInstance(city.getTimeZone()); 482 final boolean displayDayOfWeek = 483 localCal.get(DAY_OF_WEEK) != cityCal.get(DAY_OF_WEEK); 484 485 // Compare offset from UTC time on today's date (daylight savings time, etc.) 486 final TimeZone currentTimeZone = TimeZone.getDefault(); 487 final TimeZone cityTimeZone = TimeZone.getTimeZone(cityTimeZoneId); 488 final long currentTimeMillis = System.currentTimeMillis(); 489 final long currentUtcOffset = currentTimeZone.getOffset(currentTimeMillis); 490 final long cityUtcOffset = cityTimeZone.getOffset(currentTimeMillis); 491 final long offsetDelta = cityUtcOffset - currentUtcOffset; 492 493 final int hoursDifferent = (int) (offsetDelta / DateUtils.HOUR_IN_MILLIS); 494 final int minutesDifferent = (int) (offsetDelta / DateUtils.MINUTE_IN_MILLIS) % 60; 495 final boolean displayMinutes = offsetDelta % DateUtils.HOUR_IN_MILLIS != 0; 496 final boolean isAhead = hoursDifferent > 0 || (hoursDifferent == 0 497 && minutesDifferent > 0); 498 if (!Utils.isLandscape(context)) { 499 // Bind the number of hours ahead or behind, or hide if the time is the same. 500 final boolean displayDifference = hoursDifferent != 0 || displayMinutes; 501 mHoursAhead.setVisibility(displayDifference ? VISIBLE : GONE); 502 final String timeString = Utils.createHoursDifferentString( 503 context, displayMinutes, isAhead, hoursDifferent, minutesDifferent); 504 mHoursAhead.setText(displayDayOfWeek ? 505 (context.getString(isAhead ? R.string.world_hours_tomorrow 506 : R.string.world_hours_yesterday, timeString)) 507 : timeString); 508 } else { 509 // Only tomorrow/yesterday should be shown in landscape view. 510 mHoursAhead.setVisibility(displayDayOfWeek ? View.VISIBLE : View.GONE); 511 if (displayDayOfWeek) { 512 mHoursAhead.setText(context.getString(isAhead ? R.string.world_tomorrow 513 : R.string.world_yesterday)); 514 } 515 516 } 517 } 518 } 519 520 private static final class MainClockViewHolder extends RecyclerView.ViewHolder { 521 522 private final View mHairline; 523 private final TextClock mDigitalClock; 524 private final AnalogClock mAnalogClock; 525 MainClockViewHolder(View itemView)526 private MainClockViewHolder(View itemView) { 527 super(itemView); 528 529 mHairline = itemView.findViewById(R.id.hairline); 530 mDigitalClock = (TextClock) itemView.findViewById(R.id.digital_clock); 531 mAnalogClock = (AnalogClock) itemView.findViewById(R.id.analog_clock); 532 Utils.setClockIconTypeface(itemView); 533 } 534 bind(Context context, String dateFormat, String dateFormatForAccessibility, boolean showHairline)535 private void bind(Context context, String dateFormat, 536 String dateFormatForAccessibility, boolean showHairline) { 537 Utils.refreshAlarm(context, itemView); 538 539 Utils.updateDate(dateFormat, dateFormatForAccessibility, itemView); 540 Utils.setClockStyle(mDigitalClock, mAnalogClock); 541 mHairline.setVisibility(showHairline ? VISIBLE : GONE); 542 543 Utils.setClockSecondsEnabled(mDigitalClock, mAnalogClock); 544 } 545 } 546 } 547 } 548