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 }