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
17package com.android.deskclock;
18
19import android.app.Activity;
20import android.app.AlarmManager;
21import android.content.BroadcastReceiver;
22import android.content.Context;
23import android.content.Intent;
24import android.content.IntentFilter;
25import android.content.res.Resources;
26import android.database.ContentObserver;
27import android.net.Uri;
28import android.os.Bundle;
29import android.os.Handler;
30import android.provider.Settings;
31import android.support.annotation.NonNull;
32import android.support.v7.widget.LinearLayoutManager;
33import android.support.v7.widget.RecyclerView;
34import android.text.format.DateUtils;
35import android.view.GestureDetector;
36import android.view.LayoutInflater;
37import android.view.MotionEvent;
38import android.view.View;
39import android.view.ViewGroup;
40import android.widget.Button;
41import android.widget.ImageView;
42import android.widget.TextClock;
43import android.widget.TextView;
44
45import com.android.deskclock.data.City;
46import com.android.deskclock.data.CityListener;
47import com.android.deskclock.data.DataModel;
48import com.android.deskclock.events.Events;
49import com.android.deskclock.uidata.UiDataModel;
50import com.android.deskclock.worldclock.CitySelectionActivity;
51
52import java.util.Calendar;
53import java.util.List;
54import java.util.TimeZone;
55
56import static android.app.AlarmManager.ACTION_NEXT_ALARM_CLOCK_CHANGED;
57import static android.view.View.GONE;
58import static android.view.View.INVISIBLE;
59import static android.view.View.VISIBLE;
60import static com.android.deskclock.uidata.UiDataModel.Tab.CLOCKS;
61import 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 */
66public 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     */
88    public ClockFragment() {
89        super(CLOCKS);
90    }
91
92    @Override
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
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
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
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
194    public void onDestroyView() {
195        super.onDestroyView();
196        UiDataModel.getUiDataModel().removePeriodicCallback(mQuarterHourUpdater);
197        DataModel.getDataModel().removeCityListener(mCityAdapter);
198    }
199
200    @Override
201    public void onFabClick(@NonNull ImageView fab) {
202        startActivity(new Intent(getActivity(), CitySelectionActivity.class));
203    }
204
205    @Override
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
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     */
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
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
251        private CityListOnLongClickListener(Context context) {
252            mGestureDetector = new GestureDetector(context, this);
253        }
254
255        @Override
256        public void onLongPress(MotionEvent e) {
257            final View view = getView();
258            if (view != null) {
259                view.performLongClick();
260            }
261        }
262
263        @Override
264        public boolean onDown(MotionEvent e) {
265            return true;
266        }
267
268        @Override
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
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 {
292        private AlarmObserverPreL() {
293            super(new Handler());
294        }
295
296        @Override
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
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
319        public void onScrolled(RecyclerView recyclerView, int dx, int dy) {
320            setTabScrolledToTop(Utils.isScrolledToTop(mCityList));
321        }
322
323        @Override
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
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
360        public int getItemViewType(int position) {
361            if (position == 0 && mIsPortrait) {
362                return MAIN_CLOCK;
363            }
364            return WORLD_CLOCK;
365        }
366
367        @Override
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
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
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
414        private City getHomeCity() {
415            return DataModel.getDataModel().getHomeCity();
416        }
417
418        private List<City> getCities() {
419            return DataModel.getDataModel().getSelectedCities();
420        }
421
422        private void refreshAlarm() {
423            if (mIsPortrait && getItemCount() > 0) {
424                notifyItemChanged(0);
425            }
426        }
427
428        @Override
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
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
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
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
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