1 /* 2 * Copyright (C) 2020 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.AlarmManager 20 import android.content.BroadcastReceiver 21 import android.content.Context 22 import android.content.Intent 23 import android.content.IntentFilter 24 import android.database.ContentObserver 25 import android.os.Bundle 26 import android.os.Handler 27 import android.provider.Settings 28 import android.text.format.DateUtils 29 import android.view.GestureDetector 30 import android.view.LayoutInflater 31 import android.view.MotionEvent 32 import android.view.View 33 import android.view.ViewGroup 34 import android.view.GestureDetector.SimpleOnGestureListener 35 import android.widget.Button 36 import android.widget.ImageView 37 import android.widget.TextClock 38 import android.widget.TextView 39 import androidx.recyclerview.widget.LinearLayoutManager 40 import androidx.recyclerview.widget.RecyclerView 41 42 import com.android.deskclock.data.City 43 import com.android.deskclock.data.CityListener 44 import com.android.deskclock.data.DataModel 45 import com.android.deskclock.events.Events 46 import com.android.deskclock.uidata.UiDataModel 47 import com.android.deskclock.worldclock.CitySelectionActivity 48 49 import java.util.Calendar 50 import java.util.TimeZone 51 52 /** 53 * Fragment that shows the clock (analog or digital), the next alarm info and the world clock. 54 */ 55 class ClockFragment : DeskClockFragment(UiDataModel.Tab.CLOCKS) { 56 // Updates dates in the UI on every quarter-hour. 57 private val mQuarterHourUpdater: Runnable = QuarterHourRunnable() 58 59 // Updates the UI in response to changes to the scheduled alarm. 60 private var mAlarmChangeReceiver: BroadcastReceiver? = null 61 62 // Detects changes to the next scheduled alarm pre-L. 63 private var mAlarmObserver: ContentObserver? = null 64 65 private var mDigitalClock: TextClock? = null 66 private var mAnalogClock: AnalogClock? = null 67 private var mClockFrame: View? = null 68 private lateinit var mCityAdapter: SelectedCitiesAdapter 69 private lateinit var mCityList: RecyclerView 70 private lateinit var mDateFormat: String 71 private lateinit var mDateFormatForAccessibility: String 72 onCreatenull73 override fun onCreate(savedInstanceState: Bundle?) { 74 super.onCreate(savedInstanceState) 75 76 mAlarmObserver = if (Utils.isPreL) AlarmObserverPreL() else null 77 mAlarmChangeReceiver = if (Utils.isLOrLater) AlarmChangedBroadcastReceiver() else null 78 } 79 onCreateViewnull80 override fun onCreateView( 81 inflater: LayoutInflater, 82 container: ViewGroup?, 83 icicle: Bundle? 84 ): View? { 85 super.onCreateView(inflater, container, icicle) 86 87 val fragmentView = inflater.inflate(R.layout.clock_fragment, container, false) 88 89 mDateFormat = getString(R.string.abbrev_wday_month_day_no_year) 90 mDateFormatForAccessibility = getString(R.string.full_wday_month_day_no_year) 91 92 mCityAdapter = SelectedCitiesAdapter(requireActivity(), mDateFormat, 93 mDateFormatForAccessibility) 94 95 mCityList = fragmentView.findViewById<View>(R.id.cities) as RecyclerView 96 mCityList.setLayoutManager(LinearLayoutManager(requireActivity())) 97 mCityList.setAdapter(mCityAdapter) 98 mCityList.setItemAnimator(null) 99 DataModel.dataModel.addCityListener(mCityAdapter) 100 101 val scrollPositionWatcher = ScrollPositionWatcher() 102 mCityList.addOnScrollListener(scrollPositionWatcher) 103 104 val context = container!!.context 105 mCityList.setOnTouchListener(CityListOnLongClickListener(context)) 106 fragmentView.setOnLongClickListener(StartScreenSaverListener()) 107 108 // On tablet landscape, the clock frame will be a distinct view. Otherwise, it'll be added 109 // on as a header to the main listview. 110 mClockFrame = fragmentView.findViewById(R.id.main_clock_left_pane) 111 if (mClockFrame != null) { 112 mDigitalClock = mClockFrame!!.findViewById<View>(R.id.digital_clock) as TextClock 113 mAnalogClock = mClockFrame!!.findViewById<View>(R.id.analog_clock) as AnalogClock 114 Utils.setClockIconTypeface(mClockFrame) 115 Utils.updateDate(mDateFormat, mDateFormatForAccessibility, mClockFrame) 116 Utils.setClockStyle(mDigitalClock!!, mAnalogClock!!) 117 Utils.setClockSecondsEnabled(mDigitalClock!!, mAnalogClock!!) 118 } 119 120 // Schedule a runnable to update the date every quarter hour. 121 UiDataModel.uiDataModel.addQuarterHourCallback(mQuarterHourUpdater) 122 123 return fragmentView 124 } 125 onResumenull126 override fun onResume() { 127 super.onResume() 128 129 val activity = requireActivity() 130 131 mDateFormat = getString(R.string.abbrev_wday_month_day_no_year) 132 mDateFormatForAccessibility = getString(R.string.full_wday_month_day_no_year) 133 134 // Watch for system events that effect clock time or format. 135 if (mAlarmChangeReceiver != null) { 136 val filter = IntentFilter(AlarmManager.ACTION_NEXT_ALARM_CLOCK_CHANGED) 137 activity.registerReceiver(mAlarmChangeReceiver, filter) 138 } 139 140 // Resume can be invoked after changing the clock style or seconds display. 141 if (mDigitalClock != null && mAnalogClock != null) { 142 Utils.setClockStyle(mDigitalClock!!, mAnalogClock!!) 143 Utils.setClockSecondsEnabled(mDigitalClock!!, mAnalogClock!!) 144 } 145 146 val view = view 147 if (view?.findViewById<View?>(R.id.main_clock_left_pane) != null) { 148 // Center the main clock frame by hiding the world clocks when none are selected. 149 mCityList.setVisibility(if (mCityAdapter.getItemCount() == 0) { 150 View.GONE 151 } else { 152 View.VISIBLE 153 }) 154 } 155 156 refreshAlarm() 157 158 // Alarm observer is null on L or later. 159 mAlarmObserver?.let { 160 val uri = Settings.System.getUriFor(Settings.System.NEXT_ALARM_FORMATTED) 161 activity.contentResolver.registerContentObserver(uri, false, it) 162 } 163 } 164 onPausenull165 override fun onPause() { 166 super.onPause() 167 168 val activity = requireActivity() 169 if (mAlarmChangeReceiver != null) { 170 activity.unregisterReceiver(mAlarmChangeReceiver) 171 } 172 if (mAlarmObserver != null) { 173 activity.contentResolver.unregisterContentObserver(mAlarmObserver!!) 174 } 175 } 176 onDestroyViewnull177 override fun onDestroyView() { 178 super.onDestroyView() 179 UiDataModel.uiDataModel.removePeriodicCallback(mQuarterHourUpdater) 180 DataModel.dataModel.removeCityListener(mCityAdapter) 181 } 182 onFabClicknull183 override fun onFabClick(fab: ImageView) { 184 startActivity(Intent(requireActivity(), CitySelectionActivity::class.java)) 185 } 186 onUpdateFabnull187 override fun onUpdateFab(fab: ImageView) { 188 fab.visibility = View.VISIBLE 189 fab.setImageResource(R.drawable.ic_public) 190 fab.contentDescription = fab.resources.getString(R.string.button_cities) 191 } 192 onUpdateFabButtonsnull193 override fun onUpdateFabButtons(left: Button, right: Button) { 194 left.visibility = View.INVISIBLE 195 right.visibility = View.INVISIBLE 196 } 197 198 /** 199 * Refresh the next alarm time. 200 */ refreshAlarmnull201 private fun refreshAlarm() { 202 if (mClockFrame != null) { 203 Utils.refreshAlarm(requireActivity(), mClockFrame) 204 } else { 205 mCityAdapter.refreshAlarm() 206 } 207 } 208 209 /** 210 * Long pressing over the main clock starts the screen saver. 211 */ 212 private inner class StartScreenSaverListener : View.OnLongClickListener { onLongClicknull213 override fun onLongClick(view: View): Boolean { 214 startActivity(Intent(requireActivity(), ScreensaverActivity::class.java) 215 .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK) 216 .putExtra(Events.EXTRA_EVENT_LABEL, R.string.label_deskclock)) 217 return true 218 } 219 } 220 221 /** 222 * Long pressing over the city list starts the screen saver. 223 */ 224 private inner class CityListOnLongClickListener( 225 context: Context 226 ) : SimpleOnGestureListener(), View.OnTouchListener { 227 private val mGestureDetector = GestureDetector(context, this) 228 onLongPressnull229 override fun onLongPress(e: MotionEvent) { 230 val view = view 231 view?.performLongClick() 232 } 233 onDownnull234 override fun onDown(e: MotionEvent): Boolean { 235 return true 236 } 237 onTouchnull238 override fun onTouch(v: View, event: MotionEvent): Boolean { 239 return mGestureDetector.onTouchEvent(event) 240 } 241 } 242 243 /** 244 * This runnable executes at every quarter-hour (e.g. 1:00, 1:15, 1:30, 1:45, etc...) and 245 * updates the dates displayed within the UI. Quarter-hour increments were chosen to accommodate 246 * the "weirdest" timezones (e.g. Nepal is UTC/GMT +05:45). 247 */ 248 private inner class QuarterHourRunnable : Runnable { runnull249 override fun run() { 250 mCityAdapter.notifyDataSetChanged() 251 } 252 } 253 254 /** 255 * Prior to L, a ContentObserver was used to monitor changes to the next scheduled alarm. 256 * In L and beyond this is accomplished via a system broadcast of 257 * [AlarmManager.ACTION_NEXT_ALARM_CLOCK_CHANGED]. 258 */ 259 private inner class AlarmObserverPreL : ContentObserver(Handler()) { onChangenull260 override fun onChange(selfChange: Boolean) { 261 refreshAlarm() 262 } 263 } 264 265 /** 266 * Update the display of the scheduled alarm as it changes. 267 */ 268 private inner class AlarmChangedBroadcastReceiver : BroadcastReceiver() { onReceivenull269 override fun onReceive(context: Context, intent: Intent) { 270 refreshAlarm() 271 } 272 } 273 274 /** 275 * Updates the vertical scroll state of this tab in the [UiDataModel] as the user scrolls 276 * the recyclerview or when the size/position of elements within the recyclerview changes. 277 */ 278 private inner class ScrollPositionWatcher 279 : RecyclerView.OnScrollListener(), View.OnLayoutChangeListener { onScrollednull280 override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { 281 setTabScrolledToTop(Utils.isScrolledToTop(mCityList)) 282 } 283 onLayoutChangenull284 override fun onLayoutChange( 285 v: View, 286 left: Int, 287 top: Int, 288 right: Int, 289 bottom: Int, 290 oldLeft: Int, 291 oldTop: Int, 292 oldRight: Int, 293 oldBottom: Int 294 ) { 295 setTabScrolledToTop(Utils.isScrolledToTop(mCityList)) 296 } 297 } 298 299 /** 300 * This adapter lists all of the selected world clocks. Optionally, it also includes a clock at 301 * the top for the home timezone if "Automatic home clock" is turned on in settings and the 302 * current time at home does not match the current time in the timezone of the current location. 303 * If the phone is in portrait mode it will also include the main clock at the top. 304 */ 305 private class SelectedCitiesAdapter( 306 private val mContext: Context, 307 private val mDateFormat: String?, 308 private val mDateFormatForAccessibility: String? 309 ) : RecyclerView.Adapter<RecyclerView.ViewHolder>(), CityListener { 310 private val mInflater = LayoutInflater.from(mContext) 311 private val mIsPortrait: Boolean = Utils.isPortrait(mContext) 312 private val mShowHomeClock: Boolean = DataModel.dataModel.showHomeClock 313 getItemViewTypenull314 override fun getItemViewType(position: Int): Int { 315 return if (position == 0 && mIsPortrait) { 316 MAIN_CLOCK 317 } else WORLD_CLOCK 318 } 319 onCreateViewHoldernull320 override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { 321 val view = mInflater.inflate(viewType, parent, false) 322 return when (viewType) { 323 WORLD_CLOCK -> CityViewHolder(view) 324 MAIN_CLOCK -> MainClockViewHolder(view) 325 else -> throw IllegalArgumentException("View type not recognized") 326 } 327 } 328 onBindViewHoldernull329 override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { 330 when (val viewType = getItemViewType(position)) { 331 WORLD_CLOCK -> { 332 // Retrieve the city to bind. 333 val city: City 334 // If showing home clock, put it at the top 335 city = if (mShowHomeClock && position == (if (mIsPortrait) 1 else 0)) { 336 homeCity 337 } else { 338 val positionAdjuster = ((if (mIsPortrait) 1 else 0) + 339 if (mShowHomeClock) 1 else 0) 340 cities[position - positionAdjuster] 341 } 342 (holder as CityViewHolder).bind(mContext, city, position, mIsPortrait) 343 } 344 MAIN_CLOCK -> (holder as MainClockViewHolder).bind(mContext, mDateFormat, 345 mDateFormatForAccessibility, getItemCount() > 1) 346 else -> throw IllegalArgumentException("Unexpected view type: $viewType") 347 } 348 } 349 getItemCountnull350 override fun getItemCount(): Int { 351 val mainClockCount = if (mIsPortrait) 1 else 0 352 val homeClockCount = if (mShowHomeClock) 1 else 0 353 val worldClockCount = cities.size 354 return mainClockCount + homeClockCount + worldClockCount 355 } 356 357 private val homeCity: City 358 get() = DataModel.dataModel.homeCity 359 360 private val cities: List<City> 361 get() = DataModel.dataModel.selectedCities as List<City> 362 refreshAlarmnull363 fun refreshAlarm() { 364 if (mIsPortrait && getItemCount() > 0) { 365 notifyItemChanged(0) 366 } 367 } 368 citiesChangednull369 override fun citiesChanged(oldCities: List<City>, newCities: List<City>) { 370 notifyDataSetChanged() 371 } 372 373 private class CityViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { 374 private val mName: TextView = itemView.findViewById(R.id.city_name) 375 private val mDigitalClock: TextClock = itemView.findViewById(R.id.digital_clock) 376 private val mAnalogClock: AnalogClock = itemView.findViewById(R.id.analog_clock) 377 private val mHoursAhead: TextView = itemView.findViewById(R.id.hours_ahead) 378 bindnull379 fun bind(context: Context, city: City, position: Int, isPortrait: Boolean) { 380 val cityTimeZoneId: String = city.timeZone.id 381 382 // Configure the digital clock or analog clock depending on the user preference. 383 if (DataModel.dataModel.clockStyle == DataModel.ClockStyle.ANALOG) { 384 mDigitalClock.visibility = View.GONE 385 mAnalogClock.visibility = View.VISIBLE 386 mAnalogClock.setTimeZone(cityTimeZoneId) 387 mAnalogClock.enableSeconds(false) 388 } else { 389 mAnalogClock.visibility = View.GONE 390 mDigitalClock.visibility = View.VISIBLE 391 mDigitalClock.timeZone = cityTimeZoneId 392 mDigitalClock.format12Hour = Utils.get12ModeFormat(0.3f, false) 393 mDigitalClock.format24Hour = Utils.get24ModeFormat(false) 394 } 395 396 // Supply top and bottom padding dynamically. 397 val res = context.resources 398 val padding = res.getDimensionPixelSize(R.dimen.medium_space_top) 399 val top = if (position == 0 && !isPortrait) 0 else padding 400 val left: Int = itemView.paddingLeft 401 val right: Int = itemView.paddingRight 402 val bottom: Int = itemView.paddingBottom 403 itemView.setPadding(left, top, right, bottom) 404 405 // Bind the city name. 406 mName.text = city.name 407 408 // Compute if the city week day matches the weekday of the current timezone. 409 val localCal = Calendar.getInstance(TimeZone.getDefault()) 410 val cityCal: Calendar = Calendar.getInstance(city.timeZone) 411 val displayDayOfWeek = 412 localCal[Calendar.DAY_OF_WEEK] != cityCal[Calendar.DAY_OF_WEEK] 413 414 // Compare offset from UTC time on today's date (daylight savings time, etc.) 415 val currentTimeZone = TimeZone.getDefault() 416 val cityTimeZone = TimeZone.getTimeZone(cityTimeZoneId) 417 val currentTimeMillis = System.currentTimeMillis() 418 val currentUtcOffset = currentTimeZone.getOffset(currentTimeMillis).toLong() 419 val cityUtcOffset = cityTimeZone.getOffset(currentTimeMillis).toLong() 420 val offsetDelta = cityUtcOffset - currentUtcOffset 421 422 val hoursDifferent = (offsetDelta / DateUtils.HOUR_IN_MILLIS).toInt() 423 val minutesDifferent = (offsetDelta / DateUtils.MINUTE_IN_MILLIS).toInt() % 60 424 val displayMinutes = offsetDelta % DateUtils.HOUR_IN_MILLIS != 0L 425 val isAhead = hoursDifferent > 0 || (hoursDifferent == 0 && 426 minutesDifferent > 0) 427 if (!Utils.isLandscape(context)) { 428 // Bind the number of hours ahead or behind, or hide if the time is the same. 429 val displayDifference = hoursDifferent != 0 || displayMinutes 430 mHoursAhead.visibility = if (displayDifference) View.VISIBLE else View.GONE 431 val timeString = Utils.createHoursDifferentString( 432 context, displayMinutes, isAhead, hoursDifferent, minutesDifferent) 433 mHoursAhead.text = if (displayDayOfWeek) { 434 context.getString(if (isAhead) { 435 R.string.world_hours_tomorrow 436 } else { 437 R.string.world_hours_yesterday 438 }, timeString) 439 } else { 440 timeString 441 } 442 } else { 443 // Only tomorrow/yesterday should be shown in landscape view. 444 mHoursAhead.visibility = if (displayDayOfWeek) View.VISIBLE else View.GONE 445 if (displayDayOfWeek) { 446 mHoursAhead.text = context.getString(if (isAhead) { 447 R.string.world_tomorrow 448 } else { 449 R.string.world_yesterday 450 }) 451 } 452 } 453 } 454 } 455 456 private class MainClockViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { 457 private val mHairline: View = itemView.findViewById(R.id.hairline) 458 private val mDigitalClock: TextClock = itemView.findViewById(R.id.digital_clock) 459 private val mAnalogClock: AnalogClock = itemView.findViewById(R.id.analog_clock) 460 461 init { 462 Utils.setClockIconTypeface(itemView) 463 } 464 bindnull465 fun bind( 466 context: Context, 467 dateFormat: String?, 468 dateFormatForAccessibility: String?, 469 showHairline: Boolean 470 ) { 471 Utils.refreshAlarm(context, itemView) 472 473 Utils.updateDate(dateFormat, dateFormatForAccessibility, itemView) 474 Utils.setClockStyle(mDigitalClock, mAnalogClock) 475 mHairline.visibility = if (showHairline) View.VISIBLE else View.GONE 476 477 Utils.setClockSecondsEnabled(mDigitalClock, mAnalogClock) 478 } 479 } 480 481 companion object { 482 private const val MAIN_CLOCK = R.layout.main_clock_frame 483 private const val WORLD_CLOCK = R.layout.world_clock_item 484 } 485 } 486 }