├── app
├── .gitignore
├── src
│ ├── main
│ │ ├── res
│ │ │ ├── xml
│ │ │ │ └── backup.xml
│ │ │ ├── mipmap-hdpi
│ │ │ │ └── ic_launcher.png
│ │ │ ├── mipmap-mdpi
│ │ │ │ └── ic_launcher.png
│ │ │ ├── mipmap-xhdpi
│ │ │ │ └── ic_launcher.png
│ │ │ ├── mipmap-xxhdpi
│ │ │ │ └── ic_launcher.png
│ │ │ ├── mipmap-xxxhdpi
│ │ │ │ └── ic_launcher.png
│ │ │ ├── values-w376dp
│ │ │ │ └── styles.xml
│ │ │ ├── values-w600dp
│ │ │ │ ├── styles.xml
│ │ │ │ └── dimens.xml
│ │ │ ├── drawable
│ │ │ │ ├── horizontal_divider.xml
│ │ │ │ ├── ic_expand_toggle_white_24dp.xml
│ │ │ │ ├── ic_add_white_24dp.xml
│ │ │ │ ├── ic_expand_less_white_24dp.xml
│ │ │ │ ├── ic_expand_more_white_24dp.xml
│ │ │ │ ├── ic_event_white_24dp.xml
│ │ │ │ ├── ic_event_white_96dp.xml
│ │ │ │ ├── ic_title_white_24dp.xml
│ │ │ │ ├── ic_access_time_white_24dp.xml
│ │ │ │ ├── ic_clear_night_24dp.xml
│ │ │ │ ├── ic_rain_24dp.xml
│ │ │ │ ├── ic_wind_24dp.xml
│ │ │ │ ├── ic_snow_24dp.xml
│ │ │ │ ├── ic_clear_day_24dp.xml
│ │ │ │ ├── ic_partly_cloudy_night_24dp.xml
│ │ │ │ ├── ic_cloudy_24dp.xml
│ │ │ │ └── ic_partly_cloudy_day_24dp.xml
│ │ │ ├── values-w820dp-land
│ │ │ │ └── dimens.xml
│ │ │ ├── values
│ │ │ │ ├── ids.xml
│ │ │ │ ├── arrays.xml
│ │ │ │ ├── colors.xml
│ │ │ │ ├── dimens.xml
│ │ │ │ ├── strings.xml
│ │ │ │ └── styles.xml
│ │ │ ├── layout
│ │ │ │ ├── grid_item_header.xml
│ │ │ │ ├── grid_item_content.xml
│ │ │ │ ├── fab_add.xml
│ │ │ │ ├── list_item_calendar.xml
│ │ │ │ ├── include_activity_main.xml
│ │ │ │ ├── empty_permission.xml
│ │ │ │ ├── activity_edit.xml
│ │ │ │ ├── toolbar_main.xml
│ │ │ │ ├── list_item_content.xml
│ │ │ │ ├── activity_main.xml
│ │ │ │ ├── list_item_header.xml
│ │ │ │ └── event_edit_view.xml
│ │ │ ├── menu
│ │ │ │ ├── menu_edit.xml
│ │ │ │ └── menu_main.xml
│ │ │ ├── layout-land
│ │ │ │ └── include_activity_main.xml
│ │ │ └── layout-w820dp-land
│ │ │ │ ├── include_activity_main.xml
│ │ │ │ ├── toolbar_main.xml
│ │ │ │ └── activity_edit.xml
│ │ ├── java
│ │ │ └── io
│ │ │ │ └── github
│ │ │ │ └── hidroh
│ │ │ │ └── calendar
│ │ │ │ ├── weather
│ │ │ │ ├── WeatherSyncAlarmReceiver.java
│ │ │ │ ├── Weather.java
│ │ │ │ └── WeatherSyncService.java
│ │ │ │ ├── ViewUtils.java
│ │ │ │ ├── content
│ │ │ │ ├── CalendarCursor.java
│ │ │ │ ├── EventCursor.java
│ │ │ │ └── EventsQueryHandler.java
│ │ │ │ ├── text
│ │ │ │ └── style
│ │ │ │ │ ├── UnderDotSpan.java
│ │ │ │ │ └── CircleSpan.java
│ │ │ │ └── widget
│ │ │ │ ├── CalendarSelectionView.java
│ │ │ │ ├── MonthViewPagerAdapter.java
│ │ │ │ ├── EventCalendarView.java
│ │ │ │ └── AgendaView.java
│ │ └── AndroidManifest.xml
│ └── test
│ │ ├── resources
│ │ └── robolectric.properties
│ │ └── java
│ │ ├── android
│ │ ├── net
│ │ │ └── http
│ │ │ │ └── AndroidHttpClient.java
│ │ └── content
│ │ │ └── ShadowAsyncQueryHandler.java
│ │ └── io
│ │ └── github
│ │ └── hidroh
│ │ └── calendar
│ │ ├── test
│ │ ├── shadows
│ │ │ ├── ShadowViewHolder.java
│ │ │ ├── ShadowLinearLayoutManager.java
│ │ │ ├── ShadowViewPager.java
│ │ │ └── ShadowRecyclerView.java
│ │ ├── TestEventCursor.java
│ │ └── assertions
│ │ │ ├── SpannableStringAssert.java
│ │ │ └── DayTimeAssert.java
│ │ ├── MainActivityPermissionTest.java
│ │ ├── MainActivityCalendarSelectionTest.java
│ │ ├── MainActivityWeatherTest.java
│ │ ├── weather
│ │ └── WeatherSyncServiceTest.java
│ │ ├── widget
│ │ ├── MonthViewTest.java
│ │ └── EventCalendarViewTest.java
│ │ └── CalendarUtilsTest.java
└── build.gradle
├── settings.gradle
├── screenshots
├── 1.png
├── 2.png
├── 3.png
├── 4.png
└── 5.png
├── gradle
└── wrapper
│ ├── gradle-wrapper.jar
│ └── gradle-wrapper.properties
├── .gitignore
├── gradle.properties
├── README.md
├── .travis.yml
├── gradlew.bat
└── gradlew
/app/.gitignore:
--------------------------------------------------------------------------------
1 | /build
2 |
--------------------------------------------------------------------------------
/settings.gradle:
--------------------------------------------------------------------------------
1 | include ':app'
2 |
--------------------------------------------------------------------------------
/screenshots/1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hidroh/calendar/HEAD/screenshots/1.png
--------------------------------------------------------------------------------
/screenshots/2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hidroh/calendar/HEAD/screenshots/2.png
--------------------------------------------------------------------------------
/screenshots/3.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hidroh/calendar/HEAD/screenshots/3.png
--------------------------------------------------------------------------------
/screenshots/4.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hidroh/calendar/HEAD/screenshots/4.png
--------------------------------------------------------------------------------
/screenshots/5.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hidroh/calendar/HEAD/screenshots/5.png
--------------------------------------------------------------------------------
/app/src/main/res/xml/backup.xml:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/app/src/test/resources/robolectric.properties:
--------------------------------------------------------------------------------
1 | constants=io.github.hidroh.calendar.BuildConfig
2 | sdk=21
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.jar:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hidroh/calendar/HEAD/gradle/wrapper/gradle-wrapper.jar
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-hdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hidroh/calendar/HEAD/app/src/main/res/mipmap-hdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-mdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hidroh/calendar/HEAD/app/src/main/res/mipmap-mdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xhdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hidroh/calendar/HEAD/app/src/main/res/mipmap-xhdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxhdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hidroh/calendar/HEAD/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hidroh/calendar/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/test/java/android/net/http/AndroidHttpClient.java:
--------------------------------------------------------------------------------
1 | package android.net.http;
2 |
3 | /**
4 | * Stub to replace deprecated legacy AndroidHttpClient
5 | */
6 | public class AndroidHttpClient {
7 | }
8 |
--------------------------------------------------------------------------------
/app/src/main/res/values-w376dp/styles.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
7 |
8 |
--------------------------------------------------------------------------------
/app/src/main/res/values-w600dp/styles.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
7 |
8 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/horizontal_divider.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/app/src/main/res/values-w820dp-land/dimens.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | 32dp
4 | 32dp
5 | 392dp
6 | 92dp
7 |
--------------------------------------------------------------------------------
/app/src/main/res/values/ids.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.properties:
--------------------------------------------------------------------------------
1 | #Sun Nov 26 13:08:28 GMT 2017
2 | distributionBase=GRADLE_USER_HOME
3 | distributionPath=wrapper/dists
4 | zipStoreBase=GRADLE_USER_HOME
5 | zipStorePath=wrapper/dists
6 | distributionUrl=https\://services.gradle.org/distributions-snapshots/gradle-4.4-20171031235950+0000-all.zip
7 |
--------------------------------------------------------------------------------
/app/src/main/res/values-w600dp/dimens.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | 32dp
4 | 16dp
5 | 16dp
6 | 120dp
7 | 24dp
8 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_expand_toggle_white_24dp.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/app/src/main/res/values/arrays.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | - @color/blue50
5 | - @color/cyan50
6 | - @color/green50
7 | - @color/yellow50
8 | - @color/red50
9 |
10 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_add_white_24dp.xml:
--------------------------------------------------------------------------------
1 |
6 |
9 |
10 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_expand_less_white_24dp.xml:
--------------------------------------------------------------------------------
1 |
6 |
9 |
10 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_expand_more_white_24dp.xml:
--------------------------------------------------------------------------------
1 |
6 |
9 |
10 |
--------------------------------------------------------------------------------
/app/src/main/res/layout/grid_item_header.xml:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_event_white_24dp.xml:
--------------------------------------------------------------------------------
1 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_event_white_96dp.xml:
--------------------------------------------------------------------------------
1 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_title_white_24dp.xml:
--------------------------------------------------------------------------------
1 |
2 |
7 |
8 |
11 |
13 |
--------------------------------------------------------------------------------
/app/src/main/res/layout/grid_item_content.xml:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/app/src/main/res/menu/menu_edit.xml:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/app/src/main/res/layout/fab_add.xml:
--------------------------------------------------------------------------------
1 |
2 |
11 |
--------------------------------------------------------------------------------
/app/src/main/res/values/colors.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | #E3F2FD
4 | #E0F7FA
5 | #E8F5E9
6 | #FFFDE7
7 | #FFEBEE
8 | #2196F3
9 | #1976D2
10 | #00C853
11 | #1F000000
12 |
13 |
--------------------------------------------------------------------------------
/app/src/test/java/io/github/hidroh/calendar/test/shadows/ShadowViewHolder.java:
--------------------------------------------------------------------------------
1 | package io.github.hidroh.calendar.test.shadows;
2 |
3 | import android.support.v7.widget.RecyclerView;
4 |
5 | import org.robolectric.annotation.Implementation;
6 | import org.robolectric.annotation.Implements;
7 |
8 | @Implements(RecyclerView.ViewHolder.class)
9 | public class ShadowViewHolder {
10 | public int adapterPosition;
11 |
12 | @Implementation
13 | public int getAdapterPosition() {
14 | return adapterPosition;
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Built application files
2 | *.apk
3 | *.ap_
4 |
5 | # Files for the Dalvik VM
6 | *.dex
7 |
8 | # Java class files
9 | *.class
10 |
11 | # Generated files
12 | bin/
13 | gen/
14 |
15 | # Gradle files
16 | .gradle/
17 | build/
18 |
19 | # Local configuration file (sdk path, etc)
20 | local.properties
21 |
22 | # Proguard folder generated by Eclipse
23 | proguard/
24 |
25 | # Log Files
26 | *.log
27 |
28 | # Android Studio Navigation editor temp files
29 | .navigation/
30 |
31 | # Android Studio captures folder
32 | captures/
33 | .idea/
34 | *.iml
35 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_access_time_white_24dp.xml:
--------------------------------------------------------------------------------
1 |
6 |
9 |
10 |
--------------------------------------------------------------------------------
/app/src/main/res/values/dimens.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | 16dp
4 | 16dp
5 | 8dp
6 | 8dp
7 | 8dp
8 | 4dp
9 | 1dp
10 | 4dp
11 | 96dp
12 | 16dp
13 | 4dp
14 |
--------------------------------------------------------------------------------
/app/src/main/java/io/github/hidroh/calendar/weather/WeatherSyncAlarmReceiver.java:
--------------------------------------------------------------------------------
1 | package io.github.hidroh.calendar.weather;
2 |
3 | import android.content.Context;
4 | import android.content.Intent;
5 | import android.support.v4.content.WakefulBroadcastReceiver;
6 |
7 | /**
8 | * Broadcast receiver that triggers launching weather sync service
9 | */
10 | public class WeatherSyncAlarmReceiver extends WakefulBroadcastReceiver {
11 | @Override
12 | public void onReceive(Context context, Intent intent) {
13 | startWakefulService(context, new Intent(context, WeatherSyncService.class));
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_clear_night_24dp.xml:
--------------------------------------------------------------------------------
1 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/app/src/main/res/layout/list_item_calendar.xml:
--------------------------------------------------------------------------------
1 |
2 |
16 |
--------------------------------------------------------------------------------
/gradle.properties:
--------------------------------------------------------------------------------
1 | # Project-wide Gradle settings.
2 |
3 | # IDE (e.g. Android Studio) users:
4 | # Gradle settings configured through the IDE *will override*
5 | # any settings specified in this file.
6 |
7 | # For more details on how to configure your build environment visit
8 | # http://www.gradle.org/docs/current/userguide/build_environment.html
9 |
10 | # Specifies the JVM arguments used for the daemon process.
11 | # The setting is particularly useful for tweaking memory settings.
12 | # Default value: -Xmx10248m -XX:MaxPermSize=256m
13 | # org.gradle.jvmargs=-Xmx2048m -XX:MaxPermSize=512m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8
14 |
15 | # When configured, Gradle will run in incubating parallel mode.
16 | # This option should only be used with decoupled projects. More details, visit
17 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
18 | # org.gradle.parallel=true
19 | org.gradle.daemon=true
20 | android.enableAapt2=false
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # My Calendar
2 | Simple event calendar, with agenda view.
3 |
4 | ## Requirements
5 | * Android SDK 26
6 | * Android SDK Build-tools 26.0.2
7 | * Android Support Library 26.1.0
8 |
9 | ## Build & Test
10 |
11 | **Build**
12 |
13 | ./gradlew :app:assembleDebug
14 |
15 | **Test & Coverage** [](https://travis-ci.org/hidroh/calendar) [](https://coveralls.io/r/hidroh/calendar?branch=master)
16 |
17 | ./gradlew :app:lintDebug
18 | ./gradlew :app:testDebug
19 | ./gradlew :app:jacocoTestCoverage
20 |
21 | ## Screenshots
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 | *Weather icons are from Meteocons set by Alessio Atzeni*
30 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: android
2 | jdk:
3 | - oraclejdk8
4 | android:
5 | components:
6 | # Uncomment the lines below if you want to
7 | # use the latest revision of Android SDK Tools
8 | - tools
9 | - tools # TODO remove once travis support API 24
10 | - platform-tools
11 |
12 | # The BuildTools version used by your project
13 | - build-tools-26.0.2
14 |
15 | # The SDK version used to compile your project
16 | - android-26
17 |
18 | # Additional components
19 | - extra-android-m2repository
20 | # - addon-google_apis-google-19
21 |
22 | # Specify at least one system image,
23 | # if you need to run emulator(s) during your tests
24 | # - sys-img-armeabi-v7a-android-19
25 | # - sys-img-x86-android-17
26 | before_cache:
27 | - rm -f $HOME/.gradle/caches/modules-2/modules-2.lock
28 | cache:
29 | directories:
30 | - $HOME/.gradle/caches/
31 | - $HOME/.gradle/wrapper/
32 | script: "./gradlew assembleDebug && ./gradlew lintDebug &&./gradlew testDebug"
33 | after_success:
34 | - ./gradlew jacocoTestReport coveralls
--------------------------------------------------------------------------------
/app/src/main/res/layout-land/include_activity_main.xml:
--------------------------------------------------------------------------------
1 |
2 |
10 |
11 |
15 |
16 |
25 |
26 |
27 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_rain_24dp.xml:
--------------------------------------------------------------------------------
1 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_wind_24dp.xml:
--------------------------------------------------------------------------------
1 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/app/src/main/java/io/github/hidroh/calendar/ViewUtils.java:
--------------------------------------------------------------------------------
1 | package io.github.hidroh.calendar;
2 |
3 | import android.content.Context;
4 | import android.content.res.TypedArray;
5 | import android.support.v4.content.ContextCompat;
6 |
7 | /**
8 | * Utility class for view logic
9 | */
10 | public class ViewUtils {
11 |
12 | /**
13 | * Retrieves pool of colors for calendars and events
14 | * @param context resources provider
15 | * @return array of {@link android.support.annotation.ColorInt} integers
16 | */
17 | public static int[] getCalendarColors(Context context) {
18 | int transparentColor = ContextCompat.getColor(context, android.R.color.transparent);
19 | TypedArray ta = context.getResources().obtainTypedArray(R.array.calendar_colors);
20 | int[] colors;
21 | if (ta.length() > 0) {
22 | colors = new int[ta.length()];
23 | for (int i = 0; i < ta.length(); i++) {
24 | colors[i] = ta.getColor(i, transparentColor);
25 | }
26 | } else {
27 | colors = new int[]{transparentColor};
28 | }
29 | ta.recycle();
30 | return colors;
31 | }
32 |
33 | }
34 |
--------------------------------------------------------------------------------
/app/src/main/res/layout/include_activity_main.xml:
--------------------------------------------------------------------------------
1 |
2 |
12 |
13 |
22 |
23 |
27 |
28 |
29 |
--------------------------------------------------------------------------------
/app/src/test/java/io/github/hidroh/calendar/test/TestEventCursor.java:
--------------------------------------------------------------------------------
1 | package io.github.hidroh.calendar.test;
2 |
3 | import android.database.ContentObserver;
4 | import android.database.MatrixCursor;
5 |
6 | import io.github.hidroh.calendar.content.EventCursor;
7 |
8 | public class TestEventCursor extends EventCursor {
9 | private ContentObserver contentObserver;
10 |
11 | public TestEventCursor() {
12 | super(new MatrixCursor(EventCursor.PROJECTION));
13 | }
14 |
15 | public void addRow(Object[] columnValues) {
16 | ((MatrixCursor) getWrappedCursor()).addRow(columnValues);
17 | }
18 |
19 | @Override
20 | public void registerContentObserver(ContentObserver observer) {
21 | contentObserver = observer;
22 | }
23 |
24 | @Override
25 | public void unregisterContentObserver(ContentObserver observer) {
26 | contentObserver = null;
27 | }
28 |
29 | public void notifyContentChange(boolean selfChange) {
30 | if (contentObserver != null) {
31 | contentObserver.onChange(selfChange);
32 | }
33 | }
34 |
35 | public boolean hasContentObserver() {
36 | return contentObserver != null;
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/app/src/main/res/layout-w820dp-land/include_activity_main.xml:
--------------------------------------------------------------------------------
1 |
2 |
8 |
9 |
13 |
14 |
21 |
22 |
28 |
29 |
30 |
--------------------------------------------------------------------------------
/app/src/test/java/io/github/hidroh/calendar/test/shadows/ShadowLinearLayoutManager.java:
--------------------------------------------------------------------------------
1 | package io.github.hidroh.calendar.test.shadows;
2 |
3 | import android.support.v7.widget.LinearLayoutManager;
4 |
5 | import org.robolectric.annotation.Implementation;
6 | import org.robolectric.annotation.Implements;
7 |
8 | @Implements(LinearLayoutManager.class)
9 | public class ShadowLinearLayoutManager {
10 | private static final int TOTAL_VISIBLE = 10; // assume we can layout 10 items on screen
11 | private int firstVisiblePosition = 0;
12 | private int lastVisiblePosition = TOTAL_VISIBLE - 1;
13 |
14 | @Implementation
15 | public void scrollToPosition(int position) {
16 | firstVisiblePosition = position;
17 | lastVisiblePosition = position + TOTAL_VISIBLE - 1;
18 | }
19 |
20 | @Implementation
21 | public int findFirstVisibleItemPosition() {
22 | return firstVisiblePosition;
23 | }
24 |
25 |
26 | @Implementation
27 | public int findLastVisibleItemPosition() {
28 | return lastVisiblePosition;
29 | }
30 |
31 | public void scrollToLastPosition(int position) {
32 | lastVisiblePosition = position;
33 | firstVisiblePosition = position - TOTAL_VISIBLE + 1;
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/app/src/main/res/layout-w820dp-land/toolbar_main.xml:
--------------------------------------------------------------------------------
1 |
2 |
7 |
8 |
14 |
15 |
25 |
26 |
27 |
28 |
29 |
--------------------------------------------------------------------------------
/app/src/main/java/io/github/hidroh/calendar/content/CalendarCursor.java:
--------------------------------------------------------------------------------
1 | package io.github.hidroh.calendar.content;
2 |
3 | import android.database.Cursor;
4 | import android.database.CursorWrapper;
5 | import android.provider.CalendarContract;
6 |
7 | /**
8 | * {@link android.provider.CalendarContract.Calendars} cursor wrapper
9 | */
10 | public class CalendarCursor extends CursorWrapper {
11 |
12 | /**
13 | * {@link android.provider.CalendarContract.Calendars} query projection
14 | */
15 | public static final String[] PROJECTION = new String[]{
16 | CalendarContract.Calendars._ID,
17 | CalendarContract.Calendars.CALENDAR_DISPLAY_NAME
18 | };
19 | private static final int PROJECTION_INDEX_ID = 0;
20 | private static final int PROJECTION_INDEX_DISPLAY_NAME = 1;
21 |
22 | public CalendarCursor(Cursor cursor) {
23 | super(cursor);
24 | }
25 |
26 | /**
27 | * Gets calendar ID
28 | * @return calendar ID
29 | */
30 | public long getId() {
31 | return getLong(PROJECTION_INDEX_ID);
32 | }
33 |
34 | /**
35 | * Gets calendar display name
36 | * @return calendar display name
37 | */
38 | public String getDisplayName() {
39 | return getString(PROJECTION_INDEX_DISPLAY_NAME);
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/app/src/main/res/layout/empty_permission.xml:
--------------------------------------------------------------------------------
1 |
2 |
10 |
11 |
21 |
22 |
29 |
30 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_snow_24dp.xml:
--------------------------------------------------------------------------------
1 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/app/src/main/res/menu/menu_main.xml:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_clear_day_24dp.xml:
--------------------------------------------------------------------------------
1 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/app/src/test/java/io/github/hidroh/calendar/test/assertions/SpannableStringAssert.java:
--------------------------------------------------------------------------------
1 | package io.github.hidroh.calendar.test.assertions;
2 |
3 | import android.text.SpannableString;
4 |
5 | import org.assertj.core.api.AbstractCharSequenceAssert;
6 | import org.assertj.core.api.Assertions;
7 |
8 | public class SpannableStringAssert
9 | extends AbstractCharSequenceAssert {
10 |
11 | public static SpannableStringAssert assertThat(SpannableString actual) {
12 | return new SpannableStringAssert(actual, SpannableStringAssert.class);
13 | }
14 |
15 | protected SpannableStringAssert(SpannableString actual, Class> selfType) {
16 | super(actual, selfType);
17 | }
18 |
19 | public SpannableStringAssert hasSpan(Class> type) {
20 | Object[] span = actual.getSpans(0, actual.length(), type);
21 | Assertions.assertThat(span)
22 | .overridingErrorMessage("Expect to have <%s> span but did not have", type.getName())
23 | .isNotEmpty();
24 | return this;
25 | }
26 |
27 | public SpannableStringAssert doesNotHaveSpan(Class> type) {
28 | Object[] span = actual.getSpans(0, actual.length(), type);
29 | Assertions.assertThat(span)
30 | .overridingErrorMessage("Expect not to have <%s> span but had", type.getName())
31 | .isEmpty();
32 | return this;
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_partly_cloudy_night_24dp.xml:
--------------------------------------------------------------------------------
1 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_cloudy_24dp.xml:
--------------------------------------------------------------------------------
1 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/app/src/main/res/layout/activity_edit.xml:
--------------------------------------------------------------------------------
1 |
2 |
10 |
11 |
15 |
16 |
20 |
21 |
22 |
23 |
28 |
29 |
33 |
34 |
35 |
36 |
37 |
--------------------------------------------------------------------------------
/app/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
33 |
34 |
35 |
36 |
37 |
38 |
--------------------------------------------------------------------------------
/app/src/main/res/layout/toolbar_main.xml:
--------------------------------------------------------------------------------
1 |
2 |
8 |
9 |
15 |
16 |
22 |
23 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_partly_cloudy_day_24dp.xml:
--------------------------------------------------------------------------------
1 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/app/src/main/res/layout/list_item_content.xml:
--------------------------------------------------------------------------------
1 |
2 |
7 |
8 |
13 |
14 |
19 |
20 |
29 |
30 |
42 |
43 |
44 |
45 |
46 |
--------------------------------------------------------------------------------
/app/src/test/java/io/github/hidroh/calendar/test/shadows/ShadowViewPager.java:
--------------------------------------------------------------------------------
1 | package io.github.hidroh.calendar.test.shadows;
2 |
3 | import android.support.v4.view.ViewPager;
4 |
5 | import org.robolectric.annotation.Implementation;
6 | import org.robolectric.annotation.Implements;
7 | import org.robolectric.annotation.RealObject;
8 | import org.robolectric.shadows.ShadowViewGroup;
9 |
10 | @Implements(value = ViewPager.class, inheritImplementationMethods = true)
11 | public class ShadowViewPager extends ShadowViewGroup {
12 |
13 | @RealObject ViewPager realObject;
14 | private ViewPager.OnPageChangeListener listener;
15 |
16 | @Implementation
17 | public void addOnPageChangeListener(ViewPager.OnPageChangeListener listener) {
18 | this.listener = listener;
19 | }
20 |
21 | @Implementation
22 | public void setCurrentItem(int item, boolean smoothScroll) {
23 | realObject.setCurrentItem(item);
24 | notifyListener(false);
25 | }
26 |
27 | /**
28 | * Simulate a swipe gesture, which should update current item and trigger page change listener
29 | */
30 | public void swipeLeft() {
31 | realObject.setCurrentItem(realObject.getCurrentItem() + 1);
32 | notifyListener(true);
33 | }
34 |
35 | /**
36 | * Simulate a swipe gesture, which should update current item and trigger page change listener
37 | */
38 | public void swipeRight() {
39 | realObject.setCurrentItem(realObject.getCurrentItem() - 1);
40 | notifyListener(true);
41 | }
42 |
43 | private void notifyListener(boolean dragging) {
44 | if (dragging) {
45 | listener.onPageScrollStateChanged(ViewPager.SCROLL_STATE_DRAGGING);
46 | }
47 | listener.onPageScrollStateChanged(ViewPager.SCROLL_STATE_SETTLING);
48 | listener.onPageSelected(realObject.getCurrentItem());
49 | listener.onPageScrollStateChanged(ViewPager.SCROLL_STATE_IDLE);
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/app/src/main/java/io/github/hidroh/calendar/text/style/UnderDotSpan.java:
--------------------------------------------------------------------------------
1 | package io.github.hidroh.calendar.text.style;
2 |
3 | import android.content.Context;
4 | import android.content.res.TypedArray;
5 | import android.graphics.Canvas;
6 | import android.graphics.Paint;
7 | import android.support.v4.content.ContextCompat;
8 | import android.text.TextUtils;
9 | import android.text.style.ReplacementSpan;
10 |
11 | import io.github.hidroh.calendar.R;
12 |
13 | /**
14 | * Text span that draws a dot under text
15 | */
16 | public class UnderDotSpan extends ReplacementSpan {
17 |
18 | private final float mSize;
19 | private final int mDotColor;
20 | private final int mTextColor;
21 |
22 | public UnderDotSpan(Context context) {
23 | super();
24 | TypedArray ta = context.getTheme().obtainStyledAttributes(new int[]{
25 | R.attr.colorAccent,
26 | android.R.attr.textColorPrimary
27 | });
28 | mDotColor = ta.getColor(0, ContextCompat.getColor(context, R.color.greenA700));
29 | //noinspection ResourceType
30 | mTextColor = ta.getColor(1, 0);
31 | ta.recycle();
32 | mSize = context.getResources().getDimension(R.dimen.dot_size);
33 | }
34 |
35 | @Override
36 | public int getSize(Paint paint, CharSequence text, int start, int end, Paint.FontMetricsInt fm) {
37 | return Math.round(paint.measureText(text, start, end));
38 | }
39 |
40 | @Override
41 | public void draw(Canvas canvas, CharSequence text, int start, int end, float x, int top, int y, int bottom, Paint paint) {
42 | if (TextUtils.isEmpty(text)) {
43 | return;
44 | }
45 | float textSize = paint.measureText(text, start, end);
46 | paint.setColor(mDotColor);
47 | canvas.drawCircle(x + textSize / 2, // text center X
48 | bottom + mSize, // dot center Y
49 | mSize / 2, // radius
50 | paint);
51 | paint.setColor(mTextColor);
52 | canvas.drawText(text, start, end, x, y, paint);
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/app/src/test/java/android/content/ShadowAsyncQueryHandler.java:
--------------------------------------------------------------------------------
1 | package android.content;
2 |
3 | import android.database.Cursor;
4 | import android.database.MatrixCursor;
5 | import android.net.Uri;
6 |
7 | import org.robolectric.annotation.Implementation;
8 | import org.robolectric.annotation.Implements;
9 | import org.robolectric.annotation.RealObject;
10 | import org.robolectric.shadows.ShadowApplication;
11 |
12 | import static org.robolectric.Shadows.shadowOf;
13 |
14 | @Implements(value = AsyncQueryHandler.class, inheritImplementationMethods = true)
15 | public class ShadowAsyncQueryHandler {
16 | @RealObject
17 | AsyncQueryHandler realObject;
18 |
19 | @Implementation
20 | public void startQuery(int token, Object cookie, Uri uri,
21 | String[] projection, String selection, String[] selectionArgs,
22 | String orderBy) {
23 | Cursor cursor = shadowOf(ShadowApplication.getInstance().getContentResolver())
24 | .query(uri, projection, selection, selectionArgs, orderBy);
25 | realObject.onQueryComplete(token, cookie, cursor != null ?
26 | cursor : new MatrixCursor(new String[0]));
27 | }
28 |
29 | @Implementation
30 | public void startInsert(int token, Object cookie, Uri uri, ContentValues initialValues) {
31 | shadowOf(ShadowApplication.getInstance().getContentResolver()).insert(uri, initialValues);
32 | realObject.onInsertComplete(token, cookie, uri);
33 | }
34 |
35 | @Implementation
36 | public void startUpdate(int token, Object cookie, Uri uri,
37 | ContentValues values, String selection, String[] selectionArgs) {
38 | shadowOf(ShadowApplication.getInstance().getContentResolver())
39 | .update(uri, values, selection, selectionArgs);
40 | realObject.onUpdateComplete(token, cookie, 1);
41 | }
42 |
43 | @Implementation
44 | public void startDelete(int token, Object cookie, Uri uri,
45 | String selection, String[] selectionArgs) {
46 | shadowOf(ShadowApplication.getInstance().getContentResolver())
47 | .delete(uri, selection, selectionArgs);
48 | realObject.onDeleteComplete(token, cookie, 1);
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/app/src/main/java/io/github/hidroh/calendar/text/style/CircleSpan.java:
--------------------------------------------------------------------------------
1 | package io.github.hidroh.calendar.text.style;
2 |
3 | import android.content.Context;
4 | import android.content.res.TypedArray;
5 | import android.graphics.Canvas;
6 | import android.graphics.Paint;
7 | import android.support.v4.content.ContextCompat;
8 | import android.text.TextUtils;
9 | import android.text.style.ReplacementSpan;
10 |
11 | import io.github.hidroh.calendar.R;
12 |
13 | /**
14 | * Text span that draws a circle around text, pads to cover at least 2 characters
15 | */
16 | public class CircleSpan extends ReplacementSpan {
17 |
18 | private final float mPadding;
19 | private final int mCircleColor;
20 | private final int mTextColor;
21 |
22 | public CircleSpan(Context context) {
23 | super();
24 | TypedArray ta = context.getTheme().obtainStyledAttributes(new int[]{
25 | R.attr.colorAccent,
26 | android.R.attr.textColorPrimaryInverse
27 | });
28 | mCircleColor = ta.getColor(0, ContextCompat.getColor(context, R.color.greenA700));
29 | //noinspection ResourceType
30 | mTextColor = ta.getColor(1, 0);
31 | ta.recycle();
32 | mPadding = context.getResources().getDimension(R.dimen.padding_circle);
33 | }
34 |
35 | @Override
36 | public int getSize(Paint paint, CharSequence text, int start, int end, Paint.FontMetricsInt fm) {
37 | return Math.round(paint.measureText(text, start, end) + mPadding * 2); // left + right
38 | }
39 |
40 | @Override
41 | public void draw(Canvas canvas, CharSequence text, int start, int end, float x, int top, int y, int bottom, Paint paint) {
42 | if (TextUtils.isEmpty(text)) {
43 | return;
44 | }
45 | float textSize = paint.measureText(text, start, end);
46 | paint.setColor(mCircleColor);
47 | // ensure radius covers at least 2 characters even if there is only 1
48 | canvas.drawCircle(x + textSize / 2 + mPadding, // center X
49 | (top + bottom) / 2, // center Y
50 | (text.length() == 1 ? textSize : textSize / 2) + mPadding, // radius
51 | paint);
52 | paint.setColor(mTextColor);
53 | canvas.drawText(text, start, end, mPadding + x, y, paint);
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/app/src/test/java/io/github/hidroh/calendar/test/shadows/ShadowRecyclerView.java:
--------------------------------------------------------------------------------
1 | package io.github.hidroh.calendar.test.shadows;
2 |
3 | import android.support.v7.widget.RecyclerView;
4 |
5 | import org.robolectric.annotation.Implementation;
6 | import org.robolectric.annotation.Implements;
7 | import org.robolectric.annotation.RealObject;
8 | import org.robolectric.internal.ShadowExtractor;
9 | import org.robolectric.shadows.ShadowViewGroup;
10 | import org.robolectric.util.ReflectionHelpers;
11 |
12 | import static org.robolectric.internal.Shadow.directlyOn;
13 |
14 | @Implements(value = RecyclerView.class, inheritImplementationMethods = true)
15 | public class ShadowRecyclerView extends ShadowViewGroup {
16 | @RealObject RecyclerView realObject;
17 |
18 | @Implementation
19 | public void smoothScrollToPosition(int position) {
20 | directlyOn(realObject, RecyclerView.class, "dispatchOnScrollStateChanged",
21 | ReflectionHelpers.ClassParameter.from(int.class, RecyclerView.SCROLL_STATE_SETTLING));
22 | realObject.getLayoutManager().scrollToPosition(position);
23 | directlyOn(realObject, RecyclerView.class, "dispatchOnScrolled",
24 | ReflectionHelpers.ClassParameter.from(int.class, 0),
25 | ReflectionHelpers.ClassParameter.from(int.class, 1));
26 | directlyOn(realObject, RecyclerView.class, "dispatchOnScrollStateChanged",
27 | ReflectionHelpers.ClassParameter.from(int.class, RecyclerView.SCROLL_STATE_IDLE));
28 | }
29 |
30 | public void scrollToLastPosition() {
31 | directlyOn(realObject, RecyclerView.class, "dispatchOnScrollStateChanged",
32 | ReflectionHelpers.ClassParameter.from(int.class, RecyclerView.SCROLL_STATE_SETTLING));
33 | ((ShadowLinearLayoutManager) ShadowExtractor.extract(realObject.getLayoutManager()))
34 | .scrollToLastPosition(realObject.getAdapter().getItemCount() - 1);
35 | directlyOn(realObject, RecyclerView.class, "dispatchOnScrolled",
36 | ReflectionHelpers.ClassParameter.from(int.class, 0),
37 | ReflectionHelpers.ClassParameter.from(int.class, 1));
38 | directlyOn(realObject, RecyclerView.class, "dispatchOnScrollStateChanged",
39 | ReflectionHelpers.ClassParameter.from(int.class, RecyclerView.SCROLL_STATE_IDLE));
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/app/src/main/res/values/strings.xml:
--------------------------------------------------------------------------------
1 |
2 | My Calendar
3 | No event
4 | Calendar access is required to manage your events
5 | All day
6 | Grant access
7 | Today
8 | Title
9 | Event time
10 | Event calendar
11 | Event title is required
12 | Choose a calendar
13 | Create new event
14 | Edit event
15 | Save
16 | Delete
17 | Delete this event?
18 | Discard changes?
19 | Please choose a calendar
20 | Ends\n%s
21 | Local Calendar
22 | Event created.
23 | Event updated.
24 | Event deleted.
25 | Morning
26 | Afternoon
27 | Night
28 | %1$.1f\u00b0F
29 | Show weather
30 | Updating weather information...
31 | Updating weather requires location access
32 | Week start
33 | Saturday
34 | Sunday
35 | Monday
36 | Open drawer
37 | Close drawer
38 | Calendars
39 | Unable to determine your location
40 |
41 |
--------------------------------------------------------------------------------
/app/src/main/res/values/styles.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
11 |
12 |
16 |
17 |
18 |
19 |
20 |
21 |
25 |
26 |
29 |
30 |
33 |
34 |
37 |
38 |
39 |
40 |
43 |
44 |
45 |
46 |
53 |
54 |
57 |
58 |
59 |
--------------------------------------------------------------------------------
/app/src/main/res/layout-w820dp-land/activity_edit.xml:
--------------------------------------------------------------------------------
1 |
2 |
11 |
12 |
17 |
18 |
22 |
23 |
24 |
25 |
36 |
37 |
49 |
50 |
54 |
55 |
56 |
57 |
58 |
--------------------------------------------------------------------------------
/gradlew.bat:
--------------------------------------------------------------------------------
1 | @if "%DEBUG%" == "" @echo off
2 | @rem ##########################################################################
3 | @rem
4 | @rem Gradle startup script for Windows
5 | @rem
6 | @rem ##########################################################################
7 |
8 | @rem Set local scope for the variables with windows NT shell
9 | if "%OS%"=="Windows_NT" setlocal
10 |
11 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
12 | set DEFAULT_JVM_OPTS=
13 |
14 | set DIRNAME=%~dp0
15 | if "%DIRNAME%" == "" set DIRNAME=.
16 | set APP_BASE_NAME=%~n0
17 | set APP_HOME=%DIRNAME%
18 |
19 | @rem Find java.exe
20 | if defined JAVA_HOME goto findJavaFromJavaHome
21 |
22 | set JAVA_EXE=java.exe
23 | %JAVA_EXE% -version >NUL 2>&1
24 | if "%ERRORLEVEL%" == "0" goto init
25 |
26 | echo.
27 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
28 | echo.
29 | echo Please set the JAVA_HOME variable in your environment to match the
30 | echo location of your Java installation.
31 |
32 | goto fail
33 |
34 | :findJavaFromJavaHome
35 | set JAVA_HOME=%JAVA_HOME:"=%
36 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe
37 |
38 | if exist "%JAVA_EXE%" goto init
39 |
40 | echo.
41 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
42 | echo.
43 | echo Please set the JAVA_HOME variable in your environment to match the
44 | echo location of your Java installation.
45 |
46 | goto fail
47 |
48 | :init
49 | @rem Get command-line arguments, handling Windowz variants
50 |
51 | if not "%OS%" == "Windows_NT" goto win9xME_args
52 | if "%@eval[2+2]" == "4" goto 4NT_args
53 |
54 | :win9xME_args
55 | @rem Slurp the command line arguments.
56 | set CMD_LINE_ARGS=
57 | set _SKIP=2
58 |
59 | :win9xME_args_slurp
60 | if "x%~1" == "x" goto execute
61 |
62 | set CMD_LINE_ARGS=%*
63 | goto execute
64 |
65 | :4NT_args
66 | @rem Get arguments from the 4NT Shell from JP Software
67 | set CMD_LINE_ARGS=%$
68 |
69 | :execute
70 | @rem Setup the command line
71 |
72 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
73 |
74 | @rem Execute Gradle
75 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
76 |
77 | :end
78 | @rem End local scope for the variables with windows NT shell
79 | if "%ERRORLEVEL%"=="0" goto mainEnd
80 |
81 | :fail
82 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
83 | rem the _cmd.exe /c_ return code!
84 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
85 | exit /b 1
86 |
87 | :mainEnd
88 | if "%OS%"=="Windows_NT" endlocal
89 |
90 | :omega
91 |
--------------------------------------------------------------------------------
/app/src/main/java/io/github/hidroh/calendar/content/EventCursor.java:
--------------------------------------------------------------------------------
1 | package io.github.hidroh.calendar.content;
2 |
3 | import android.database.Cursor;
4 | import android.database.CursorWrapper;
5 | import android.provider.CalendarContract;
6 |
7 | /**
8 | * {@link android.provider.CalendarContract.Events} cursor wrapper
9 | */
10 | public class EventCursor extends CursorWrapper {
11 |
12 | /**
13 | * {@link android.provider.CalendarContract.Events} query projection
14 | */
15 | public static final String[] PROJECTION = new String[]{
16 | CalendarContract.Events._ID,
17 | CalendarContract.Events.CALENDAR_ID,
18 | CalendarContract.Events.TITLE,
19 | CalendarContract.Events.DTSTART,
20 | CalendarContract.Events.DTEND,
21 | CalendarContract.Events.ALL_DAY
22 | };
23 | private static final int PROJECTION_INDEX_ID = 0;
24 | private static final int PROJECTION_INDEX_CALENDAR_ID = 1;
25 | private static final int PROJECTION_INDEX_TITLE = 2;
26 | private static final int PROJECTION_INDEX_DTSTART = 3;
27 | private static final int PROJECTION_INDEX_DTEND = 4;
28 | private static final int PROJECTION_INDEX_ALL_DAY = 5;
29 |
30 | public EventCursor(Cursor cursor) {
31 | super(cursor);
32 | }
33 |
34 | /**
35 | * Gets event ID
36 | * @return event ID
37 | */
38 | public long getId() {
39 | return getLong(PROJECTION_INDEX_ID);
40 | }
41 |
42 | /**
43 | * Gets event calendar ID
44 | * @return event calendar ID
45 | */
46 | public long getCalendarId() {
47 | return getLong(PROJECTION_INDEX_CALENDAR_ID);
48 | }
49 |
50 | /**
51 | * Gets event title
52 | * @return event title
53 | */
54 | public String getTitle() {
55 | return getString(PROJECTION_INDEX_TITLE);
56 | }
57 |
58 | /**
59 | * Gets start time in milliseconds.
60 | * If {@link #getAllDay()} is true, time will be midnight in UTC.
61 | * @return start time in milliseconds
62 | * @see {@link #getAllDay()}
63 | */
64 | public long getDateTimeStart() {
65 | return getLong(PROJECTION_INDEX_DTSTART);
66 | }
67 |
68 | /**
69 | * Gets end time in milliseconds.
70 | * If {@link #getAllDay()} is true, time will be midnight in UTC.
71 | * @return end time in milliseconds
72 | * @see {@link #getAllDay()}
73 | */
74 | public long getDateTimeEnd() {
75 | return getLong(PROJECTION_INDEX_DTEND);
76 | }
77 |
78 | /**
79 | * Checks if event is all day. All-day event has start and end time midnight in UTC.
80 | * @return true if all-day event, false otherwise
81 | * @see {@link #getDateTimeStart()}
82 | * @see {@link #getDateTimeEnd()}
83 | */
84 | public boolean getAllDay() {
85 | return getInt(PROJECTION_INDEX_ALL_DAY) == 1;
86 | }
87 | }
88 |
--------------------------------------------------------------------------------
/app/build.gradle:
--------------------------------------------------------------------------------
1 | apply plugin: 'com.android.application'
2 | apply plugin: 'jacoco'
3 | apply plugin: 'com.github.kt3k.coveralls'
4 |
5 | android {
6 | compileSdkVersion 26
7 | buildToolsVersion "26.0.2"
8 |
9 | defaultConfig {
10 | applicationId "io.github.hidroh.calendar"
11 | minSdkVersion 14
12 | targetSdkVersion 26
13 | versionCode 1
14 | versionName "1.0"
15 | buildConfigField "String", "FORECAST_IO_API_KEY", "\"97b232a76771e4bd5fcd985da278ac0d\""
16 | }
17 |
18 | lintOptions {
19 | htmlReport false
20 | xmlReport false
21 | textReport true
22 | warningsAsErrors true
23 | abortOnError true
24 | explainIssues false
25 | absolutePaths false
26 | ignore "InvalidPackage" // square/okio#58
27 | }
28 |
29 | testOptions.unitTests.all {
30 | testLogging {
31 | events 'passed', 'skipped', 'failed', 'standardOut', 'standardError'
32 | exceptionFormat 'full'
33 | }
34 | maxHeapSize = '2048m'
35 | maxParallelForks = 1
36 | forkEvery = 1
37 | jacoco {
38 | includeNoLocationClasses = true
39 | }
40 | }
41 | testOptions.unitTests.includeAndroidResources true
42 | }
43 |
44 | ext {
45 | supportVersion = '26.1.0'
46 | retrofit2Version = '2.0.0'
47 | robolectricVersion = '3.0'
48 | assertjVersion = '1.1.1'
49 | }
50 |
51 | dependencies {
52 | implementation "com.android.support:appcompat-v7:$supportVersion",
53 | "com.android.support:recyclerview-v7:$supportVersion",
54 | "com.android.support:design:$supportVersion",
55 | "com.squareup.retrofit2:retrofit:$retrofit2Version",
56 | "com.squareup.retrofit2:converter-gson:$retrofit2Version"
57 | testImplementation "org.robolectric:robolectric:$robolectricVersion",
58 | "org.robolectric:shadows-support-v4:$robolectricVersion",
59 | 'org.mockito:mockito-core:1.9.5+',
60 | 'junit:junit:4.12'
61 | testImplementation("com.squareup.assertj:assertj-android:$assertjVersion") {
62 | exclude group: 'com.android.support', module: 'support-annotations'
63 | }
64 | }
65 |
66 | task jacocoTestReport(type: JacocoReport, dependsOn: ['testDebugUnitTest']) {
67 | reports {
68 | xml.enabled true
69 | xml.destination file("${buildDir}/reports/jacoco/test/jacocoTestReport.xml")
70 | html.destination file("${buildDir}/reports/coverage")
71 | }
72 |
73 | executionData = files "${buildDir}/jacoco/testDebugUnitTest.exec"
74 | sourceDirectories = files android.sourceSets.main.java.srcDirs
75 | classDirectories = fileTree(dir: "${buildDir}/intermediates/classes/debug",
76 | exclude: [ '**/R.class', '**/R$*.class' ])
77 |
78 | doLast {
79 | println "coveralls report has been generated to file://${buildDir}/reports/jacoco/test/jacocoTestReport.xml"
80 | println "coverage report generated at file://${reports.html.destination}/index.html"
81 | }
82 | }
--------------------------------------------------------------------------------
/app/src/test/java/io/github/hidroh/calendar/test/assertions/DayTimeAssert.java:
--------------------------------------------------------------------------------
1 | package io.github.hidroh.calendar.test.assertions;
2 |
3 | import org.assertj.core.api.AbstractLongAssert;
4 | import org.assertj.core.api.Assertions;
5 |
6 | import java.util.Calendar;
7 |
8 | public class DayTimeAssert extends AbstractLongAssert {
9 |
10 | public static DayTimeAssert assertThat(long actual) {
11 | return new DayTimeAssert(actual, DayTimeAssert.class);
12 | }
13 |
14 | protected DayTimeAssert(long actual, Class> selfType) {
15 | super(actual, selfType);
16 | }
17 |
18 | public DayTimeAssert isInSameMonthAs(long timeMillis) {
19 | isNotNull();
20 | Calendar actualCalendar = toCalendar(actual), expectedCalendar = toCalendar(timeMillis);
21 | int actualYear = actualCalendar.get(Calendar.YEAR),
22 | actualMonth = actualCalendar.get(Calendar.MONTH),
23 | expectedYear = expectedCalendar.get(Calendar.YEAR),
24 | expectedMonth = expectedCalendar.get(Calendar.MONTH);
25 | Assertions.assertThat(actualYear)
26 | .overridingErrorMessage("Expected <%s/%s> but was <%s/%s>",
27 | expectedMonth + 1, expectedYear, actualMonth + 1, actualYear)
28 | .isEqualTo(expectedYear);
29 | Assertions.assertThat(actualMonth)
30 | .overridingErrorMessage("Expected <%s/%s> but was <%s/%s>",
31 | expectedMonth + 1, expectedYear, actualMonth + 1, actualYear)
32 | .isEqualTo(expectedMonth);
33 | return this;
34 | }
35 |
36 | public DayTimeAssert isMonthsAfter(long timeMillis, int month) {
37 | Calendar expected = toCalendar(timeMillis);
38 | expected.add(Calendar.MONTH, month);
39 | isInSameMonthAs(expected.getTimeInMillis());
40 | return this;
41 | }
42 |
43 | public DayTimeAssert isMonthsBefore(long timeMillis, int month) {
44 | isMonthsAfter(timeMillis, -month);
45 | return this;
46 | }
47 |
48 | public DayTimeAssert isFirstDayOf(long monthMillis) {
49 | isInSameMonthAs(monthMillis);
50 | int actualDay = toCalendar(actual).get(Calendar.DAY_OF_MONTH);
51 | Assertions.assertThat(actualDay)
52 | .overridingErrorMessage("Expected day to be equal to <1> but was <%s>", actualDay)
53 | .isEqualTo(1);
54 | return this;
55 | }
56 |
57 | public DayTimeAssert isNotFirstDayOf(long monthMillis) {
58 | isInSameMonthAs(monthMillis);
59 | int actualDay = toCalendar(actual).get(Calendar.DAY_OF_MONTH);
60 | Assertions.assertThat(actualDay)
61 | .overridingErrorMessage("Expected day not be equal to <1> but was <%s>", actualDay)
62 | .isNotEqualTo(1);
63 | return this;
64 | }
65 |
66 | private Calendar toCalendar(long timeMillis) {
67 | Calendar calendar = Calendar.getInstance();
68 | calendar.setTimeInMillis(timeMillis);
69 | return calendar;
70 | }
71 | }
72 |
--------------------------------------------------------------------------------
/app/src/main/res/layout/activity_main.xml:
--------------------------------------------------------------------------------
1 |
2 |
6 |
7 |
11 |
12 |
17 |
18 |
21 |
22 |
26 |
27 |
33 |
34 |
35 |
36 |
45 |
46 |
54 |
55 |
61 |
62 |
63 |
64 |
65 |
66 |
72 |
73 |
74 |
--------------------------------------------------------------------------------
/app/src/main/res/layout/list_item_header.xml:
--------------------------------------------------------------------------------
1 |
2 |
9 |
10 |
19 |
20 |
24 |
25 |
34 |
35 |
44 |
45 |
55 |
56 |
65 |
66 |
76 |
77 |
86 |
87 |
88 |
89 |
90 |
--------------------------------------------------------------------------------
/app/src/test/java/io/github/hidroh/calendar/MainActivityPermissionTest.java:
--------------------------------------------------------------------------------
1 | package io.github.hidroh.calendar;
2 |
3 | import android.annotation.SuppressLint;
4 | import android.os.Bundle;
5 |
6 | import org.junit.After;
7 | import org.junit.Before;
8 | import org.junit.Test;
9 | import org.junit.runner.RunWith;
10 | import org.robolectric.Robolectric;
11 | import org.robolectric.RobolectricGradleTestRunner;
12 | import org.robolectric.util.ActivityController;
13 |
14 | import static org.assertj.android.api.Assertions.assertThat;
15 | import static org.mockito.Mockito.mock;
16 | import static org.mockito.Mockito.never;
17 | import static org.mockito.Mockito.verify;
18 |
19 | @SuppressWarnings("ConstantConditions")
20 | @RunWith(RobolectricGradleTestRunner.class)
21 | public class MainActivityPermissionTest {
22 | private ActivityController controller;
23 | private TestMainActivity activity;
24 |
25 | @Before
26 | public void setUp() {
27 | controller = Robolectric.buildActivity(TestMainActivity.class);
28 | activity = controller.create().start().postCreate(null).get();
29 | }
30 |
31 | @Test
32 | public void testInitialState() {
33 | verify(activity.permissionRequester, never()).requestPermissions();
34 | assertThat(activity.findViewById(R.id.fab)).isNotVisible();
35 | assertThat(activity.findViewById(R.id.empty)).isVisible();
36 | }
37 |
38 | @Test
39 | public void testGrantPermissions() {
40 | activity.findViewById(R.id.button_permission).performClick();
41 | verify(activity.permissionRequester).requestPermissions();
42 | activity.permissionCheckResult = true;
43 | activity.onRequestPermissionsResult(0, new String[0], new int[0]);
44 | assertThat(activity.findViewById(R.id.fab)).isVisible();
45 | assertThat(activity.findViewById(R.id.empty)).isNotVisible();
46 | }
47 |
48 | @Test
49 | public void testDenyPermissions() {
50 | activity.findViewById(R.id.button_permission).performClick();
51 | verify(activity.permissionRequester).requestPermissions();
52 | activity.onRequestPermissionsResult(0, new String[0], new int[0]);
53 | assertThat(activity.findViewById(R.id.fab)).isNotVisible();
54 | assertThat(activity.findViewById(R.id.empty)).isVisible();
55 | }
56 |
57 | @Test
58 | public void testStateRestoration() {
59 | // initial state
60 | verify(activity.permissionRequester, never()).requestPermissions();
61 | assertThat(activity.findViewById(R.id.empty)).isVisible();
62 |
63 | activity.shadowRecreate();
64 | verify(activity.permissionRequester, never()).requestPermissions();
65 | assertThat(activity.findViewById(R.id.empty)).isVisible();
66 | }
67 |
68 | @After
69 | public void tearDown() {
70 | controller.stop().destroy();
71 | }
72 |
73 | @SuppressLint("Registered")
74 | static class TestMainActivity extends MainActivity {
75 | boolean permissionCheckResult = false;
76 | final PermissionRequester permissionRequester = mock(PermissionRequester.class);
77 |
78 | @Override
79 | protected boolean checkCalendarPermissions() {
80 | return permissionCheckResult;
81 | }
82 |
83 | @Override
84 | protected void requestCalendarPermissions() {
85 | permissionRequester.requestPermissions();
86 | }
87 |
88 | void shadowRecreate() {
89 | Bundle outState = new Bundle();
90 | onSaveInstanceState(outState);
91 | onPause();
92 | onStop();
93 | onDestroy();
94 | onCreate(outState);
95 | onStart();
96 | onRestoreInstanceState(outState);
97 | onPostCreate(outState); // Robolectric misses this
98 | onResume();
99 | }
100 | }
101 |
102 | interface PermissionRequester {
103 | void requestPermissions();
104 | }
105 | }
106 |
--------------------------------------------------------------------------------
/app/src/main/java/io/github/hidroh/calendar/widget/CalendarSelectionView.java:
--------------------------------------------------------------------------------
1 | package io.github.hidroh.calendar.widget;
2 |
3 | import android.content.Context;
4 | import android.content.res.TypedArray;
5 | import android.database.Cursor;
6 | import android.provider.CalendarContract;
7 | import android.support.annotation.Nullable;
8 | import android.support.v4.content.ContextCompat;
9 | import android.support.v4.widget.CursorAdapter;
10 | import android.support.v4.widget.SimpleCursorAdapter;
11 | import android.util.AttributeSet;
12 | import android.view.View;
13 | import android.widget.AdapterView;
14 | import android.widget.ListView;
15 |
16 | import java.util.Set;
17 |
18 | import io.github.hidroh.calendar.R;
19 | import io.github.hidroh.calendar.ViewUtils;
20 | import io.github.hidroh.calendar.content.CalendarCursor;
21 |
22 | public class CalendarSelectionView extends ListView {
23 | private final SimpleCursorAdapter mCursorAdapter;
24 | private OnSelectionChangeListener mListener;
25 | private final int[] mColors;
26 |
27 | public interface OnSelectionChangeListener {
28 | void onSelectionChange(long id, boolean enabled);
29 | }
30 |
31 | public CalendarSelectionView(Context context) {
32 | this(context, null);
33 | }
34 |
35 | public CalendarSelectionView(Context context, AttributeSet attrs) {
36 | this(context, attrs, 0);
37 | }
38 |
39 | public CalendarSelectionView(Context context, AttributeSet attrs, int defStyleAttr) {
40 | super(context, attrs, defStyleAttr);
41 | if (isInEditMode()) {
42 | mColors = new int[]{ContextCompat.getColor(context, android.R.color.transparent)};
43 | } else {
44 | mColors = ViewUtils.getCalendarColors(context);
45 | }
46 | mCursorAdapter = new CalendarCursorAdapter(context);
47 | TypedArray ta = context.getTheme().obtainStyledAttributes(new int[]{
48 | R.attr.selectableItemBackground
49 | });
50 | setSelector(ta.getDrawable(0));
51 | ta.recycle();
52 | setDrawSelectorOnTop(true);
53 | setChoiceMode(CHOICE_MODE_MULTIPLE);
54 | setAdapter(mCursorAdapter);
55 | setOnItemClickListener(new OnItemClickListener() {
56 | @Override
57 | public void onItemClick(AdapterView> parent, View view, int position, long id) {
58 | if (mListener != null) {
59 | mListener.onSelectionChange(id, isItemChecked(position));
60 | }
61 | }
62 | });
63 | }
64 |
65 | public void setOnSelectionChangeListener(OnSelectionChangeListener listener) {
66 | mListener = listener;
67 | }
68 |
69 | public void swapCursor(@Nullable CalendarCursor cursor, @Nullable Set exclusions) {
70 | mCursorAdapter.swapCursor(cursor);
71 | if (cursor != null) {
72 | for (int i = 0; i < cursor.getCount(); i++) {
73 | if (exclusions != null &&
74 | !exclusions.contains(String.valueOf(mCursorAdapter.getItemId(i)))) {
75 | setItemChecked(i, true);
76 | }
77 | }
78 | }
79 | }
80 |
81 | class CalendarCursorAdapter extends SimpleCursorAdapter {
82 | private static final long NO_ID = -1;
83 |
84 | public CalendarCursorAdapter(Context context) {
85 | super(context,
86 | R.layout.list_item_calendar,
87 | null,
88 | new String[]{CalendarContract.Calendars.CALENDAR_DISPLAY_NAME},
89 | new int[]{R.id.text_view_title},
90 | CursorAdapter.FLAG_REGISTER_CONTENT_OBSERVER);
91 | }
92 |
93 | @Override
94 | public void bindView(View view, Context context, Cursor cursor) {
95 | super.bindView(view, context, cursor);
96 | view.setBackgroundColor(mColors[
97 | Math.abs((int) (((CalendarCursor) cursor).getId() % mColors.length))]);
98 | }
99 |
100 | @Override
101 | public boolean hasStableIds() {
102 | return true;
103 | }
104 |
105 | @Override
106 | public long getItemId(int position) {
107 | if (mCursor == null || !mCursor.moveToPosition(position)) {
108 | return NO_ID;
109 | }
110 | return ((CalendarCursor) mCursor).getId();
111 | }
112 | }
113 | }
114 |
--------------------------------------------------------------------------------
/app/src/main/java/io/github/hidroh/calendar/weather/Weather.java:
--------------------------------------------------------------------------------
1 | package io.github.hidroh.calendar.weather;
2 |
3 | import android.content.Context;
4 | import android.graphics.drawable.Drawable;
5 | import android.support.annotation.ColorInt;
6 | import android.support.annotation.NonNull;
7 | import android.support.annotation.Nullable;
8 | import android.support.v4.content.ContextCompat;
9 | import android.support.v4.graphics.drawable.DrawableCompat;
10 | import android.text.TextUtils;
11 |
12 | import java.util.HashMap;
13 |
14 | import io.github.hidroh.calendar.R;
15 |
16 | /**
17 | * Model for synced weather information of today and tomorrow
18 | */
19 | public class Weather {
20 |
21 | private static final int SPEC_SIZE = 6;
22 |
23 | /**
24 | * Today weather, null if not synced
25 | */
26 | public DayInfo today = null;
27 | /**
28 | * Tomorrow weather, null if not synced
29 | */
30 | public DayInfo tomorrow = null;
31 |
32 | /**
33 | * Constructs an instance of weather from unpacked specs
34 | * @param todayData array of [morning icon, morning temp,
35 | * afternoon icon, afternoon temp,
36 | * night icon, night temp], values may be empty
37 | * @param tomorrowData array of [morning icon, morning temp,
38 | * afternoon icon, afternoon temp,
39 | * night icon, night temp], values may be empty
40 | */
41 | public Weather(@NonNull String[] todayData, @NonNull String[] tomorrowData) {
42 | if (todayData.length == SPEC_SIZE) {
43 | today = new DayInfo(todayData);
44 | }
45 | if (tomorrowData.length == SPEC_SIZE) {
46 | tomorrow = new DayInfo(tomorrowData);
47 | }
48 | }
49 |
50 | /**
51 | * Model for weather information in a day
52 | */
53 | public static class DayInfo {
54 |
55 | /**
56 | * Morning weather
57 | */
58 | public final WeatherInfo morning = new WeatherInfo();
59 | /**
60 | * Afternoon weather
61 | */
62 | public final WeatherInfo afternoon = new WeatherInfo();
63 | /**
64 | * Night weather
65 | */
66 | public final WeatherInfo night = new WeatherInfo();
67 |
68 | DayInfo(String[] specs) {
69 | int index = 0;
70 | morning.icon = specs[index++];
71 | morning.temperature = toTemperature(specs[index++]);
72 | afternoon.icon = specs[index++];
73 | afternoon.temperature = toTemperature(specs[index++]);
74 | night.icon = specs[index++];
75 | night.temperature = toTemperature(specs[index]);
76 | }
77 |
78 | private Float toTemperature(String temperature) {
79 | if (TextUtils.isEmpty(temperature)) {
80 | return null;
81 | }
82 | try {
83 | return Float.valueOf(temperature);
84 | } catch (NumberFormatException e) {
85 | return null;
86 | }
87 | }
88 | }
89 |
90 | /**
91 | * View model for weather information
92 | */
93 | public static class WeatherInfo {
94 | private static final HashMap ICON_MAP = new HashMap<>();
95 | private static final String ICON_CLEAR_DAY = "clear-day";
96 | private static final String ICON_CLEAR_NIGHT = "clear-night";
97 | private static final String ICON_CLOUDY = "cloudy";
98 | private static final String ICON_RAIN = "rain";
99 | private static final String ICON_SNOW = "snow";
100 | private static final String ICON_WIND = "wind";
101 | private static final String ICON_PARTLY_CLOUDY_DAY = "partly-cloudy-day";
102 | private static final String ICON_PARTLY_CLOUDY_NIGHT = "partly-cloudy-night";
103 |
104 | static {
105 | ICON_MAP.put(ICON_CLEAR_DAY, R.drawable.ic_clear_day_24dp);
106 | ICON_MAP.put(ICON_CLEAR_NIGHT, R.drawable.ic_clear_night_24dp);
107 | ICON_MAP.put(ICON_CLOUDY, R.drawable.ic_cloudy_24dp);
108 | ICON_MAP.put(ICON_PARTLY_CLOUDY_DAY, R.drawable.ic_partly_cloudy_day_24dp);
109 | ICON_MAP.put(ICON_PARTLY_CLOUDY_NIGHT, R.drawable.ic_partly_cloudy_night_24dp);
110 | ICON_MAP.put(ICON_RAIN, R.drawable.ic_rain_24dp);
111 | ICON_MAP.put(ICON_SNOW, R.drawable.ic_snow_24dp);
112 | ICON_MAP.put(ICON_WIND, R.drawable.ic_wind_24dp);
113 | }
114 | String icon;
115 | /**
116 | * Temperature in Fahrenheit
117 | */
118 | public Float temperature;
119 |
120 | /**
121 | * Gets drawable for icon representing this instance weather condition
122 | * @param context resources provider
123 | * @param tint icon tint color
124 | * @return drawable for weather condition, or null if not available
125 | */
126 | @Nullable
127 | public Drawable getIcon(Context context, @ColorInt int tint) {
128 | if (TextUtils.isEmpty(icon)) {
129 | return null;
130 | }
131 | int drawableResId = R.drawable.ic_cloudy_24dp;
132 | if (ICON_MAP.containsKey(icon)) {
133 | drawableResId = ICON_MAP.get(icon);
134 | }
135 | Drawable drawable = ContextCompat.getDrawable(context, drawableResId);
136 | drawable = DrawableCompat.wrap(drawable);
137 | DrawableCompat.setTint(drawable, tint);
138 | return drawable;
139 | }
140 | }
141 | }
142 |
--------------------------------------------------------------------------------
/gradlew:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 |
3 | ##############################################################################
4 | ##
5 | ## Gradle start up script for UN*X
6 | ##
7 | ##############################################################################
8 |
9 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
10 | DEFAULT_JVM_OPTS=""
11 |
12 | APP_NAME="Gradle"
13 | APP_BASE_NAME=`basename "$0"`
14 |
15 | # Use the maximum available, or set MAX_FD != -1 to use that value.
16 | MAX_FD="maximum"
17 |
18 | warn ( ) {
19 | echo "$*"
20 | }
21 |
22 | die ( ) {
23 | echo
24 | echo "$*"
25 | echo
26 | exit 1
27 | }
28 |
29 | # OS specific support (must be 'true' or 'false').
30 | cygwin=false
31 | msys=false
32 | darwin=false
33 | case "`uname`" in
34 | CYGWIN* )
35 | cygwin=true
36 | ;;
37 | Darwin* )
38 | darwin=true
39 | ;;
40 | MINGW* )
41 | msys=true
42 | ;;
43 | esac
44 |
45 | # Attempt to set APP_HOME
46 | # Resolve links: $0 may be a link
47 | PRG="$0"
48 | # Need this for relative symlinks.
49 | while [ -h "$PRG" ] ; do
50 | ls=`ls -ld "$PRG"`
51 | link=`expr "$ls" : '.*-> \(.*\)$'`
52 | if expr "$link" : '/.*' > /dev/null; then
53 | PRG="$link"
54 | else
55 | PRG=`dirname "$PRG"`"/$link"
56 | fi
57 | done
58 | SAVED="`pwd`"
59 | cd "`dirname \"$PRG\"`/" >/dev/null
60 | APP_HOME="`pwd -P`"
61 | cd "$SAVED" >/dev/null
62 |
63 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
64 |
65 | # Determine the Java command to use to start the JVM.
66 | if [ -n "$JAVA_HOME" ] ; then
67 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
68 | # IBM's JDK on AIX uses strange locations for the executables
69 | JAVACMD="$JAVA_HOME/jre/sh/java"
70 | else
71 | JAVACMD="$JAVA_HOME/bin/java"
72 | fi
73 | if [ ! -x "$JAVACMD" ] ; then
74 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
75 |
76 | Please set the JAVA_HOME variable in your environment to match the
77 | location of your Java installation."
78 | fi
79 | else
80 | JAVACMD="java"
81 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
82 |
83 | Please set the JAVA_HOME variable in your environment to match the
84 | location of your Java installation."
85 | fi
86 |
87 | # Increase the maximum file descriptors if we can.
88 | if [ "$cygwin" = "false" -a "$darwin" = "false" ] ; then
89 | MAX_FD_LIMIT=`ulimit -H -n`
90 | if [ $? -eq 0 ] ; then
91 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
92 | MAX_FD="$MAX_FD_LIMIT"
93 | fi
94 | ulimit -n $MAX_FD
95 | if [ $? -ne 0 ] ; then
96 | warn "Could not set maximum file descriptor limit: $MAX_FD"
97 | fi
98 | else
99 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
100 | fi
101 | fi
102 |
103 | # For Darwin, add options to specify how the application appears in the dock
104 | if $darwin; then
105 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
106 | fi
107 |
108 | # For Cygwin, switch paths to Windows format before running java
109 | if $cygwin ; then
110 | APP_HOME=`cygpath --path --mixed "$APP_HOME"`
111 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
112 | JAVACMD=`cygpath --unix "$JAVACMD"`
113 |
114 | # We build the pattern for arguments to be converted via cygpath
115 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
116 | SEP=""
117 | for dir in $ROOTDIRSRAW ; do
118 | ROOTDIRS="$ROOTDIRS$SEP$dir"
119 | SEP="|"
120 | done
121 | OURCYGPATTERN="(^($ROOTDIRS))"
122 | # Add a user-defined pattern to the cygpath arguments
123 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then
124 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
125 | fi
126 | # Now convert the arguments - kludge to limit ourselves to /bin/sh
127 | i=0
128 | for arg in "$@" ; do
129 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
130 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
131 |
132 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
133 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
134 | else
135 | eval `echo args$i`="\"$arg\""
136 | fi
137 | i=$((i+1))
138 | done
139 | case $i in
140 | (0) set -- ;;
141 | (1) set -- "$args0" ;;
142 | (2) set -- "$args0" "$args1" ;;
143 | (3) set -- "$args0" "$args1" "$args2" ;;
144 | (4) set -- "$args0" "$args1" "$args2" "$args3" ;;
145 | (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
146 | (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
147 | (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
148 | (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
149 | (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
150 | esac
151 | fi
152 |
153 | # Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules
154 | function splitJvmOpts() {
155 | JVM_OPTS=("$@")
156 | }
157 | eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS
158 | JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME"
159 |
160 | exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@"
161 |
--------------------------------------------------------------------------------
/app/src/main/java/io/github/hidroh/calendar/content/EventsQueryHandler.java:
--------------------------------------------------------------------------------
1 | package io.github.hidroh.calendar.content;
2 |
3 | import android.content.AsyncQueryHandler;
4 | import android.content.ContentResolver;
5 | import android.database.Cursor;
6 | import android.net.Uri;
7 | import android.provider.CalendarContract;
8 | import android.support.annotation.NonNull;
9 |
10 | import java.util.ArrayList;
11 | import java.util.Collection;
12 | import java.util.Iterator;
13 | import java.util.List;
14 |
15 | import io.github.hidroh.calendar.CalendarUtils;
16 |
17 | /**
18 | * Calendar Provider {@link AsyncQueryHandler} that queries for events
19 | * in a given time period
20 | */
21 | public abstract class EventsQueryHandler extends AsyncQueryHandler {
22 |
23 | private static final String SORT = CalendarContract.Events.DTSTART + " ASC";
24 | private static final String AND = " AND ";
25 | private static final String OR = " OR ";
26 | private static final String INT_TRUE = "1";
27 | private static final String INT_FALSE = "0";
28 | private static final String ALL_DAY = CalendarContract.Events.ALL_DAY + "=?";
29 | private static final String DELETED = CalendarContract.Events.DELETED + "=?";
30 | private static final String NOT_CALENDAR_ID = CalendarContract.Events.CALENDAR_ID + "!=?";
31 | // select events that starts within query range
32 | private static final String START_WITHIN = "(" +
33 | CalendarContract.Events.DTSTART + ">=?" + AND +
34 | CalendarContract.Events.DTSTART + "" +
35 | ")";
36 | // select events that starts before but end within or after query range
37 | private static final String START_BEF_END_WITHIN_AFTER = "(" +
38 | CalendarContract.Events.DTSTART + "" + AND +
39 | CalendarContract.Events.DTEND + ">?" +
40 | ")";
41 | // select non all-day events
42 | private static final String SELECTION_NON_ALL_DAY_EVENTS = "(" +
43 | ALL_DAY + AND +
44 | "(" + START_WITHIN + OR + START_BEF_END_WITHIN_AFTER + ")" +
45 | ")";
46 | // select all-day events
47 | private static final String SELECTION_ALL_DAY_EVENTS = "(" +
48 | ALL_DAY + AND +
49 | "(" + START_WITHIN + OR + START_BEF_END_WITHIN_AFTER + ")" +
50 | ")";
51 | // select non-deleted events from either set
52 | private static final String SELECTION = "(" +
53 | DELETED + AND + "(" + SELECTION_NON_ALL_DAY_EVENTS + OR + SELECTION_ALL_DAY_EVENTS + ")" +
54 | ")";
55 |
56 | @NonNull
57 | private final Collection mExcludedCalendarIds;
58 |
59 | /**
60 | * Contrsucts an instance of async query handler for {@link android.provider.CalendarContract.Events}
61 | * @param cr content resolver
62 | * @param excludedCalendarIds collection of excluded calendar IDs
63 | */
64 | public EventsQueryHandler(ContentResolver cr,
65 | @NonNull Collection excludedCalendarIds) {
66 | super(cr);
67 | mExcludedCalendarIds = excludedCalendarIds;
68 | }
69 |
70 | /**
71 | * Starts background query for events from given start time to given end time
72 | * Results will be handled asynchronously on main thread
73 | * via {@link #handleQueryComplete(int, Object, EventCursor)}
74 | * @param cookie cookie object to be passed back on complete
75 | * @param startTimeMillis start time in milliseconds
76 | * @param endTimeMillis end time in milliseconds
77 | * @see {@link #handleQueryComplete(int, Object, EventCursor)}
78 | */
79 | public final void startQuery(Object cookie, long startTimeMillis, long endTimeMillis) {
80 | final String utcStart = String.valueOf(CalendarUtils.toUtcTimeZone(startTimeMillis)),
81 | utcEnd = String.valueOf(CalendarUtils.toUtcTimeZone(endTimeMillis)),
82 | localStart = String.valueOf(startTimeMillis),
83 | localEnd = String.valueOf(endTimeMillis);
84 | List args = new ArrayList() {{
85 | add(INT_FALSE); // not deleted
86 | add(INT_FALSE); // not all day
87 | add(localStart);
88 | add(localEnd);
89 | add(localStart);
90 | add(localStart);
91 | add(INT_TRUE); // all day
92 | add(utcStart);
93 | add(utcEnd);
94 | add(utcStart);
95 | add(utcStart);
96 | }};
97 | StringBuilder sb = new StringBuilder(SELECTION);
98 | if (!mExcludedCalendarIds.isEmpty()) {
99 | Iterator iterator = mExcludedCalendarIds.iterator();
100 | sb.append(AND).append("(");
101 | while (iterator.hasNext()) {
102 | args.add(iterator.next());
103 | sb.append(NOT_CALENDAR_ID);
104 | if (iterator.hasNext()) {
105 | sb.append(AND);
106 | }
107 | }
108 | sb.append(")");
109 | }
110 | startQuery(0, cookie, CalendarContract.Events.CONTENT_URI,
111 | EventCursor.PROJECTION, sb.toString(), args.toArray(new String[args.size()]), SORT);
112 | }
113 |
114 | @Override
115 | protected final void onQueryComplete(int token, Object cookie, Cursor cursor) {
116 | handleQueryComplete(token, cookie, new EventCursor(cursor));
117 | }
118 |
119 | /**
120 | * Handles query results. This will be called on main thread.
121 | * @param token query token
122 | * @param cookie query cookie
123 | * @param cursor {@link android.provider.CalendarContract.Events} cursor wrapper
124 | * @see {@link #startQuery(int, Object, Uri, String[], String, String[], String)}
125 | */
126 | protected abstract void handleQueryComplete(int token, Object cookie, EventCursor cursor);
127 | }
128 |
--------------------------------------------------------------------------------
/app/src/test/java/io/github/hidroh/calendar/MainActivityCalendarSelectionTest.java:
--------------------------------------------------------------------------------
1 | package io.github.hidroh.calendar;
2 |
3 | import android.annotation.SuppressLint;
4 | import android.content.ShadowAsyncQueryHandler;
5 | import android.database.ContentObserver;
6 | import android.database.DataSetObserver;
7 | import android.os.Bundle;
8 | import android.preference.PreferenceManager;
9 | import android.provider.CalendarContract;
10 | import android.support.v4.widget.ResourceCursorAdapter;
11 | import android.view.View;
12 | import android.widget.CheckedTextView;
13 |
14 | import org.junit.After;
15 | import org.junit.Before;
16 | import org.junit.Ignore;
17 | import org.junit.Test;
18 | import org.junit.runner.RunWith;
19 | import org.robolectric.Robolectric;
20 | import org.robolectric.RobolectricGradleTestRunner;
21 | import org.robolectric.annotation.Config;
22 | import org.robolectric.fakes.RoboCursor;
23 | import org.robolectric.shadows.ShadowApplication;
24 | import org.robolectric.shadows.ShadowContentResolver;
25 | import org.robolectric.util.ActivityController;
26 |
27 | import java.util.Arrays;
28 | import java.util.List;
29 |
30 | import io.github.hidroh.calendar.content.CalendarCursor;
31 | import io.github.hidroh.calendar.widget.CalendarSelectionView;
32 |
33 | import static junit.framework.Assert.assertFalse;
34 | import static junit.framework.Assert.assertTrue;
35 | import static org.assertj.android.api.Assertions.assertThat;
36 | import static org.assertj.core.api.Assertions.assertThat;
37 | import static org.robolectric.Shadows.shadowOf;
38 |
39 | @SuppressWarnings("ConstantConditions")
40 | @Config(shadows = {ShadowAsyncQueryHandler.class})
41 | @RunWith(RobolectricGradleTestRunner.class)
42 | public class MainActivityCalendarSelectionTest {
43 |
44 | private ActivityController controller;
45 | private TestMainActivity activity;
46 |
47 | @Before
48 | public void setUp() {
49 | RoboCursor cursor = new TestRoboCursor();
50 | cursor.setResults(new Object[][]{
51 | new Object[]{1L, "My Calendar"},
52 | new Object[]{2L, "Birthdays"},
53 | });
54 | shadowOf(ShadowApplication.getInstance().getContentResolver())
55 | .setCursor(CalendarContract.Calendars.CONTENT_URI, cursor);
56 | controller = Robolectric.buildActivity(TestMainActivity.class);
57 | activity = controller.get();
58 | }
59 |
60 | @Test @Ignore
61 | public void testCalendarSelectionView() {
62 | // initial state: exclude calendar ID 2
63 | PreferenceManager.getDefaultSharedPreferences(activity)
64 | .edit()
65 | .putString(CalendarUtils.PREF_CALENDAR_EXCLUSIONS, "2")
66 | .apply();
67 | controller.create().start().postCreate(null).resume();
68 |
69 | // calendar selection should have 1 checked, 2 unchecked
70 | CalendarSelectionView selectionView =
71 | (CalendarSelectionView) activity.findViewById(R.id.list_view_calendar);
72 | ResourceCursorAdapter adapter = (ResourceCursorAdapter) selectionView.getAdapter();
73 | assertThat(adapter.getCount()).isEqualTo(2);
74 | assertTrue(selectionView.isItemChecked(0));
75 | assertFalse(selectionView.isItemChecked(1));
76 |
77 | // calendar selection view should bind calendar display name
78 | adapter.getCursor().moveToFirst();
79 | View itemView = adapter.newView(activity, adapter.getCursor(), selectionView);
80 | adapter.bindView(itemView, activity, adapter.getCursor());
81 | assertThat((CheckedTextView) itemView.findViewById(R.id.text_view_title))
82 | .hasTextString("My Calendar");
83 | controller.pause().stop().destroy();
84 | }
85 |
86 | @Test @Ignore
87 | public void testToggleCalendarSelection() {
88 | // initial state
89 | controller.create().start().postCreate(null).resume();
90 | CalendarSelectionView selectionView =
91 | (CalendarSelectionView) activity.findViewById(R.id.list_view_calendar);
92 | assertTrue(selectionView.isItemChecked(0));
93 |
94 | // clicking item should toggle its checked status
95 | shadowOf(selectionView).performItemClick(0);
96 | assertFalse(selectionView.isItemChecked(0));
97 |
98 | // clicking item should toggle its checked status
99 | shadowOf(selectionView).performItemClick(0);
100 | assertTrue(selectionView.isItemChecked(0));
101 | controller.pause().stop().destroy();
102 | }
103 |
104 | @Test @Ignore
105 | public void testPersistExclusions() {
106 | // initial state
107 | assertThat(PreferenceManager.getDefaultSharedPreferences(activity)
108 | .getString(CalendarUtils.PREF_CALENDAR_EXCLUSIONS, null))
109 | .isNullOrEmpty();
110 | controller.create().start().postCreate(null).resume();
111 |
112 | // toggle off
113 | CalendarSelectionView selectionView =
114 | (CalendarSelectionView) activity.findViewById(R.id.list_view_calendar);
115 | shadowOf(selectionView).performItemClick(0);
116 | shadowOf(selectionView).performItemClick(1);
117 |
118 | // destroying activity should persist preference
119 | controller.pause().stop().destroy();
120 | assertThat(PreferenceManager.getDefaultSharedPreferences(activity)
121 | .getString(CalendarUtils.PREF_CALENDAR_EXCLUSIONS, null))
122 | .contains("1")
123 | .contains(",")
124 | .contains("2");
125 | }
126 |
127 | @Test
128 | public void testCreateLocalCalendar() {
129 | shadowOf(ShadowApplication.getInstance().getContentResolver())
130 | .setCursor(CalendarContract.Calendars.CONTENT_URI, new TestRoboCursor());
131 | controller.create().start().postCreate(null).resume();
132 | List inserts =
133 | shadowOf(ShadowApplication.getInstance()
134 | .getContentResolver())
135 | .getInsertStatements();
136 | assertThat(inserts).hasSize(1);
137 | assertThat(inserts.get(0).getUri().toString())
138 | .contains(CalendarContract.Calendars.CONTENT_URI.toString());
139 | controller.pause().stop().destroy();
140 | }
141 |
142 | @After
143 | public void tearDown() {
144 | }
145 |
146 | @SuppressLint("Registered")
147 | static class TestMainActivity extends MainActivity {
148 | @Override
149 | protected boolean checkCalendarPermissions() {
150 | return true;
151 | }
152 | }
153 |
154 | static class TestRoboCursor extends RoboCursor {
155 | public TestRoboCursor() {
156 | setColumnNames(Arrays.asList(CalendarCursor.PROJECTION));
157 | }
158 |
159 | @Override
160 | public void registerDataSetObserver(DataSetObserver observer) {
161 | // no op
162 | }
163 |
164 | @Override
165 | public void unregisterDataSetObserver(DataSetObserver observer) {
166 | // no op
167 | }
168 |
169 | @Override
170 | public void registerContentObserver(ContentObserver observer) {
171 | // no op
172 | }
173 |
174 | @Override
175 | public void unregisterContentObserver(ContentObserver observer) {
176 | // no op
177 | }
178 |
179 | @Override
180 | public void setExtras(Bundle extras) {
181 | // no op
182 | }
183 |
184 | @Override
185 | public boolean isClosed() {
186 | return false;
187 | }
188 | }
189 | }
190 |
--------------------------------------------------------------------------------
/app/src/test/java/io/github/hidroh/calendar/MainActivityWeatherTest.java:
--------------------------------------------------------------------------------
1 | package io.github.hidroh.calendar;
2 |
3 | import android.annotation.SuppressLint;
4 | import android.preference.PreferenceManager;
5 | import android.support.annotation.NonNull;
6 | import android.support.v7.widget.LinearLayoutManager;
7 | import android.support.v7.widget.RecyclerView;
8 |
9 | import org.junit.After;
10 | import org.junit.Before;
11 | import org.junit.Ignore;
12 | import org.junit.Test;
13 | import org.junit.runner.RunWith;
14 | import org.robolectric.Robolectric;
15 | import org.robolectric.RobolectricGradleTestRunner;
16 | import org.robolectric.annotation.Config;
17 | import org.robolectric.util.ActivityController;
18 |
19 | import io.github.hidroh.calendar.test.shadows.ShadowLinearLayoutManager;
20 | import io.github.hidroh.calendar.test.shadows.ShadowRecyclerView;
21 | import io.github.hidroh.calendar.weather.WeatherSyncService;
22 | import io.github.hidroh.calendar.widget.AgendaView;
23 |
24 | import static junit.framework.Assert.assertFalse;
25 | import static junit.framework.Assert.assertTrue;
26 | import static org.assertj.android.api.Assertions.assertThat;
27 | import static org.mockito.Mockito.mock;
28 | import static org.mockito.Mockito.never;
29 | import static org.mockito.Mockito.times;
30 | import static org.mockito.Mockito.verify;
31 | import static org.robolectric.Shadows.shadowOf;
32 |
33 | @Config(shadows = {ShadowRecyclerView.class, ShadowLinearLayoutManager.class})
34 | @RunWith(RobolectricGradleTestRunner.class)
35 | public class MainActivityWeatherTest {
36 | private ActivityController controller;
37 | private TestMainActivity activity;
38 |
39 | @Before
40 | public void setUp() {
41 | controller = Robolectric.buildActivity(TestMainActivity.class);
42 | activity = controller.get();
43 | }
44 |
45 | @Test
46 | public void testEnableWeather() {
47 | // initial state
48 | assertFalse(PreferenceManager.getDefaultSharedPreferences(activity)
49 | .getBoolean(WeatherSyncService.PREF_WEATHER_ENABLED, false));
50 |
51 | // enabling weather should prompt for permissions
52 | controller.create().start().postCreate(null).resume().visible();
53 | shadowOf(activity).clickMenuItem(R.id.action_weather);
54 | verify(activity.permissionRequester).requestPermissions();
55 |
56 | // granting permissions should persist preference
57 | activity.permissionCheckResult = true;
58 | activity.onRequestPermissionsResult(1, new String[0], new int[0]);
59 | assertTrue(PreferenceManager.getDefaultSharedPreferences(activity)
60 | .getBoolean(WeatherSyncService.PREF_WEATHER_ENABLED, false));
61 | }
62 |
63 | @Test
64 | public void testEnableWeatherDenyPermissions() {
65 | // initial state
66 | assertFalse(PreferenceManager.getDefaultSharedPreferences(activity)
67 | .getBoolean(WeatherSyncService.PREF_WEATHER_ENABLED, false));
68 |
69 | // enabling weather should prompt for permissions
70 | controller.create().start().postCreate(null).resume().visible();
71 | shadowOf(activity).clickMenuItem(R.id.action_weather);
72 | verify(activity.permissionRequester).requestPermissions();
73 |
74 | // denying permissions should not persist preference and show explanation
75 | activity.onRequestPermissionsResult(1, new String[0], new int[0]);
76 | assertFalse(PreferenceManager.getDefaultSharedPreferences(activity)
77 | .getBoolean(WeatherSyncService.PREF_WEATHER_ENABLED, false));
78 |
79 | // can be flaky here as SnackBar may disappear due to animation
80 | assertThat(activity.findViewById(R.id.snackbar_action)).isVisible();
81 |
82 | // granting permissions through SnackBar action should persist preference
83 | //noinspection ConstantConditions
84 | activity.findViewById(R.id.snackbar_action).performClick();
85 | verify(activity.permissionRequester, times(2)).requestPermissions();
86 | activity.permissionCheckResult = true;
87 | activity.onRequestPermissionsResult(1, new String[0], new int[0]);
88 | assertTrue(PreferenceManager.getDefaultSharedPreferences(activity)
89 | .getBoolean(WeatherSyncService.PREF_WEATHER_ENABLED, false));
90 | }
91 |
92 | @Test
93 | public void testToggleWeatherOption() {
94 | activity.permissionCheckResult = true;
95 | controller.create().start().postCreate(null).resume().visible();
96 |
97 | // toggling on-off-on again should not prompt for permissions, which has been granted
98 | shadowOf(activity).clickMenuItem(R.id.action_weather);
99 | shadowOf(activity).clickMenuItem(R.id.action_weather);
100 | verify(activity.permissionRequester, never()).requestPermissions();
101 | }
102 |
103 | @Test @Ignore
104 | public void testWeatherEnabledButMissingPermissions() {
105 | PreferenceManager.getDefaultSharedPreferences(activity)
106 | .edit()
107 | .putBoolean(WeatherSyncService.PREF_WEATHER_ENABLED, true)
108 | .apply();
109 | controller.create().start().postCreate(null).resume().visible();
110 |
111 | // can be flaky here as SnackBar may disappear due to animation
112 | assertThat(activity.findViewById(R.id.snackbar_action)).isVisible();
113 | }
114 |
115 | @Test
116 | public void testWeatherUpdate() {
117 | // initial state with no weather information
118 | PreferenceManager.getDefaultSharedPreferences(activity)
119 | .edit()
120 | .putBoolean(WeatherSyncService.PREF_WEATHER_ENABLED, true)
121 | .apply();
122 | activity.permissionCheckResult = true;
123 | controller.create().start().postCreate(null).resume().visible();
124 | assertThat(createBindFirstViewHolder().itemView.findViewById(R.id.weather)).isNotVisible();
125 |
126 | PreferenceManager.getDefaultSharedPreferences(activity)
127 | .edit()
128 | .putString(WeatherSyncService.PREF_WEATHER_TODAY,
129 | "clear-day|86.0|clear-day|86.0|clear-day|86.0")
130 | .putString(WeatherSyncService.PREF_WEATHER_TOMORROW,
131 | "clear-day|86.0|clear-day|86.0|clear-day|86.0")
132 | .apply();
133 | assertThat(createBindFirstViewHolder().itemView.findViewById(R.id.weather)).isVisible();
134 | }
135 |
136 | @After
137 | public void tearDown() {
138 | controller.pause().stop().destroy();
139 | }
140 |
141 | @SuppressWarnings({"ConstantConditions", "unchecked"})
142 | @NonNull
143 | private RecyclerView.ViewHolder createBindFirstViewHolder() {
144 | AgendaView agendaView = (AgendaView) activity.findViewById(R.id.agenda_view);
145 | RecyclerView.Adapter adapter = agendaView.getAdapter();
146 | int firstPosition = ((LinearLayoutManager) agendaView.getLayoutManager())
147 | .findFirstVisibleItemPosition();
148 | RecyclerView.ViewHolder viewHolder = adapter.createViewHolder(agendaView,
149 | adapter.getItemViewType(firstPosition));
150 | adapter.bindViewHolder(viewHolder, firstPosition);
151 | return viewHolder;
152 | }
153 |
154 | @SuppressLint("Registered")
155 | static class TestMainActivity extends MainActivity {
156 | boolean permissionCheckResult = false;
157 | final PermissionRequester permissionRequester = mock(PermissionRequester.class);
158 |
159 | @Override
160 | protected boolean checkCalendarPermissions() {
161 | return true;
162 | }
163 |
164 | @Override
165 | protected boolean checkLocationPermissions() {
166 | return permissionCheckResult;
167 | }
168 |
169 | @Override
170 | protected void requestLocationPermissions() {
171 | permissionRequester.requestPermissions();
172 | }
173 | }
174 |
175 | interface PermissionRequester {
176 | void requestPermissions();
177 | }
178 | }
179 |
--------------------------------------------------------------------------------
/app/src/test/java/io/github/hidroh/calendar/weather/WeatherSyncServiceTest.java:
--------------------------------------------------------------------------------
1 | package io.github.hidroh.calendar.weather;
2 |
3 | import android.annotation.SuppressLint;
4 | import android.app.AlarmManager;
5 | import android.content.Context;
6 | import android.content.Intent;
7 | import android.location.Location;
8 | import android.preference.PreferenceManager;
9 | import android.support.annotation.Nullable;
10 | import android.text.format.DateUtils;
11 |
12 | import org.junit.After;
13 | import org.junit.Before;
14 | import org.junit.Test;
15 | import org.junit.runner.RunWith;
16 | import org.robolectric.Robolectric;
17 | import org.robolectric.RobolectricGradleTestRunner;
18 | import org.robolectric.RuntimeEnvironment;
19 | import org.robolectric.shadows.ShadowAlarmManager;
20 | import org.robolectric.util.ServiceController;
21 |
22 | import java.io.IOException;
23 |
24 | import io.github.hidroh.calendar.CalendarUtils;
25 | import retrofit2.Call;
26 | import retrofit2.Response;
27 |
28 | import static org.assertj.android.api.Assertions.assertThat;
29 | import static org.assertj.core.api.Assertions.assertThat;
30 | import static org.mockito.Matchers.anyDouble;
31 | import static org.mockito.Matchers.anyLong;
32 | import static org.mockito.Matchers.eq;
33 | import static org.mockito.Mockito.mock;
34 | import static org.mockito.Mockito.never;
35 | import static org.mockito.Mockito.verify;
36 | import static org.mockito.Mockito.when;
37 | import static org.robolectric.Shadows.shadowOf;
38 |
39 | @SuppressWarnings("unchecked")
40 | @RunWith(RobolectricGradleTestRunner.class)
41 | public class WeatherSyncServiceTest {
42 | private ServiceController controller;
43 | private TestService service;
44 |
45 | @Before
46 | public void setUp() {
47 | controller = Robolectric.buildService(TestService.class);
48 | service = controller.attach().create().get();
49 | PreferenceManager.getDefaultSharedPreferences(service)
50 | .edit()
51 | .putBoolean(WeatherSyncService.PREF_WEATHER_ENABLED, true)
52 | .apply();
53 | }
54 |
55 | @Test
56 | public void testNoLocation() {
57 | // initial state
58 | assertThat(WeatherSyncService.getSyncedWeather(service)).isNull();
59 |
60 | // trigger service with no location
61 | service.location = null;
62 | controller.startCommand(0, 0);
63 | verify(service.webService, never()).forecast(anyDouble(), anyDouble(), anyLong());
64 | assertThat(WeatherSyncService.getSyncedWeather(service)).isNull();
65 | }
66 |
67 | @Test
68 | public void testFetchWithException() throws IOException {
69 | // initial state
70 | assertThat(WeatherSyncService.getSyncedWeather(service)).isNull();
71 |
72 | // trigger service that generates exception
73 | Call faultyCall = mock(Call.class);
74 | when(faultyCall.execute()).thenThrow(IOException.class);
75 | when(service.webService.forecast(anyDouble(), anyDouble(), anyLong()))
76 | .thenReturn(faultyCall);
77 | controller.startCommand(0, 0);
78 |
79 | assertThat(WeatherSyncService.getSyncedWeather(service)).isNull();
80 | }
81 |
82 | @Test
83 | public void testFetchForecast() throws IOException {
84 | // initial state
85 | assertThat(WeatherSyncService.getSyncedWeather(service)).isNull();
86 | setForecastResponse(createForecast(true));
87 |
88 | // trigger service
89 | controller.startCommand(0, 0);
90 |
91 | // service should fetch and persist response to shared preferences
92 | long todaySeconds = CalendarUtils.today() / DateUtils.SECOND_IN_MILLIS,
93 | tomorrowSeconds = todaySeconds + DateUtils.DAY_IN_MILLIS / DateUtils.SECOND_IN_MILLIS;
94 | verify(service.webService).forecast(anyDouble(), anyDouble(), eq(todaySeconds));
95 | verify(service.webService).forecast(anyDouble(), anyDouble(), eq(tomorrowSeconds));
96 | assertThat(WeatherSyncService.getSyncedWeather(service)).isNotNull();
97 | }
98 |
99 | @Test
100 | public void testDisabled() {
101 | // initial state
102 | ShadowAlarmManager alarmManager = shadowOf((AlarmManager) service
103 | .getSystemService(Context.ALARM_SERVICE));
104 | assertThat(alarmManager.getScheduledAlarms()).isEmpty();
105 | PreferenceManager.getDefaultSharedPreferences(service)
106 | .edit()
107 | .putBoolean(WeatherSyncService.PREF_WEATHER_ENABLED, false)
108 | .apply();
109 |
110 | // trigger service while disabled should not schedule another alarm
111 | controller.startCommand(0, 0);
112 | assertThat(alarmManager.getScheduledAlarms()).isEmpty();
113 | }
114 |
115 | @Test
116 | public void testForecastMissingInfo() throws IOException {
117 | // initial state
118 | assertThat(WeatherSyncService.getSyncedWeather(service)).isNull();
119 | setForecastResponse(createForecast(false));
120 |
121 | // trigger service
122 | controller.startCommand(0, 0);
123 |
124 | // service should still persist any available info
125 | assertThat(WeatherSyncService.getSyncedWeather(service)).isNotNull();
126 | }
127 |
128 | @Test
129 | public void testAlarm() throws IOException {
130 | // initial state
131 | ShadowAlarmManager alarmManager = shadowOf((AlarmManager) service
132 | .getSystemService(Context.ALARM_SERVICE));
133 | assertThat(alarmManager.getScheduledAlarms()).isEmpty();
134 |
135 | // trigger service should schedule alarm
136 | setForecastResponse(createForecast(true));
137 | controller.startCommand(0, 0);
138 |
139 | assertThat(alarmManager.getScheduledAlarms()).hasSize(1);
140 | assertThat(alarmManager.getNextScheduledAlarm().triggerAtTime)
141 | .isGreaterThanOrEqualTo(CalendarUtils.today() + AlarmManager.INTERVAL_DAY);
142 |
143 | // trigger service again should remove scheduled alarm and schedule new one
144 | controller.startCommand(0, 0);
145 | assertThat(alarmManager.getScheduledAlarms()).hasSize(1);
146 | }
147 |
148 | @Test
149 | public void testAlarmBroadcastReceiver() {
150 | new WeatherSyncAlarmReceiver().onReceive(RuntimeEnvironment.application, null);
151 | assertThat(shadowOf(RuntimeEnvironment.application).getNextStartedService())
152 | .hasComponent(RuntimeEnvironment.application, WeatherSyncService.class);
153 | }
154 |
155 | @After
156 | public void tearDown() {
157 | controller.destroy();
158 | }
159 |
160 | private void setForecastResponse(WeatherSyncService.ForecastIOService.Forecast forecast)
161 | throws IOException {
162 | Call call = mock(Call.class);
163 | when(call.execute()).thenReturn(Response.success(forecast));
164 | when(service.webService.forecast(anyDouble(), anyDouble(), anyLong())).thenReturn(call);
165 | }
166 |
167 | private WeatherSyncService.ForecastIOService.Forecast createForecast(final boolean full) {
168 | return new WeatherSyncService.ForecastIOService.Forecast(){{
169 | hourly = new WeatherSyncService.ForecastIOService.Hourly(){{
170 | data = new WeatherSyncService.ForecastIOService.DataPoint[24];
171 | if (!full) {
172 | data[8] = new WeatherSyncService.ForecastIOService.DataPoint() {{
173 | icon = "clear-day";
174 | temperature = 86.0F;
175 | }};
176 | data[14] = new WeatherSyncService.ForecastIOService.DataPoint(){{
177 | icon = "rain";
178 | temperature = 86.0F;
179 | }};
180 | data[20] = new WeatherSyncService.ForecastIOService.DataPoint(){{
181 | icon = "clear-night";
182 | temperature = 86.0F;
183 | }};
184 | }
185 | }};
186 | }};
187 | }
188 |
189 | @SuppressLint("Registered")
190 | public static class TestService extends WeatherSyncService {
191 | ForecastIOService webService = mock(ForecastIOService.class);
192 | Location location = new Location("");
193 |
194 | @Override
195 | public void onStart(Intent intent, int startId) {
196 | // same logic as in internal ServiceHandler.handleMessage()
197 | // but runs on same thread as Service
198 | onHandleIntent(intent);
199 | stopSelf(startId);
200 | }
201 |
202 | @Nullable
203 | @Override
204 | protected Location getLocation() {
205 | return location;
206 | }
207 |
208 | @Override
209 | protected ForecastIOService getForecastService() {
210 | return webService;
211 | }
212 | }
213 | }
214 |
--------------------------------------------------------------------------------
/app/src/main/java/io/github/hidroh/calendar/widget/MonthViewPagerAdapter.java:
--------------------------------------------------------------------------------
1 | package io.github.hidroh.calendar.widget;
2 |
3 | import android.database.ContentObserver;
4 | import android.os.Bundle;
5 | import android.os.Parcelable;
6 | import android.support.annotation.Nullable;
7 | import android.support.annotation.VisibleForTesting;
8 | import android.support.v4.util.ArrayMap;
9 | import android.support.v4.view.PagerAdapter;
10 | import android.support.v4.view.ViewPager;
11 | import android.view.View;
12 | import android.view.ViewGroup;
13 |
14 | import java.util.ArrayList;
15 | import java.util.List;
16 |
17 | import io.github.hidroh.calendar.CalendarUtils;
18 | import io.github.hidroh.calendar.content.EventCursor;
19 |
20 | /**
21 | * A circular {@link PagerAdapter}, with a view pool of 5 items:
22 | * buffer, left, [active], right, buffer
23 | * Upon user scrolling to a buffer view, {@link ViewPager#setCurrentItem(int)}
24 | * should be called to wrap around and shift active view to the next non-buffer
25 | * @see #shiftLeft()
26 | * @see #shiftRight()
27 | */
28 | class MonthViewPagerAdapter extends PagerAdapter {
29 | private static final String STATE_FIRST_MONTH_MILLIS = "state:month";
30 | private static final String STATE_SELECTED_DAY_MILLIS = "state:selectedDay";
31 | static final int ITEM_COUNT = 5; // buffer, left, active, right, buffer
32 |
33 | @VisibleForTesting final List mViews = new ArrayList<>(getCount());
34 | @VisibleForTesting long mSelectedDayMillis = CalendarUtils.today();
35 | private final List mMonths = new ArrayList<>(getCount());
36 | private final MonthView.OnDateChangeListener mListener;
37 | private final List mCursors = new ArrayList<>(getCount());
38 | private final ArrayMap mObservers =
39 | new ArrayMap<>(getCount());
40 |
41 | public MonthViewPagerAdapter(MonthView.OnDateChangeListener listener) {
42 | mListener = listener;
43 | int mid = ITEM_COUNT / 2;
44 | long todayMillis = CalendarUtils.monthFirstDay(CalendarUtils.today());
45 | for (int i = 0; i < getCount(); i++) {
46 | mMonths.add(CalendarUtils.addMonths(todayMillis, i - mid));
47 | mViews.add(null);
48 | mCursors.add(null);
49 | }
50 | }
51 |
52 | @Override
53 | public Object instantiateItem(ViewGroup container, int position) {
54 | MonthView view = new MonthView(container.getContext());
55 | view.setLayoutParams(new ViewPager.LayoutParams());
56 | view.setOnDateChangeListener(mListener);
57 | mViews.set(position, view);
58 | container.addView(view); // views are not added in same order as adapter items
59 | bind(position);
60 | return view;
61 | }
62 |
63 | @Override
64 | public void destroyItem(ViewGroup container, int position, Object object) {
65 | ((MonthView) object).setOnDateChangeListener(null);
66 | container.removeView((View) object);
67 | }
68 |
69 | @Override
70 | public int getCount() {
71 | return ITEM_COUNT;
72 | }
73 |
74 | @Override
75 | public boolean isViewFromObject(View view, Object object) {
76 | return view == object;
77 | }
78 |
79 | @Override
80 | public Parcelable saveState() {
81 | Bundle bundle = new Bundle();
82 | bundle.putLong(STATE_FIRST_MONTH_MILLIS, mMonths.get(0));
83 | bundle.putLong(STATE_SELECTED_DAY_MILLIS, mSelectedDayMillis);
84 | return bundle;
85 | }
86 |
87 | @Override
88 | public void restoreState(Parcelable state, ClassLoader loader) {
89 | Bundle savedState = (Bundle) state;
90 | if (savedState == null) {
91 | return;
92 | }
93 | mSelectedDayMillis = savedState.getLong(STATE_SELECTED_DAY_MILLIS);
94 | long firstMonthMillis = savedState.getLong(STATE_FIRST_MONTH_MILLIS);
95 | for (int i = 0; i < getCount(); i++) {
96 | mMonths.set(i, CalendarUtils.addMonths(firstMonthMillis, i));
97 | }
98 | }
99 |
100 | /**
101 | * Sets selected day for page at given position, which either sets new selected day
102 | * if it falls within that month, or unsets previously selected day if any
103 | * @param position page position
104 | * @param dayMillis selected day in milliseconds
105 | * @param notifySelf true to rebind page at given position, false otherwise
106 | */
107 | void setSelectedDay(int position, long dayMillis, boolean notifySelf) {
108 | mSelectedDayMillis = dayMillis;
109 | if (notifySelf) {
110 | bindSelectedDay(position);
111 | }
112 | if (position > 0) {
113 | bindSelectedDay(position - 1);
114 | }
115 | if (position < getCount() - 1) {
116 | bindSelectedDay(position + 1);
117 | }
118 | }
119 |
120 | /**
121 | * Gets month of calendar in given position
122 | * @param position adapter position
123 | * @return month in milliseconds
124 | */
125 | long getMonth(int position) {
126 | return mMonths.get(position);
127 | }
128 |
129 | /**
130 | * Shifts Jan, Feb, Mar, Apr, [May] to Apr, [May], Jun, Jul, Aug
131 | * Rebinds views in view pool if needed
132 | */
133 | void shiftLeft() {
134 | for (int i = 0; i < getCount() - 2; i++) {
135 | mMonths.add(CalendarUtils.addMonths(mMonths.remove(0), getCount()));
136 | }
137 | // TODO only deactivate non reusable cursors
138 | for (int i = 0; i < getCount(); i++) {
139 | swapCursor(i, null, null);
140 | }
141 | // rebind current item (2nd) and 2 adjacent items
142 | for (int i = 0; i <= 2; i++) {
143 | bind(i);
144 | }
145 | }
146 |
147 | /**
148 | * Shifts [Jan], Feb, Mar, Apr, May to Oct, Nov, Dec, [Jan], Feb
149 | * Rebinds views in view pool if needed
150 | */
151 | void shiftRight() {
152 | for (int i = 0; i < getCount() - 2; i++) {
153 | mMonths.add(0, CalendarUtils.addMonths(mMonths.remove(getCount() - 1), -getCount()));
154 | mCursors.add(0, mCursors.remove(getCount() - 1));
155 | }
156 | // TODO only deactivate non reusable cursors
157 | for (int i = 0; i < getCount(); i++) {
158 | swapCursor(i, null, null);
159 | }
160 | // rebind current item (2nd to last) and 2 adjacent items
161 | for (int i = 0; i <= 2; i++) {
162 | bind(getCount() - 1 - i);
163 | }
164 | }
165 |
166 | /**
167 | * Rebinds month, events and selected day for calendar at given position
168 | * @param position adapter position
169 | */
170 | void bind(int position) {
171 | if (mViews.get(position) != null) {
172 | mViews.get(position).setCalendar(mMonths.get(position));
173 | }
174 | bindCursor(position);
175 | bindSelectedDay(position);
176 | }
177 |
178 | /**
179 | * Gets cursor for calendar events at given position
180 | * @param position adapter position
181 | * @return {@link android.provider.CalendarContract.Events} cursor wrapper or null
182 | * @see {@link #swapCursor(long, EventCursor, ContentObserver)}
183 | */
184 | EventCursor getCursor(int position) {
185 | return mCursors.get(position);
186 | }
187 |
188 | /**
189 | * Swaps cursor for calendar events for given month
190 | * Closes previously bound cursor, unregisters observer if any
191 | * @param monthMillis month in milliseconds
192 | * @param cursor {@link android.provider.CalendarContract.Events} cursor wrapper or null
193 | * @param contentObserver content observer for given cursor
194 | */
195 | void swapCursor(long monthMillis, @Nullable EventCursor cursor,
196 | ContentObserver contentObserver) {
197 | for (int i = 0; i < mMonths.size(); i++) {
198 | if (CalendarUtils.sameMonth(monthMillis, mMonths.get(i))) {
199 | swapCursor(i, cursor, contentObserver);
200 | break;
201 | }
202 | }
203 | }
204 |
205 | /**
206 | * Deactivates all previously bound cursors and unregisters their observers
207 | */
208 | void deactivate() {
209 | for (EventCursor cursor : mCursors) {
210 | deactivate(cursor);
211 | }
212 | }
213 |
214 | /**
215 | * Deactivates all previously bound cursors and unregisters their observers,
216 | * prepares views for new data bindings
217 | */
218 | void invalidate() {
219 | for (int i = 0; i < mCursors.size(); i++) {
220 | swapCursor(i, null, null);
221 | }
222 | }
223 |
224 | private void bindSelectedDay(int position) {
225 | if (mViews.get(position) != null) {
226 | mViews.get(position).setSelectedDay(mSelectedDayMillis);
227 | }
228 | }
229 |
230 | private void swapCursor(int position, @Nullable EventCursor cursor,
231 | ContentObserver contentObserver) {
232 | deactivate(mCursors.get(position));
233 | if (cursor != null) {
234 | cursor.registerContentObserver(contentObserver);
235 | mObservers.put(cursor, contentObserver);
236 | }
237 | mCursors.set(position, cursor);
238 | bindCursor(position);
239 | }
240 |
241 | private void bindCursor(int position) {
242 | if (mCursors.get(position) != null && mViews.get(position) != null) {
243 | mViews.get(position).swapCursor(mCursors.get(position));
244 | }
245 | }
246 |
247 | private void deactivate(EventCursor cursor) {
248 | if (cursor != null) {
249 | cursor.unregisterContentObserver(mObservers.get(cursor));
250 | mObservers.remove(cursor);
251 | cursor.close();
252 | }
253 | }
254 | }
255 |
--------------------------------------------------------------------------------
/app/src/main/res/layout/event_edit_view.xml:
--------------------------------------------------------------------------------
1 |
2 |
5 |
6 |
12 |
13 |
17 |
18 |
26 |
27 |
28 |
39 |
40 |
45 |
46 |
47 |
48 |
63 |
64 |
70 |
71 |
76 |
77 |
82 |
83 |
88 |
89 |
99 |
100 |
105 |
106 |
107 |
122 |
123 |
135 |
136 |
147 |
148 |
149 |
161 |
162 |
174 |
175 |
176 |
188 |
189 |
201 |
202 |
203 |
218 |
219 |
233 |
234 |
235 |
--------------------------------------------------------------------------------
/app/src/test/java/io/github/hidroh/calendar/widget/MonthViewTest.java:
--------------------------------------------------------------------------------
1 | package io.github.hidroh.calendar.widget;
2 |
3 | import android.annotation.SuppressLint;
4 | import android.os.Bundle;
5 | import android.support.annotation.Nullable;
6 | import android.support.v7.app.AppCompatActivity;
7 | import android.support.v7.widget.RecyclerView;
8 | import android.text.SpannableString;
9 | import android.view.ViewGroup;
10 | import android.widget.FrameLayout;
11 | import android.widget.TextView;
12 |
13 | import org.junit.After;
14 | import org.junit.Before;
15 | import org.junit.Test;
16 | import org.junit.runner.RunWith;
17 | import org.robolectric.Robolectric;
18 | import org.robolectric.RobolectricGradleTestRunner;
19 | import org.robolectric.annotation.Config;
20 | import org.robolectric.internal.ShadowExtractor;
21 | import org.robolectric.util.ActivityController;
22 |
23 | import java.text.DateFormatSymbols;
24 | import java.util.Calendar;
25 | import java.util.TimeZone;
26 |
27 | import io.github.hidroh.calendar.CalendarUtils;
28 | import io.github.hidroh.calendar.R;
29 | import io.github.hidroh.calendar.test.TestEventCursor;
30 | import io.github.hidroh.calendar.test.shadows.ShadowViewHolder;
31 | import io.github.hidroh.calendar.text.style.CircleSpan;
32 | import io.github.hidroh.calendar.text.style.UnderDotSpan;
33 |
34 | import static io.github.hidroh.calendar.test.assertions.SpannableStringAssert.assertThat;
35 | import static org.assertj.android.api.Assertions.assertThat;
36 | import static org.assertj.core.api.Assertions.assertThat;
37 | import static org.mockito.Matchers.anyLong;
38 | import static org.mockito.Mockito.mock;
39 | import static org.mockito.Mockito.never;
40 | import static org.mockito.Mockito.times;
41 | import static org.mockito.Mockito.verify;
42 |
43 | @SuppressWarnings("unchecked")
44 | @Config(shadows = ShadowViewHolder.class)
45 | @RunWith(RobolectricGradleTestRunner.class)
46 | public class MonthViewTest {
47 | private ActivityController controller;
48 | private MonthView monthView;
49 | private MonthView.GridAdapter adapter;
50 |
51 | @Before
52 | public void setUp() {
53 | controller = Robolectric.buildActivity(TestActivity.class);
54 | TestActivity activity = controller.create().start().resume().visible().get();
55 | monthView = (MonthView) activity.findViewById(R.id.calendar_view);
56 | //noinspection ConstantConditions
57 | monthView.setCalendar(createDayMillis(2016, Calendar.MARCH, 1));
58 | adapter = (MonthView.GridAdapter) monthView.getAdapter();
59 | // 7 header cells + 2 carried days from Feb + 31 days in March
60 | assertThat(adapter.getItemCount()).isEqualTo(7 + 31 + 2);
61 | }
62 |
63 | @Test
64 | public void testHeader() {
65 | RecyclerView.ViewHolder viewHolder = createBindViewHolder(0);
66 | assertThat(viewHolder.itemView).isInstanceOf(TextView.class);
67 | assertThat((TextView) viewHolder.itemView)
68 | .hasTextString(DateFormatSymbols.getInstance().getShortWeekdays()[Calendar.SUNDAY]);
69 | }
70 |
71 | @Test
72 | public void testEmptyContent() {
73 | RecyclerView.ViewHolder viewHolder = createBindViewHolder(7); // carried over from Feb
74 | assertThat(viewHolder.itemView).isInstanceOf(TextView.class);
75 | assertThat((TextView) viewHolder.itemView).isEmpty();
76 | }
77 |
78 | @Test
79 | public void testContent() {
80 | RecyclerView.ViewHolder viewHolder = createBindViewHolder(9); // 01-March-2016
81 | assertThat(viewHolder.itemView).isInstanceOf(TextView.class);
82 | assertThat((TextView) viewHolder.itemView).isNotEmpty();
83 | }
84 |
85 | @Test
86 | public void testDaySelectionChange() {
87 | MonthView.OnDateChangeListener listener = mock(MonthView.OnDateChangeListener.class);
88 | monthView.setOnDateChangeListener(listener);
89 |
90 | // clear selection
91 | monthView.setSelectedDay(CalendarUtils.NO_TIME_MILLIS);
92 | verify(listener, never()).onSelectedDayChange(anyLong());
93 |
94 | // new selection outside current month, not triggered by users
95 | long selection = createDayMillis(2016, Calendar.APRIL, 1);
96 | monthView.setSelectedDay(selection);
97 | verify(listener, never()).onSelectedDayChange(anyLong());
98 |
99 | // new selection inside current month, not triggered by users
100 | selection = createDayMillis(2016, Calendar.MARCH, 1);
101 | monthView.setSelectedDay(selection);
102 | verify(listener, never()).onSelectedDayChange(anyLong());
103 |
104 | // change selection via UI interaction, triggered by users
105 | RecyclerView.ViewHolder viewHolder = createBindViewHolder(10); // 02-March-2016
106 | viewHolder.itemView.performClick();
107 | verify(listener).onSelectedDayChange(anyLong());
108 |
109 | // change selection via UI interaction, triggered by users
110 | viewHolder = createBindViewHolder(11); // 03-March-2016
111 | viewHolder.itemView.performClick();
112 | verify(listener, times(2)).onSelectedDayChange(anyLong());
113 | }
114 |
115 | @Test
116 | public void testBindSelectedDay() {
117 | // initial state
118 | CharSequence actual = ((TextView) createBindViewHolder(10).itemView)
119 | .getText(); // 02-March-2016
120 | assertThat(actual).isInstanceOf(SpannableString.class);
121 | assertThat((SpannableString) actual).doesNotHaveSpan(CircleSpan.class);
122 |
123 | // selecting day should circle it
124 | monthView.setSelectedDay(createDayMillis(2016, Calendar.MARCH, 2));
125 | actual = ((TextView) createBindViewHolder(10).itemView).getText(); // 02-March-2016
126 | assertThat(actual).isInstanceOf(SpannableString.class);
127 | assertThat((SpannableString) actual).hasSpan(CircleSpan.class);
128 | }
129 |
130 | @Test
131 | public void testSwapCursor() {
132 | TimeZone defaultTimeZone = TimeZone.getDefault();
133 | TimeZone.setDefault(TimeZone.getTimeZone("UTC"));
134 | TestEventCursor cursor = new TestEventCursor();
135 | long day14 = createDayMillis(2016, Calendar.MARCH, 14),
136 | day15 = createDayMillis(2016, Calendar.MARCH, 15),
137 | day17 = createDayMillis(2016, Calendar.MARCH, 17),
138 | day20 = createDayMillis(2016, Calendar.MARCH, 20),
139 | day21 = createDayMillis(2016, Calendar.MARCH, 21);
140 | cursor.addRow(new Object[]{1L, 1L, "Event 1", day14, day17, 0}); // multi day
141 | cursor.addRow(new Object[]{1L, 1L, "Event 1", day15, day15, 0}); // single day
142 | cursor.addRow(new Object[]{1L, 1L, "Event 1", day20, day21, 1}); // all day
143 | monthView.swapCursor(cursor);
144 | assertThat(adapter.mEvents)
145 | .hasSize(5)
146 | .contains(13, 14, 15, 16, 19);
147 |
148 | // swapping the same cursor should not alter bound events
149 | monthView.swapCursor(cursor);
150 | assertThat(adapter.mEvents)
151 | .hasSize(5)
152 | .contains(13, 14, 15, 16, 19);
153 |
154 | // swapping new cursor should rebind existing events
155 | TestEventCursor updatedCursor = new TestEventCursor();
156 | updatedCursor.addRow(new Object[]{1L, 1L, "Event 1", day20, day21, 1}); // all day
157 | monthView.swapCursor(updatedCursor);
158 | assertThat(adapter.mEvents)
159 | .hasSize(1)
160 | .contains(19);
161 |
162 | // swapping empty cursor should clear all existing events
163 | TestEventCursor emptyCursor = new TestEventCursor();
164 | monthView.swapCursor(emptyCursor);
165 | assertThat(adapter.mEvents).isEmpty();
166 | TimeZone.setDefault(defaultTimeZone);
167 | }
168 |
169 | @Test
170 | public void testBindCursor() {
171 | // initial state
172 | CharSequence actual = ((TextView) createBindViewHolder(10).itemView)
173 | .getText(); // 02-March-2016
174 | assertThat(actual).isInstanceOf(SpannableString.class);
175 | assertThat((SpannableString) actual).doesNotHaveSpan(UnderDotSpan.class);
176 |
177 | // swapping cursor should decorate it
178 | TestEventCursor cursor = new TestEventCursor();
179 | long day2 = createDayMillis(2016, Calendar.MARCH, 2);
180 | cursor.addRow(new Object[]{1L, 1L, "Event 1", day2, day2, 0});
181 | monthView.swapCursor(cursor);
182 | actual = ((TextView) createBindViewHolder(10).itemView).getText(); // 02-March-2016
183 | assertThat(actual).isInstanceOf(SpannableString.class);
184 | assertThat((SpannableString) actual).hasSpan(UnderDotSpan.class);
185 | }
186 |
187 | @After
188 | public void tearDown() {
189 | controller.pause().stop().destroy();
190 | }
191 |
192 | private RecyclerView.ViewHolder createBindViewHolder(int position) {
193 | RecyclerView.ViewHolder viewHolder = adapter.createViewHolder(monthView,
194 | adapter.getItemViewType(position));
195 | ((ShadowViewHolder) ShadowExtractor.extract(viewHolder)).adapterPosition = position;
196 | adapter.bindViewHolder((MonthView.CellViewHolder) viewHolder, position);
197 | return viewHolder;
198 | }
199 |
200 | private long createDayMillis(int year, int month, int day) {
201 | Calendar calendar = Calendar.getInstance();
202 | calendar.set(year, month, day, 0, 0, 0);
203 | return calendar.getTimeInMillis();
204 | }
205 |
206 | @SuppressLint("Registered")
207 | static class TestActivity extends AppCompatActivity {
208 | @Override
209 | protected void onCreate(@Nullable Bundle savedInstanceState) {
210 | super.onCreate(savedInstanceState);
211 | MonthView view = new MonthView(this);
212 | view.setLayoutParams(new FrameLayout.LayoutParams(
213 | ViewGroup.LayoutParams.MATCH_PARENT,
214 | ViewGroup.LayoutParams.MATCH_PARENT));
215 | view.setId(R.id.calendar_view);
216 | setContentView(view);
217 | }
218 | }
219 | }
220 |
--------------------------------------------------------------------------------
/app/src/test/java/io/github/hidroh/calendar/CalendarUtilsTest.java:
--------------------------------------------------------------------------------
1 | package io.github.hidroh.calendar;
2 |
3 | import org.junit.After;
4 | import org.junit.Before;
5 | import org.junit.Test;
6 | import org.junit.runner.RunWith;
7 | import org.robolectric.RobolectricGradleTestRunner;
8 | import org.robolectric.RuntimeEnvironment;
9 |
10 | import java.util.Calendar;
11 | import java.util.Locale;
12 | import java.util.TimeZone;
13 |
14 | import static junit.framework.Assert.assertFalse;
15 | import static junit.framework.Assert.assertTrue;
16 | import static org.assertj.core.api.Assertions.assertThat;
17 |
18 | @RunWith(RobolectricGradleTestRunner.class)
19 | public class CalendarUtilsTest {
20 | private Locale defaultLocale;
21 | private TimeZone defaultTimeZone;
22 |
23 | @Before
24 | public void setUp() {
25 | defaultLocale = Locale.getDefault();
26 | Locale.setDefault(Locale.US);
27 | defaultTimeZone = TimeZone.getDefault();
28 | TimeZone.setDefault(TimeZone.getTimeZone("Asia/Singapore"));
29 | }
30 |
31 | @Test
32 | public void testIsNotTime() {
33 | assertTrue(CalendarUtils.isNotTime(CalendarUtils.NO_TIME_MILLIS));
34 | assertFalse(CalendarUtils.isNotTime(System.currentTimeMillis()));
35 | }
36 |
37 | @Test
38 | public void testToday() {
39 | long actual = CalendarUtils.today();
40 | Calendar calendar = Calendar.getInstance();
41 | calendar.setTimeInMillis(actual);
42 | assertThat(calendar.get(Calendar.HOUR_OF_DAY)).isEqualTo(0);
43 | assertThat(calendar.get(Calendar.MINUTE)).isEqualTo(0);
44 | assertThat(calendar.get(Calendar.SECOND)).isEqualTo(0);
45 | assertThat(calendar.get(Calendar.MILLISECOND)).isEqualTo(0);
46 | }
47 |
48 | @Test
49 | public void testToDayString() {
50 | Calendar calendar = Calendar.getInstance();
51 | calendar.set(2016, Calendar.MARCH, 20);
52 | String actual = CalendarUtils.toDayString(RuntimeEnvironment.application,
53 | calendar.getTimeInMillis());
54 | assertThat(actual)
55 | .contains("Sunday")
56 | .contains("March")
57 | .contains("20");
58 | }
59 |
60 | @Test
61 | public void testToMonthString() {
62 | Calendar calendar = Calendar.getInstance();
63 | calendar.set(2016, Calendar.MARCH, 20);
64 | String actual = CalendarUtils.toMonthString(RuntimeEnvironment.application,
65 | calendar.getTimeInMillis());
66 | assertThat(actual)
67 | .doesNotContain("Sunday")
68 | .contains("March")
69 | .contains("20");
70 | }
71 |
72 | @Test
73 | public void testToTimeString() {
74 | Calendar calendar = Calendar.getInstance();
75 | calendar.set(2016, Calendar.MARCH, 20, 8, 30);
76 | String actual = CalendarUtils.toTimeString(RuntimeEnvironment.application,
77 | calendar.getTimeInMillis());
78 | assertThat(actual).contains("8:30 AM");
79 | }
80 |
81 | @Test
82 | public void testSameMonth() {
83 | Calendar march20 = Calendar.getInstance();
84 | march20.set(2016, Calendar.MARCH, 20);
85 | Calendar march30 = Calendar.getInstance();
86 | march30.set(2016, Calendar.MARCH, 30);
87 | Calendar april20 = Calendar.getInstance();
88 | april20.set(2016, Calendar.APRIL, 20);
89 | assertFalse(CalendarUtils.sameMonth(CalendarUtils.NO_TIME_MILLIS, CalendarUtils.NO_TIME_MILLIS));
90 | assertFalse(CalendarUtils.sameMonth(march20.getTimeInMillis(), CalendarUtils.NO_TIME_MILLIS));
91 | assertFalse(CalendarUtils.sameMonth(CalendarUtils.NO_TIME_MILLIS, march20.getTimeInMillis()));
92 | assertTrue(CalendarUtils.sameMonth(march20.getTimeInMillis(), march20.getTimeInMillis()));
93 | assertTrue(CalendarUtils.sameMonth(march20.getTimeInMillis(), march30.getTimeInMillis()));
94 | assertFalse(CalendarUtils.sameMonth(march20.getTimeInMillis(), april20.getTimeInMillis()));
95 | }
96 |
97 | @Test
98 | public void testMonthBefore() {
99 | Calendar march20 = Calendar.getInstance();
100 | march20.set(2016, Calendar.MARCH, 20);
101 | Calendar march30 = Calendar.getInstance();
102 | march30.set(2016, Calendar.MARCH, 30);
103 | Calendar may20 = Calendar.getInstance();
104 | may20.set(2016, Calendar.MAY, 20);
105 | assertFalse(CalendarUtils.monthBefore(CalendarUtils.NO_TIME_MILLIS, CalendarUtils.NO_TIME_MILLIS));
106 | assertFalse(CalendarUtils.monthBefore(march20.getTimeInMillis(), CalendarUtils.NO_TIME_MILLIS));
107 | assertFalse(CalendarUtils.monthBefore(CalendarUtils.NO_TIME_MILLIS, march20.getTimeInMillis()));
108 | assertFalse(CalendarUtils.monthBefore(march20.getTimeInMillis(), march20.getTimeInMillis()));
109 | assertFalse(CalendarUtils.monthBefore(march20.getTimeInMillis(), march30.getTimeInMillis()));
110 | assertTrue(CalendarUtils.monthBefore(march20.getTimeInMillis(), may20.getTimeInMillis()));
111 | assertFalse(CalendarUtils.monthBefore(may20.getTimeInMillis(), march20.getTimeInMillis()));
112 | }
113 |
114 | @Test
115 | public void testMonthAfter() {
116 | Calendar march20 = Calendar.getInstance();
117 | march20.set(2016, Calendar.MARCH, 20);
118 | Calendar march30 = Calendar.getInstance();
119 | march30.set(2016, Calendar.MARCH, 30);
120 | Calendar may20 = Calendar.getInstance();
121 | may20.set(2016, Calendar.MAY, 20);
122 | assertFalse(CalendarUtils.monthAfter(CalendarUtils.NO_TIME_MILLIS, CalendarUtils.NO_TIME_MILLIS));
123 | assertFalse(CalendarUtils.monthAfter(march20.getTimeInMillis(), CalendarUtils.NO_TIME_MILLIS));
124 | assertFalse(CalendarUtils.monthAfter(CalendarUtils.NO_TIME_MILLIS, march20.getTimeInMillis()));
125 | assertFalse(CalendarUtils.monthAfter(march20.getTimeInMillis(), march20.getTimeInMillis()));
126 | assertFalse(CalendarUtils.monthAfter(march20.getTimeInMillis(), march30.getTimeInMillis()));
127 | assertFalse(CalendarUtils.monthAfter(march20.getTimeInMillis(), may20.getTimeInMillis()));
128 | assertTrue(CalendarUtils.monthAfter(may20.getTimeInMillis(), march20.getTimeInMillis()));
129 | }
130 |
131 | @Test
132 | public void testDayOfMonth() {
133 | assertThat(CalendarUtils.dayOfMonth(CalendarUtils.NO_TIME_MILLIS)).isEqualTo(-1);
134 | Calendar march20 = Calendar.getInstance();
135 | march20.set(2016, Calendar.MARCH, 20);
136 | assertThat(CalendarUtils.dayOfMonth(march20.getTimeInMillis())).isEqualTo(20);
137 | }
138 |
139 | @Test
140 | public void testAddMonth() {
141 | assertThat(CalendarUtils.addMonths(CalendarUtils.NO_TIME_MILLIS, 1))
142 | .isEqualTo(CalendarUtils.NO_TIME_MILLIS);
143 | Calendar december1 = Calendar.getInstance();
144 | december1.set(2016, Calendar.DECEMBER, 1);
145 | Calendar january1 = Calendar.getInstance();
146 | january1.set(2017, Calendar.JANUARY, 1);
147 | long actual = CalendarUtils.addMonths(december1.getTimeInMillis(), 1);
148 | Calendar actualCalendar = Calendar.getInstance();
149 | actualCalendar.setTimeInMillis(actual);
150 | assertThat(actualCalendar.get(Calendar.YEAR)).isEqualTo(2017);
151 | assertThat(actualCalendar.get(Calendar.MONTH)).isEqualTo(Calendar.JANUARY);
152 | assertThat(actualCalendar.get(Calendar.DAY_OF_MONTH)).isEqualTo(1);
153 | }
154 |
155 | @Test
156 | public void testMonthFirstDay() {
157 | assertThat(CalendarUtils.monthFirstDay(CalendarUtils.NO_TIME_MILLIS))
158 | .isEqualTo(CalendarUtils.NO_TIME_MILLIS);
159 | Calendar march20 = Calendar.getInstance();
160 | march20.set(2016, Calendar.MARCH, 20);
161 | Calendar expected = Calendar.getInstance();
162 | expected.set(2016, Calendar.MARCH, 1);
163 | expected.set(Calendar.HOUR_OF_DAY, 0);
164 | expected.set(Calendar.MINUTE, 0);
165 | expected.set(Calendar.SECOND, 0);
166 | expected.set(Calendar.MILLISECOND, 0);
167 | assertThat(CalendarUtils.monthFirstDay(march20.getTimeInMillis()))
168 | .isEqualTo(expected.getTimeInMillis());
169 | }
170 |
171 | @Test
172 | public void testMonthLastDay() {
173 | assertThat(CalendarUtils.monthLastDay(CalendarUtils.NO_TIME_MILLIS))
174 | .isEqualTo(CalendarUtils.NO_TIME_MILLIS);
175 | Calendar march20 = Calendar.getInstance();
176 | march20.set(2016, Calendar.MARCH, 20);
177 | Calendar expected = Calendar.getInstance();
178 | expected.set(2016, Calendar.MARCH, 31);
179 | expected.set(Calendar.HOUR_OF_DAY, 0);
180 | expected.set(Calendar.MINUTE, 0);
181 | expected.set(Calendar.SECOND, 0);
182 | expected.set(Calendar.MILLISECOND, 0);
183 | assertThat(CalendarUtils.monthLastDay(march20.getTimeInMillis()))
184 | .isEqualTo(expected.getTimeInMillis());
185 | }
186 |
187 | @Test
188 | public void testMonthSize() {
189 | assertThat(CalendarUtils.monthSize(CalendarUtils.NO_TIME_MILLIS))
190 | .isEqualTo(0);
191 | Calendar march20 = Calendar.getInstance();
192 | march20.set(2016, Calendar.MARCH, 20);
193 | assertThat(CalendarUtils.monthSize(march20.getTimeInMillis()))
194 | .isEqualTo(31);
195 | }
196 |
197 | @Test
198 | public void testMonthFirstDayOffset() {
199 | assertThat(CalendarUtils.monthFirstDayOffset(CalendarUtils.NO_TIME_MILLIS))
200 | .isEqualTo(0);
201 | Calendar march = Calendar.getInstance();
202 | march.set(2016, Calendar.MARCH, 1);
203 | assertThat(CalendarUtils.monthFirstDayOffset(march.getTimeInMillis()))
204 | .isEqualTo(2); // [Sun, Mon] Tue
205 | int original = CalendarUtils.sWeekStart;
206 | CalendarUtils.sWeekStart = Calendar.SATURDAY;
207 | assertThat(CalendarUtils.monthFirstDayOffset(march.getTimeInMillis()))
208 | .isEqualTo(3); // [Sat, Sun, Mon] Tue
209 | CalendarUtils.sWeekStart = original;
210 | }
211 |
212 | @Test
213 | public void testConvertTimeZone() {
214 | long local = System.currentTimeMillis();
215 | long utc = CalendarUtils.toUtcTimeZone(local);
216 | assertThat(local).isLessThan(utc);
217 | assertThat(local).isEqualTo(CalendarUtils.toLocalTimeZone(utc));
218 | }
219 |
220 | @After
221 | public void tearDown() {
222 | Locale.setDefault(defaultLocale);
223 | TimeZone.setDefault(defaultTimeZone);
224 | }
225 | }
226 |
--------------------------------------------------------------------------------
/app/src/main/java/io/github/hidroh/calendar/widget/EventCalendarView.java:
--------------------------------------------------------------------------------
1 | package io.github.hidroh.calendar.widget;
2 |
3 | import android.content.Context;
4 | import android.database.ContentObserver;
5 | import android.os.Handler;
6 | import android.support.annotation.NonNull;
7 | import android.support.v4.view.ViewPager;
8 | import android.util.AttributeSet;
9 | import android.view.View;
10 |
11 | import io.github.hidroh.calendar.CalendarUtils;
12 | import io.github.hidroh.calendar.content.EventCursor;
13 |
14 | /**
15 | * A custom CalendarDate View, in the form of circular {@link ViewPager}
16 | * that supports month change event and state restoration.
17 | *
18 | * The {@link ViewPager} recycles adapter item views as users scroll
19 | * to first or last item.
20 | */
21 | public class EventCalendarView extends ViewPager {
22 |
23 | private final MonthView.OnDateChangeListener mDateChangeListener =
24 | new MonthView.OnDateChangeListener() {
25 | @Override
26 | public void onSelectedDayChange(long dayMillis) {
27 | // this should come from a page, only notify its neighbors
28 | mPagerAdapter.setSelectedDay(getCurrentItem(), dayMillis, false);
29 | notifyDayChange(dayMillis);
30 | }
31 | };
32 | private MonthViewPagerAdapter mPagerAdapter;
33 | private OnChangeListener mListener;
34 | private CalendarAdapter mCalendarAdapter;
35 |
36 | /**
37 | * Callback interface for calendar view change events
38 | */
39 | public interface OnChangeListener {
40 | /**
41 | * Fired when selected day has been changed via UI interaction
42 | * @param dayMillis selected day in milliseconds
43 | */
44 | void onSelectedDayChange(long dayMillis);
45 | }
46 |
47 | /**
48 | * Adapter class for loading and binding calendar events asynchronously
49 | */
50 | public static abstract class CalendarAdapter {
51 | private EventCalendarView mCalendarView;
52 |
53 | void setCalendarView(EventCalendarView calendarView) {
54 | mCalendarView = calendarView;
55 | }
56 |
57 | /**
58 | * Loads events for given month. Should call {@link #bindEvents(long, EventCursor)} on complete
59 | * @param monthMillis month in milliseconds
60 | * @see {@link #bindEvents(long, EventCursor)}
61 | */
62 | protected void loadEvents(long monthMillis) {
63 | // override to load events
64 | }
65 |
66 | /**
67 | * Binds events for given month that have been loaded via {@link #loadEvents(long)}
68 | * @param monthMillis month in milliseconds
69 | * @param cursor {@link android.provider.CalendarContract.Events} cursor wrapper
70 | */
71 | public final void bindEvents(long monthMillis, EventCursor cursor) {
72 | mCalendarView.swapCursor(monthMillis, cursor);
73 | }
74 | }
75 |
76 | public EventCalendarView(Context context) {
77 | this(context, null);
78 | }
79 |
80 | public EventCalendarView(Context context, AttributeSet attrs) {
81 | super(context, attrs);
82 | init();
83 | }
84 |
85 | @Override
86 | protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
87 | super.onMeasure(widthMeasureSpec, heightMeasureSpec);
88 | // make this ViewPager's height WRAP_CONTENT
89 | View child = mPagerAdapter.mViews.get(getCurrentItem());
90 | if (child != null) {
91 | child.measure(widthMeasureSpec, heightMeasureSpec);
92 | int height = child.getMeasuredHeight();
93 | setMeasuredDimension(getMeasuredWidth(), height);
94 | }
95 | }
96 |
97 | /**
98 | * Sets listener to be notified upon calendar view change events
99 | * @param listener listener to be notified
100 | */
101 | public void setOnChangeListener(OnChangeListener listener) {
102 | mListener = listener;
103 | }
104 |
105 | /**
106 | * Sets selected day, automatically move to next/previous month
107 | * if given day is not within active month
108 | * TODO assume that min left month < selectedDay < max right month
109 | * @param dayMillis new selected day in milliseconds
110 | */
111 | public void setSelectedDay(long dayMillis) {
112 | // notify active page and its neighbors
113 | int position = getCurrentItem();
114 | if (CalendarUtils.monthBefore(dayMillis, mPagerAdapter.mSelectedDayMillis)) {
115 | mPagerAdapter.setSelectedDay(position - 1, dayMillis, true);
116 | setCurrentItem(position - 1, true);
117 | } else if (CalendarUtils.monthAfter(dayMillis, mPagerAdapter.mSelectedDayMillis)) {
118 | mPagerAdapter.setSelectedDay(position + 1, dayMillis, true);
119 | setCurrentItem(position + 1, true);
120 | } else {
121 | mPagerAdapter.setSelectedDay(position, dayMillis, true);
122 | }
123 | }
124 |
125 | /**
126 | * Sets adapter for calendar events
127 | * {@link #deactivate()} should be called when appropriate
128 | * to deactivate active data bindings
129 | * @param adapter calendar events adapter
130 | * @see {@link #deactivate()}
131 | */
132 | public void setCalendarAdapter(@NonNull CalendarAdapter adapter) {
133 | mCalendarAdapter = adapter;
134 | mCalendarAdapter.setCalendarView(this);
135 | loadEvents(getCurrentItem());
136 | }
137 |
138 | /**
139 | * Clears any active data bindings from adapter
140 | * @see {@link #setCalendarAdapter(CalendarAdapter)}
141 | */
142 | public void deactivate() {
143 | mPagerAdapter.deactivate();
144 | }
145 |
146 | /**
147 | * Clears any active data bindings from adapter,
148 | * but keeps view state and triggers rebinding data
149 | */
150 | public void invalidateData() {
151 | mPagerAdapter.invalidate();
152 | loadEvents(getCurrentItem());
153 | }
154 |
155 | /**
156 | * Clears any active data bindings from adapter,
157 | * resets view state to initial state and triggers rebinding data
158 | */
159 | public void reset() {
160 | deactivate();
161 | init();
162 | loadEvents(getCurrentItem());
163 | }
164 |
165 | private void init() {
166 | mPagerAdapter = new MonthViewPagerAdapter(mDateChangeListener);
167 | setAdapter(mPagerAdapter);
168 | setCurrentItem(mPagerAdapter.getCount() / 2);
169 | addOnPageChangeListener(new SimpleOnPageChangeListener() {
170 | public boolean mDragging = false; // indicate if page change is from user
171 |
172 | @Override
173 | public void onPageSelected(int position) {
174 | if (mDragging) {
175 | // sequence: IDLE -> (DRAGGING) -> SETTLING -> onPageSelected -> IDLE
176 | // ensures that this will always be triggered before syncPages() for position
177 | toFirstDay(position);
178 | notifyDayChange(mPagerAdapter.getMonth(position));
179 | }
180 | mDragging = false;
181 | // trigger same scroll state changed logic, which would not be fired if not visible
182 | if (getVisibility() != VISIBLE) {
183 | onPageScrollStateChanged(SCROLL_STATE_IDLE);
184 | }
185 | }
186 |
187 | @Override
188 | public void onPageScrollStateChanged(int state) {
189 | if (state == ViewPager.SCROLL_STATE_IDLE) {
190 | syncPages(getCurrentItem());
191 | loadEvents(getCurrentItem());
192 | } else if (state == SCROLL_STATE_DRAGGING) {
193 | mDragging = true;
194 | }
195 | }
196 | });
197 | }
198 |
199 | private void toFirstDay(int position) {
200 | mPagerAdapter.setSelectedDay(position,
201 | CalendarUtils.monthFirstDay(mPagerAdapter.getMonth(position)), true);
202 | }
203 |
204 | private void notifyDayChange(long dayMillis) {
205 | if (mListener != null) {
206 | mListener.onSelectedDayChange(dayMillis);
207 | }
208 | }
209 |
210 | /**
211 | * shift and recycle pages if we are currently at last or first,
212 | * ensure that users can peek hidden pages on 2 sides
213 | * @param position current item position
214 | */
215 | private void syncPages(int position) {
216 | int first = 0, last = mPagerAdapter.getCount() - 1;
217 | if (position == last) {
218 | mPagerAdapter.shiftLeft();
219 | setCurrentItem(first + 1, false);
220 | } else if (position == 0) {
221 | mPagerAdapter.shiftRight();
222 | setCurrentItem(last - 1, false);
223 | } else {
224 | // rebind neighbours due to shifting
225 | if (position > 0) {
226 | mPagerAdapter.bind(position - 1);
227 | }
228 | if (position < mPagerAdapter.getCount() - 1) {
229 | mPagerAdapter.bind(position + 1);
230 | }
231 | }
232 | }
233 |
234 | private void loadEvents(int position) {
235 | if (mCalendarAdapter != null && mPagerAdapter.getCursor(position) == null) {
236 | mCalendarAdapter.loadEvents(mPagerAdapter.getMonth(position));
237 | }
238 | }
239 |
240 | private void swapCursor(long monthMillis, EventCursor cursor) {
241 | mPagerAdapter.swapCursor(monthMillis, cursor, new PagerContentObserver(monthMillis));
242 | }
243 |
244 | class PagerContentObserver extends ContentObserver {
245 |
246 | private final long monthMillis;
247 |
248 | public PagerContentObserver(long monthMillis) {
249 | super(new Handler());
250 | this.monthMillis = monthMillis;
251 | }
252 |
253 | @Override
254 | public boolean deliverSelfNotifications() {
255 | return true;
256 | }
257 |
258 | @Override
259 | public void onChange(boolean selfChange) {
260 | // invalidate previous cursor for given month
261 | mPagerAdapter.swapCursor(monthMillis, null, null);
262 | // reload events if given month is active month
263 | // hidden months will be reloaded upon being swiped to
264 | if (CalendarUtils.sameMonth(monthMillis, mPagerAdapter.getMonth(getCurrentItem()))) {
265 | loadEvents(getCurrentItem());
266 | }
267 | }
268 | }
269 | }
270 |
--------------------------------------------------------------------------------
/app/src/main/java/io/github/hidroh/calendar/weather/WeatherSyncService.java:
--------------------------------------------------------------------------------
1 | package io.github.hidroh.calendar.weather;
2 |
3 | import android.Manifest;
4 | import android.app.AlarmManager;
5 | import android.app.IntentService;
6 | import android.app.PendingIntent;
7 | import android.content.Context;
8 | import android.content.Intent;
9 | import android.content.SharedPreferences;
10 | import android.content.pm.PackageManager;
11 | import android.location.Location;
12 | import android.location.LocationManager;
13 | import android.os.Handler;
14 | import android.os.Looper;
15 | import android.preference.PreferenceManager;
16 | import android.support.annotation.Nullable;
17 | import android.support.annotation.VisibleForTesting;
18 | import android.support.v4.content.ContextCompat;
19 | import android.text.TextUtils;
20 | import android.text.format.DateUtils;
21 | import android.widget.Toast;
22 |
23 | import java.io.IOException;
24 | import java.util.Calendar;
25 |
26 | import io.github.hidroh.calendar.BuildConfig;
27 | import io.github.hidroh.calendar.CalendarUtils;
28 | import io.github.hidroh.calendar.R;
29 | import retrofit2.Call;
30 | import retrofit2.Retrofit;
31 | import retrofit2.converter.gson.GsonConverterFactory;
32 | import retrofit2.http.GET;
33 | import retrofit2.http.Path;
34 |
35 | /**
36 | * Background service that syncs weather information with remote source,
37 | * persists information into {@link SharedPreferences},
38 | * and automatically schedules itself to repeat every 24h
39 | */
40 | public class WeatherSyncService extends IntentService {
41 |
42 | /**
43 | * {@link SharedPreferences} string that packs previously synced weather for today
44 | * This will be updated every 24h
45 | */
46 | public static final String PREF_WEATHER_TODAY = "weatherToday";
47 | /**
48 | * {@link SharedPreferences} string that packs previously synced weather for tomorrow
49 | * This will be updated every 24h
50 | */
51 | public static final String PREF_WEATHER_TOMORROW = "weatherTomorrow";
52 | /**
53 | * {@link SharedPreferences} boolean that contains preference for showing weather
54 | */
55 | public static final String PREF_WEATHER_ENABLED = "weatherEnabled";
56 | public static final String TAG = WeatherSyncService.class.getName();
57 | private static final int[] HOUR_INDICES = new int[]{8, 14, 20};
58 | private static final String SEPARATOR = "|";
59 | // indicate if service is trigger while UI is active, not from alarm
60 | private static final String EXTRA_ACTIVE = "extra:active";
61 |
62 | private ForecastIOService mForecastService;
63 |
64 | /**
65 | * Gets previously synced weather information, triggers a new fetch if never synced
66 | * @param context context
67 | * @return previously synced weather information or null if never synced
68 | */
69 | @Nullable
70 | public static Weather getSyncedWeather(Context context) {
71 | SharedPreferences sp = PreferenceManager.getDefaultSharedPreferences(context);
72 | String[] today =unpack(sp.getString(PREF_WEATHER_TODAY, null)),
73 | tomorrow = unpack(sp.getString(PREF_WEATHER_TOMORROW, null));
74 | // initiate a new remote fetch if some sync data are missing
75 | if (today == null || tomorrow == null) {
76 | Toast.makeText(context, R.string.updating_weather, Toast.LENGTH_SHORT).show();
77 | context.startService(new Intent(context, WeatherSyncService.class)
78 | .putExtra(EXTRA_ACTIVE, true));
79 | return null;
80 | } else {
81 | return new Weather(today, tomorrow);
82 | }
83 | }
84 |
85 | /**
86 | * Packs retrieved forecast into string for storing into {@link SharedPreferences},
87 | * that can be later unpacked via {@link #unpack(String)}
88 | * @param forecast retrieved forecast
89 | * @return packed forecast string, or null if forecast is unavailable
90 | * @see {@link #unpack(String)}
91 | */
92 | private static String pack(ForecastIOService.Forecast forecast) {
93 | if (forecast == null || forecast.hourly == null ||
94 | forecast.hourly.data == null || forecast.hourly.data.length == 0) {
95 | return null;
96 | }
97 | StringBuilder sb = new StringBuilder();
98 | for (int i = 0; i < HOUR_INDICES.length; i++) {
99 | int hourIndex = HOUR_INDICES[i];
100 | if (hourIndex >= forecast.hourly.data.length ||
101 | forecast.hourly.data[hourIndex] == null) {
102 | sb.append(SEPARATOR);
103 | } else {
104 | sb.append(forecast.hourly.data[hourIndex].icon)
105 | .append(SEPARATOR)
106 | .append(forecast.hourly.data[hourIndex].temperature);
107 | }
108 | if (i < HOUR_INDICES.length - 1) {
109 | sb.append(SEPARATOR);
110 | }
111 | }
112 | return sb.toString();
113 | }
114 |
115 | /**
116 | * Unpacks a packed forecast string into an array of information
117 | * that has been previously packed via {@link #pack(ForecastIOService.Forecast)}
118 | * to construct a {@link Weather} instance
119 | * @param string packed forecast string
120 | * @return unpacked information, or null if no valid packed forecast string
121 | * @see {@link #pack(ForecastIOService.Forecast)}
122 | */
123 | private static String[] unpack(String string) {
124 | if (TextUtils.isEmpty(string)) {
125 | return null;
126 | }
127 | return string.split("\\" + WeatherSyncService.SEPARATOR, -1);
128 | }
129 |
130 | public WeatherSyncService() {
131 | super(TAG);
132 | }
133 |
134 | @Override
135 | protected void onHandleIntent(Intent intent) {
136 | cancelScheduledAlarm();
137 | boolean enabled = PreferenceManager.getDefaultSharedPreferences(this)
138 | .getBoolean(PREF_WEATHER_ENABLED, false);
139 | if (!enabled) {
140 | persist(null, PREF_WEATHER_TODAY);
141 | persist(null, PREF_WEATHER_TOMORROW);
142 | return;
143 | }
144 | Location location = getLocation();
145 | if (location == null && intent.getBooleanExtra(EXTRA_ACTIVE, false)) {
146 | notifyLocationError();
147 | }
148 | long todaySeconds = CalendarUtils.today() / DateUtils.SECOND_IN_MILLIS,
149 | tomorrowSeconds = todaySeconds + DateUtils.DAY_IN_MILLIS / DateUtils.SECOND_IN_MILLIS;
150 | persist(fetchForecast(location, todaySeconds), PREF_WEATHER_TODAY);
151 | persist(fetchForecast(location, tomorrowSeconds), PREF_WEATHER_TOMORROW);
152 | scheduleAlarm();
153 | }
154 |
155 | private void cancelScheduledAlarm() {
156 | // cancel a previously scheduled alarm if any
157 | PendingIntent alarmIntent;
158 | if ((alarmIntent = PendingIntent.getBroadcast(this, 0,
159 | new Intent(this, WeatherSyncAlarmReceiver.class), PendingIntent.FLAG_NO_CREATE)) != null) {
160 | ((AlarmManager) getSystemService(ALARM_SERVICE)).cancel(alarmIntent);
161 | }
162 | }
163 |
164 | private void scheduleAlarm() {
165 | // schedule for update in next 24h
166 | ((AlarmManager) getSystemService(ALARM_SERVICE)).set(AlarmManager.RTC_WAKEUP,
167 | Calendar.getInstance().getTimeInMillis() + AlarmManager.INTERVAL_DAY,
168 | PendingIntent.getBroadcast(this, 0,
169 | new Intent(this, WeatherSyncAlarmReceiver.class), 0));
170 | }
171 |
172 | private void notifyLocationError() {
173 | new Handler(Looper.getMainLooper()).post(new Runnable() {
174 | @Override
175 | public void run() {
176 | Toast.makeText(WeatherSyncService.this, R.string.error_location,
177 | Toast.LENGTH_SHORT).show();
178 | }
179 | });
180 | }
181 |
182 | @VisibleForTesting
183 | @Nullable
184 | protected Location getLocation() {
185 | Location location = null;
186 | // try network provider first
187 | if (ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_COARSE_LOCATION) ==
188 | PackageManager.PERMISSION_GRANTED) {
189 | location = ((LocationManager) getSystemService(LOCATION_SERVICE))
190 | .getLastKnownLocation(LocationManager.NETWORK_PROVIDER);
191 | }
192 | // if not available try GPS provider
193 | if (location == null && ContextCompat.checkSelfPermission(this,
194 | Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED) {
195 | location = ((LocationManager) getSystemService(LOCATION_SERVICE))
196 | .getLastKnownLocation(LocationManager.GPS_PROVIDER);
197 | }
198 | return location;
199 | }
200 |
201 | @VisibleForTesting
202 | protected ForecastIOService getForecastService() {
203 | if (mForecastService == null) {
204 | mForecastService = new Retrofit.Builder()
205 | .baseUrl(ForecastIOService.BASE_URL)
206 | .addConverterFactory(GsonConverterFactory.create())
207 | .build()
208 | .create(ForecastIOService.class);
209 | }
210 | return mForecastService;
211 | }
212 |
213 | private ForecastIOService.Forecast fetchForecast(Location location, long timeSeconds) {
214 | if (location == null) {
215 | return null;
216 | }
217 | try {
218 | return getForecastService()
219 | .forecast(location.getLatitude(), location.getLongitude(), timeSeconds)
220 | .execute()
221 | .body();
222 | } catch (IOException e) {
223 | return null;
224 | }
225 | }
226 |
227 | private void persist(ForecastIOService.Forecast forecast, String preferenceKey) {
228 | PreferenceManager.getDefaultSharedPreferences(this)
229 | .edit()
230 | .putString(preferenceKey, pack(forecast))
231 | .apply();
232 | }
233 |
234 | interface ForecastIOService {
235 | String BASE_URL = "https://api.forecast.io/";
236 |
237 | @GET("forecast/" + BuildConfig.FORECAST_IO_API_KEY +
238 | "/{latitude},{longitude},{time}?exclude=currently,daily,flags")
239 | Call forecast(@Path("latitude") double latitude,
240 | @Path("longitude") double longitude,
241 | @Path("time") long timeSeconds);
242 |
243 | class Forecast {
244 | Hourly hourly;
245 | }
246 |
247 | class Hourly {
248 | DataPoint[] data;
249 | }
250 |
251 | class DataPoint {
252 | String icon;
253 | float temperature;
254 | }
255 | }
256 | }
257 |
--------------------------------------------------------------------------------
/app/src/main/java/io/github/hidroh/calendar/widget/AgendaView.java:
--------------------------------------------------------------------------------
1 | package io.github.hidroh.calendar.widget;
2 |
3 | import android.content.Context;
4 | import android.graphics.Canvas;
5 | import android.graphics.Paint;
6 | import android.graphics.PointF;
7 | import android.graphics.Rect;
8 | import android.os.Bundle;
9 | import android.os.Parcelable;
10 | import android.support.annotation.Nullable;
11 | import android.support.v4.content.ContextCompat;
12 | import android.support.v7.widget.LinearLayoutManager;
13 | import android.support.v7.widget.LinearSmoothScroller;
14 | import android.support.v7.widget.RecyclerView;
15 | import android.util.AttributeSet;
16 | import android.view.View;
17 |
18 | import io.github.hidroh.calendar.CalendarUtils;
19 | import io.github.hidroh.calendar.R;
20 | import io.github.hidroh.calendar.ViewUtils;
21 | import io.github.hidroh.calendar.weather.Weather;
22 |
23 | public class AgendaView extends RecyclerView {
24 | private static final String STATE_VIEW = "state:view";
25 | private static final String STATE_ADAPTER = "state:adapter";
26 |
27 | private OnDateChangeListener mListener;
28 | private AgendaAdapter mAdapter;
29 | // represent top scroll position to be set programmatically
30 | private int mPendingScrollPosition = NO_POSITION;
31 | private long mPrevTimeMillis = CalendarUtils.NO_TIME_MILLIS;
32 | private Bundle mAdapterSavedState;
33 | private final int[] mColors;
34 |
35 | /**
36 | * Callback interface for active (top) date change event
37 | */
38 | public interface OnDateChangeListener {
39 | /**
40 | * Fired when active (top) date has been changed via UI interaction
41 | * @param dayMillis new active (top) day in milliseconds
42 | */
43 | void onSelectedDayChange(long dayMillis);
44 | }
45 |
46 | public AgendaView(Context context) {
47 | this(context, null);
48 | }
49 |
50 | public AgendaView(Context context, @Nullable AttributeSet attrs) {
51 | this(context, attrs, 0);
52 | }
53 |
54 | public AgendaView(Context context, @Nullable AttributeSet attrs, int defStyle) {
55 | super(context, attrs, defStyle);
56 | init();
57 | if (isInEditMode()) {
58 | mColors = new int[]{ContextCompat.getColor(context, android.R.color.transparent)};
59 | setAdapter(new AgendaAdapter(context) {});
60 | } else {
61 | mColors = ViewUtils.getCalendarColors(context);
62 | }
63 | }
64 |
65 | @Override
66 | protected Parcelable onSaveInstanceState() {
67 | Bundle outState = new Bundle();
68 | outState.putParcelable(STATE_VIEW, super.onSaveInstanceState());
69 | if (mAdapter != null) {
70 | outState.putBundle(STATE_ADAPTER, mAdapter.saveState());
71 | }
72 | return outState;
73 | }
74 |
75 | @Override
76 | protected void onRestoreInstanceState(Parcelable state) {
77 | Bundle savedState = (Bundle) state;
78 | mAdapterSavedState = savedState.getBundle(STATE_ADAPTER);
79 | super.onRestoreInstanceState(savedState.getParcelable(STATE_VIEW));
80 | }
81 |
82 | @Override
83 | public void onScrolled(int dx, int dy) {
84 | if (dy != 0) { // avoid loading more or triggering notification on 1st layout
85 | loadMore();
86 | notifyDateChange();
87 | }
88 | }
89 |
90 | @Override
91 | public void onScrollStateChanged(int state) {
92 | super.onScrollStateChanged(state);
93 | if (state == SCROLL_STATE_IDLE && mPendingScrollPosition != NO_POSITION) {
94 | mPendingScrollPosition = NO_POSITION; // clear pending
95 | mAdapter.unlockBinding();
96 | }
97 | }
98 |
99 | @Override
100 | public void setAdapter(Adapter adapter) {
101 | if (adapter != null && !(adapter instanceof AgendaAdapter)) {
102 | throw new IllegalArgumentException("Adapter must be an instance of AgendaAdapter");
103 | }
104 | mAdapter = (AgendaAdapter) adapter;
105 | if (mAdapter != null) {
106 | if (mAdapterSavedState != null) {
107 | mAdapter.restoreState(mAdapterSavedState);
108 | mAdapterSavedState = null;
109 | } else {
110 | mAdapter.append(getContext());
111 | getLinearLayoutManager().scrollToPosition(mAdapter.getItemCount() / 2);
112 | }
113 | mAdapter.setCalendarColors(mColors);
114 | }
115 | super.setAdapter(mAdapter);
116 | }
117 |
118 | /**
119 | * Sets listener to be notified when active (top) date in agenda changes
120 | * @param listener listener to be notified
121 | */
122 | public void setOnDateChangeListener(OnDateChangeListener listener) {
123 | mListener = listener;
124 | }
125 |
126 | /**
127 | * Sets active (top) date to be displayed
128 | * @param dayMillis new active (top) date in milliseconds
129 | */
130 | public void setSelectedDay(long dayMillis) {
131 | if (mAdapter == null) {
132 | return;
133 | }
134 | mPendingScrollPosition = mAdapter.getPosition(getContext(), dayMillis);
135 | if (mPendingScrollPosition >= 0) {
136 | // lock binding to prevent loading events that might offset scroll position
137 | mAdapter.lockBinding();
138 | smoothScrollToPosition(mPendingScrollPosition);
139 | }
140 | }
141 |
142 | /**
143 | * Sets weather information to be displayed
144 | * @param weather weather information to be displayed, or null to disable
145 | */
146 | public void setWeather(@Nullable Weather weather) {
147 | if (mAdapter != null) {
148 | mAdapter.setWeather(weather);
149 | }
150 | }
151 |
152 | /**
153 | * Clears previous bindings if any, resets view to initial state and triggers rebinding data
154 | */
155 | public void reset() {
156 | // clear view state
157 | mPendingScrollPosition = NO_POSITION;
158 | mPrevTimeMillis = CalendarUtils.NO_TIME_MILLIS;
159 | mAdapterSavedState = null;
160 | if (mAdapter != null) {
161 | int originalCount = mAdapter.getItemCount();
162 | mAdapter.lockBinding();
163 | mAdapter.deactivate();
164 | mAdapter.notifyItemRangeRemoved(0, originalCount);
165 | mAdapter.append(getContext());
166 | mAdapter.notifyItemRangeInserted(0, mAdapter.getItemCount());
167 | setSelectedDay(CalendarUtils.today());
168 | }
169 | }
170 |
171 | /**
172 | * Clears previous bindings if any, but keeps view state and triggers rebinding data
173 | */
174 | public void invalidateData() {
175 | if (mAdapter != null) {
176 | mAdapter.invalidate();
177 | }
178 | }
179 |
180 | private void init() {
181 | setHasFixedSize(false);
182 | setLayoutManager(new AgendaLinearLayoutManager(getContext()));
183 | addItemDecoration(new DividerItemDecoration(getContext()));
184 | setItemAnimator(null);
185 | }
186 |
187 | private LinearLayoutManager getLinearLayoutManager() {
188 | return (LinearLayoutManager) getLayoutManager();
189 | }
190 |
191 | void loadMore() {
192 | if (mAdapter == null) {
193 | return;
194 | }
195 | if (getLinearLayoutManager().findFirstVisibleItemPosition() == 0) {
196 | // once prepended first visible position will no longer be 0
197 | // which will negate the guard check
198 | mAdapter.prepend(getContext());
199 | } else if (getLinearLayoutManager().findLastVisibleItemPosition()
200 | == mAdapter.getItemCount() - 1) {
201 | // once appended last visible position will no longer be last adapter position
202 | // which will negate the guard check
203 | mAdapter.append(getContext());
204 | }
205 | }
206 |
207 | private void notifyDateChange() {
208 | int position = getLinearLayoutManager().findFirstVisibleItemPosition();
209 | if (position < 0) {
210 | return;
211 | }
212 | long timeMillis = mAdapter.getAdapterItem(position).mTimeMillis;
213 | if (mPrevTimeMillis != timeMillis) {
214 | mPrevTimeMillis = timeMillis;
215 | // only notify listener if scroll is not triggered programmatically (i.e. no pending)
216 | if (mPendingScrollPosition == NO_POSITION && mListener != null) {
217 | mListener.onSelectedDayChange(timeMillis);
218 | }
219 | }
220 | }
221 |
222 | static class DividerItemDecoration extends ItemDecoration {
223 | private final Paint mPaint;
224 | private final int mSize;
225 |
226 | public DividerItemDecoration(Context context) {
227 | mSize = context.getResources().getDimensionPixelSize(R.dimen.divider_size);
228 | mPaint = new Paint();
229 | mPaint.setColor(ContextCompat.getColor(context, R.color.colorDivider));
230 | mPaint.setStrokeWidth(mSize);
231 | }
232 |
233 | @Override
234 | public void onDrawOver(Canvas c, RecyclerView parent, State state) {
235 | int top, left = 0, right = parent.getMeasuredWidth();
236 | for (int i = 0; i < parent.getChildCount(); i++) {
237 | top = parent.getChildAt(i).getTop() - mSize / 2;
238 | c.drawLine(left, top, right, top, mPaint);
239 | }
240 | }
241 |
242 | @Override
243 | public void getItemOffsets(Rect outRect, View view, RecyclerView parent, State state) {
244 | if (parent.getChildAdapterPosition(view) > 0) {
245 | outRect.top = mSize;
246 | }
247 | }
248 | }
249 |
250 | /**
251 | * Light extension to {@link LinearLayoutManager} that overrides smooth scroller to
252 | * always snap to start
253 | */
254 | static class AgendaLinearLayoutManager extends LinearLayoutManager {
255 |
256 | public AgendaLinearLayoutManager(Context context) {
257 | super(context);
258 | }
259 |
260 | @Override
261 | public void smoothScrollToPosition(RecyclerView recyclerView,
262 | RecyclerView.State state,
263 | int position) {
264 | RecyclerView.SmoothScroller smoothScroller =
265 | new LinearSmoothScroller(recyclerView.getContext()) {
266 | @Override
267 | public PointF computeScrollVectorForPosition(int targetPosition) {
268 | return AgendaLinearLayoutManager.this
269 | .computeScrollVectorForPosition(targetPosition);
270 | }
271 |
272 | @Override
273 | protected int getVerticalSnapPreference() {
274 | return SNAP_TO_START; // override base class behavior
275 | }
276 | };
277 | smoothScroller.setTargetPosition(position);
278 | startSmoothScroll(smoothScroller);
279 | }
280 | }
281 | }
282 |
--------------------------------------------------------------------------------
/app/src/test/java/io/github/hidroh/calendar/widget/EventCalendarViewTest.java:
--------------------------------------------------------------------------------
1 | package io.github.hidroh.calendar.widget;
2 |
3 | import android.annotation.SuppressLint;
4 | import android.os.Bundle;
5 | import android.support.annotation.Nullable;
6 | import android.support.v7.app.AppCompatActivity;
7 | import android.text.format.DateUtils;
8 | import android.view.ViewGroup;
9 | import android.widget.FrameLayout;
10 |
11 | import org.junit.After;
12 | import org.junit.Before;
13 | import org.junit.Test;
14 | import org.junit.runner.RunWith;
15 | import org.robolectric.Robolectric;
16 | import org.robolectric.RobolectricGradleTestRunner;
17 | import org.robolectric.annotation.Config;
18 | import org.robolectric.internal.ShadowExtractor;
19 | import org.robolectric.util.ActivityController;
20 |
21 | import io.github.hidroh.calendar.CalendarUtils;
22 | import io.github.hidroh.calendar.R;
23 | import io.github.hidroh.calendar.test.TestEventCursor;
24 | import io.github.hidroh.calendar.test.shadows.ShadowViewPager;
25 |
26 | import static io.github.hidroh.calendar.test.assertions.DayTimeAssert.assertThat;
27 | import static junit.framework.Assert.assertFalse;
28 | import static junit.framework.Assert.assertTrue;
29 | import static org.assertj.android.api.Assertions.assertThat;
30 | import static org.mockito.Matchers.anyLong;
31 | import static org.mockito.Mockito.mock;
32 | import static org.mockito.Mockito.verify;
33 |
34 | @SuppressWarnings("ConstantConditions")
35 | @Config(shadows = ShadowViewPager.class)
36 | @RunWith(RobolectricGradleTestRunner.class)
37 | public class EventCalendarViewTest {
38 | private ActivityController controller;
39 | private EventCalendarView calendarView;
40 | private ShadowViewPager shadowCalendarView;
41 | private long todayMillis = CalendarUtils.today();
42 |
43 | @Before
44 | public void setUp() {
45 | controller = Robolectric.buildActivity(TestActivity.class);
46 | TestActivity activity = controller.create().start().resume().visible().get();
47 | calendarView = (EventCalendarView) activity.findViewById(R.id.calendar_view);
48 | shadowCalendarView = (ShadowViewPager) ShadowExtractor.extract(calendarView);
49 | }
50 |
51 | @Test
52 | public void testMonthData() {
53 | // initial state: 1 active, 2 hidden and 2 uninitialized
54 | assertThat(calendarView).hasChildCount(3);
55 | assertThat(calendarView.getChildAt(0)).isInstanceOf(MonthView.class);
56 | assertThat(getMonthAt(2))
57 | .isInSameMonthAs(todayMillis)
58 | .isMonthsAfter(getMonthAt(1), 1)
59 | .isMonthsBefore(getMonthAt(3), 1);
60 | }
61 |
62 | @Test
63 | public void testSwipeLeftChangeMonth() {
64 | // initial state
65 | long expected = CalendarUtils.today();
66 | int actual = calendarView.getCurrentItem();
67 | assertThat(actual).isEqualTo(2);
68 | assertThat(getMonthAt(actual))
69 | .isInSameMonthAs(expected);
70 |
71 | // swipe left, no shifting
72 | // changing month by swiping should automatically set selected day to 1st day
73 | expected = CalendarUtils.addMonths(expected, 1);
74 | shadowCalendarView.swipeLeft();
75 | actual = calendarView.getCurrentItem();
76 | assertThat(actual).isEqualTo(3);
77 | assertThat(getMonthAt(actual))
78 | .isInSameMonthAs(expected)
79 | .isMonthsBefore(getMonthAt(actual + 1), 1)
80 | .isMonthsAfter(getMonthAt(actual - 1), 1)
81 | .isFirstDayOf(expected);
82 |
83 | // swipe left, reach the end, should shift left to front
84 | expected = CalendarUtils.addMonths(expected, 1);
85 | shadowCalendarView.swipeLeft();
86 | actual = calendarView.getCurrentItem();
87 | assertThat(actual).isEqualTo(1);
88 | assertThat(getMonthAt(actual))
89 | .isInSameMonthAs(expected)
90 | .isMonthsBefore(getMonthAt(actual + 1), 1)
91 | .isMonthsAfter(getMonthAt(actual - 1), 1);
92 | }
93 |
94 | @Test
95 | public void testSwipeRightChangeMonth() {
96 | // initial state
97 | long expected = CalendarUtils.today();
98 | int actual = calendarView.getCurrentItem();
99 | assertThat(actual).isEqualTo(2);
100 | assertThat(getMonthAt(actual))
101 | .isInSameMonthAs(expected);
102 |
103 | // swipe right, no shifting
104 | // changing month by swiping should automatically set selected day to 1st day
105 | expected = CalendarUtils.addMonths(expected, -1);
106 | shadowCalendarView.swipeRight();
107 | actual = calendarView.getCurrentItem();
108 | assertThat(actual).isEqualTo(1);
109 | assertThat(getMonthAt(actual))
110 | .isInSameMonthAs(expected)
111 | .isMonthsBefore(getMonthAt(actual + 1), 1)
112 | .isMonthsAfter(getMonthAt(actual - 1), 1)
113 | .isFirstDayOf(expected);
114 |
115 | // swipe right, reach the end, should shift right to end
116 | expected = CalendarUtils.addMonths(expected, -1);
117 | shadowCalendarView.swipeRight();
118 | actual = calendarView.getCurrentItem();
119 | assertThat(actual).isEqualTo(3);
120 | assertThat(getMonthAt(actual))
121 | .isInSameMonthAs(expected)
122 | .isMonthsBefore(getMonthAt(actual + 1), 1)
123 | .isMonthsAfter(getMonthAt(actual - 1), 1);
124 | }
125 |
126 | @Test
127 | public void testChangeActiveMonthSelectedDay() {
128 | // setting day in same month should not change page
129 | long firstDay = CalendarUtils.monthFirstDay(todayMillis);
130 | calendarView.setSelectedDay(firstDay);
131 | assertThat(getMonthAt(calendarView.getCurrentItem()))
132 | .isInSameMonthAs(todayMillis);
133 |
134 | // setting day in same month should not change page
135 | long lastDay = CalendarUtils.monthLastDay(todayMillis);
136 | calendarView.setSelectedDay(lastDay);
137 | assertThat(getMonthAt(calendarView.getCurrentItem()))
138 | .isInSameMonthAs(todayMillis);
139 |
140 | // setting day in same month should not change page
141 | long middleDay = firstDay + DateUtils.DAY_IN_MILLIS * 15;
142 | calendarView.setSelectedDay(middleDay);
143 | assertThat(getMonthAt(calendarView.getCurrentItem()))
144 | .isInSameMonthAs(todayMillis);
145 | }
146 |
147 | @Test
148 | public void testChangeSelectedDayToPreviousMonth() {
149 | long middleDayPrevMonth = CalendarUtils.addMonths(
150 | CalendarUtils.monthFirstDay(todayMillis), -1) + DateUtils.DAY_IN_MILLIS * 15;
151 |
152 | // setting day in previous month should swipe to left page
153 | // changing month programmatically should NOT automatically set selected day to 1st day
154 | calendarView.setSelectedDay(middleDayPrevMonth);
155 | assertThat(getSelectedDay())
156 | .isInSameMonthAs(middleDayPrevMonth)
157 | .isNotFirstDayOf(middleDayPrevMonth);
158 | }
159 |
160 | @Test
161 | public void testChangeSelectedDayToNextMonth() {
162 | long middleDayNextMonth = CalendarUtils.addMonths(
163 | CalendarUtils.monthFirstDay(todayMillis), 1) + DateUtils.DAY_IN_MILLIS * 15;
164 |
165 | // setting day in next month should swipe to right page
166 | // changing month programmatically should NOT automatically set selected day to 1st day
167 | calendarView.setSelectedDay(middleDayNextMonth);
168 | assertThat(getSelectedDay())
169 | .isInSameMonthAs(middleDayNextMonth)
170 | .isNotFirstDayOf(middleDayNextMonth);
171 | }
172 |
173 | @Test
174 | public void testNotifyListener() {
175 | EventCalendarView.OnChangeListener listener = mock(EventCalendarView.OnChangeListener.class);
176 | calendarView.setOnChangeListener(listener);
177 |
178 | // swiping to change page, should generate notification
179 | shadowCalendarView.swipeLeft();
180 | verify(listener).onSelectedDayChange(anyLong());
181 |
182 | // changing month programmatically, should not generate notification
183 | calendarView.setSelectedDay(todayMillis);
184 | verify(listener).onSelectedDayChange(anyLong());
185 |
186 | // TODO test changing day from month view, should generate notification
187 | }
188 |
189 | @Test
190 | public void testBindCursor() {
191 | // setting calendar adapter should load and bind cursor
192 | TestEventCursor cursor = new TestEventCursor();
193 | cursor.addRow(new Object[]{1L, 1L, "Event 1", todayMillis, todayMillis, 0});
194 | TestCalendarAdapter testAdapter = new TestCalendarAdapter();
195 | testAdapter.cursor = cursor;
196 | calendarView.setCalendarAdapter(testAdapter);
197 | assertThat(cursor).isNotClosed();
198 | assertTrue(cursor.hasContentObserver());
199 |
200 | // deactivating should close cursor and unregister content observer
201 | calendarView.deactivate();
202 | assertThat(cursor).isClosed();
203 | assertFalse(cursor.hasContentObserver());
204 | }
205 |
206 | @Test
207 | public void testCursorContentChange() {
208 | // setting calendar adapter should load and bind cursor
209 | TestEventCursor cursor = new TestEventCursor();
210 | cursor.addRow(new Object[]{1L, 1L, "Event 1", todayMillis, todayMillis, 0});
211 | TestCalendarAdapter testAdapter = new TestCalendarAdapter();
212 | testAdapter.cursor = cursor;
213 | calendarView.setCalendarAdapter(testAdapter);
214 | assertThat(cursor).isNotClosed();
215 | assertTrue(cursor.hasContentObserver());
216 |
217 | // content change should load and bind new cursor, deactivate existing cursor
218 | TestEventCursor updatedCursor = new TestEventCursor();
219 | testAdapter.cursor = updatedCursor;
220 | cursor.notifyContentChange(false);
221 | assertThat(cursor).isClosed();
222 | assertFalse(cursor.hasContentObserver());
223 | assertThat(updatedCursor).isNotClosed();
224 | assertTrue(updatedCursor.hasContentObserver());
225 | }
226 |
227 | @After
228 | public void tearDown() {
229 | controller.pause().stop().destroy();
230 | }
231 |
232 | private long getMonthAt(int position) {
233 | return ((MonthViewPagerAdapter) calendarView.getAdapter())
234 | .mViews.get(position).mMonthMillis;
235 | }
236 |
237 | private long getSelectedDay() {
238 | return ((MonthViewPagerAdapter) calendarView.getAdapter()).mSelectedDayMillis;
239 | }
240 |
241 | @SuppressLint("Registered")
242 | static class TestActivity extends AppCompatActivity {
243 | @Override
244 | protected void onCreate(@Nullable Bundle savedInstanceState) {
245 | super.onCreate(savedInstanceState);
246 | EventCalendarView calendarView = new EventCalendarView(this);
247 | calendarView.setId(R.id.calendar_view);
248 | calendarView.setLayoutParams(new FrameLayout.LayoutParams(
249 | ViewGroup.LayoutParams.MATCH_PARENT,
250 | ViewGroup.LayoutParams.MATCH_PARENT));
251 | setContentView(calendarView);
252 | }
253 | }
254 |
255 | static class TestCalendarAdapter extends EventCalendarView.CalendarAdapter {
256 | TestEventCursor cursor = new TestEventCursor();
257 |
258 | @Override
259 | protected void loadEvents(long monthMillis) {
260 | bindEvents(monthMillis, cursor);
261 | }
262 | }
263 | }
264 |
--------------------------------------------------------------------------------