public class Basics extends BroadcastReceiver {

    private Context context = null;
    private SharedPreferences preferences = null;
    private Uri documentTree = null;
    private DAO dao = null;
    private TimerManager timerManager = null;
    private TimeCalculator timeCalculator = null;
    private ExternalNotificationManager externalNotificationManager = null;
    private NotificationChannel notificationChannel = null;
    private NotificationChannel serviceNotificationChannel = null;
    private ThirdPartyReceiver thirdPartyReceiver;

    private static Basics instance = null;

    /**
     * Creates an instance of this class.
     */
    public Basics() {
        if (instance == null) {
            instance = this;
        }
    }

    /**
     * Fetches the singleton.
     */
    public static Basics getInstance() {
        return instance;
    }

    /**
     * Creates the singleton if not already created.
     */
    public static Basics getOrCreateInstance(Context androidContext) {
        if (instance == null) {
            Logger.debug("created a new Basics instance from context {}", androidContext);
            instance = new Basics();
            instance.receivedIntent(androidContext);
        }
        return instance;
    }

    @Override
    public void onReceive(Context androidContext, Intent intent) {
        // always only use the one instance
        instance.receivedIntent(androidContext);
    }

    /**
     * Forwarding from {@link #onReceive(Context, Intent)}, but this method is only called on the singleton instance -
     * no matter how many instances Android might choose to create of this class.
     */
    public void receivedIntent(Context androidContext) {
        context = androidContext;
        init();
        schedulePeriodicIntents();
    }

    private void init() {
        preferences = PreferenceManager.getDefaultSharedPreferences(context);
        dao = new DAO(context);

        // run database migrations if needed
        dao.executePendingMigrations();

        timerManager = new TimerManager(dao, preferences, context);
        timeCalculator = new TimeCalculator(dao, timerManager);
        externalNotificationManager = new ExternalNotificationManager(context, preferences);

        initTinyLog();

        registerThirdPartyReceiver();
    }

    public void initTinyLog() {
        String threadToObserve = Thread.currentThread().getName();
        Configurator.defaultConfig()
            .writer(new RollingFileWriter(getCurrentLogFile().getPath(),
                    2, false, new CountLabeler(), new DailyPolicy()),
                Level.DEBUG, "{date:yyyy-MM-dd HH:mm:ss} {{level}|min-size=5} {class_name}.{method} - {message}")
            .addWriter(new LogcatWriter("trackworktime"), Level.DEBUG, "{message}")
            .writingThread(threadToObserve, 1)
            .activate();
        Logger.info("logger initialized - writing thread observes \"{}\"", threadToObserve);
    }

    private void registerThirdPartyReceiver() {
        if (thirdPartyReceiver != null) {
            Logger.warn("{} already registered, skipping.", ThirdPartyReceiver.class.getSimpleName());
            return;
        }

        thirdPartyReceiver = new ThirdPartyReceiver();
        IntentFilter intentFilter = new IntentFilter();
        intentFilter.addAction(Constants.CLOCK_IN_ACTION);
        intentFilter.addAction(Constants.CLOCK_OUT_ACTION);
        intentFilter.addAction(Constants.STATUS_REQUEST_ACTION);
        context.registerReceiver(thirdPartyReceiver, intentFilter);
        Logger.debug("Registered {}", ThirdPartyReceiver.class.getSimpleName());
    }

    public void unregisterThirdPartyReceiver() {
        if (thirdPartyReceiver == null) {
            Logger.warn("{} not registered, skipping.", ThirdPartyReceiver.class.getSimpleName());
            return;
        }

        context.unregisterReceiver(thirdPartyReceiver);
        thirdPartyReceiver = null;
        Logger.debug("Unregistered {}", ThirdPartyReceiver.class.getSimpleName());
    }

    public File getCurrentLogFile() {
        return new File(context.getFilesDir(), Constants.CURRENT_LOG_FILE_NAME);
    }

    public NotificationChannel getNotificationChannel() {
        return notificationChannel;
    }

    public void setNotificationChannel(NotificationChannel notificationChannel) {
        this.notificationChannel = notificationChannel;
    }

    public NotificationChannel getServiceNotificationChannel() {
        return serviceNotificationChannel;
    }

    public void setServiceNotificationChannel(NotificationChannel serviceNotificationChannel) {
        this.serviceNotificationChannel = serviceNotificationChannel;
    }

    private void schedulePeriodicIntents() {
        AlarmManager service = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE);
        Intent intentToSchedule = new Intent(context, Watchdog.class);
        PendingIntent pendingIntent = PendingIntent.getBroadcast(context, 0, intentToSchedule,
            PendingIntent.FLAG_CANCEL_CURRENT +
                (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M
                    ? PendingIntent.FLAG_IMMUTABLE
                    : 0));

        Calendar cal = Calendar.getInstance();
        // start one minute after boot completed
        cal.add(Calendar.MINUTE, 1);

        // schedule once every minute
        service.setInexactRepeating(AlarmManager.RTC_WAKEUP, cal.getTimeInMillis(), Constants.REPEAT_TIME,
            pendingIntent);
    }

    /**
     * Hook method which gets called approx. once a minute.
     */
    public void periodicHook() {
        Logger.debug("executing periodic hook");
        // first make sure that the options are consistent
        safeCheckPreferences();
        // then start the action
        safeCheckLocationBasedTracking();
        safeCheckWifiBasedTracking();
        safeCheckExternalControls();
        WorkTimeTrackerActivity.refreshViewIfShown();
    }

    /**
     * Wrapper for {@link PreferencesUtil#checkAllPreferenceSections()} that doesn't throw any exception.
     */
    private void safeCheckPreferences() {
        try {
            PreferencesUtil.checkAllPreferenceSections();
        } catch (Exception e) {
            Logger.warn(e, "exception handled by ACRA");
            ACRA.getErrorReporter().handleException(e);
        }
    }

    /**
     * Wrapper for {@link #checkLocationBasedTracking()} that doesn't throw any exception.
     */
    public void safeCheckLocationBasedTracking() {
        try {
            checkLocationBasedTracking();
        } catch (Exception e) {
            Logger.warn(e, "exception handled by ACRA");
            ACRA.getErrorReporter().handleException(e);
        }
    }

    /**
     * Wrapper for {@link #checkWifiBasedTracking()} that doesn't throw any exception.
     */
    public void safeCheckWifiBasedTracking() {
        try {
            checkWifiBasedTracking();
        } catch (Exception e) {
            Logger.warn(e, "exception handled by ACRA");
            ACRA.getErrorReporter().handleException(e);
        }
    }

    /**
     * Updates any external views, such as notifications, app widgets, etc.
     */
    public void safeCheckExternalControls() {
        safeCheckWidget();
        safeCheckPersistentNotification();
    }

    /**
     * Wrapper for {@link #checkWidget()} that doesn't throw any exception.
     */
    public void safeCheckWidget() {
        try {
            checkWidget();
        } catch (Exception e) {
            Logger.warn(e, "exception handled by ACRA");
            ACRA.getErrorReporter().handleException(e);
        }
    }

    /**
     * Dispatches refresh event to {@link Widget}
     */
    public void checkWidget() {
        Widget.dispatchUpdateIntent(context);
    }

    /**
     * Wrapper for {@link #checkPersistentNotification()} that doesn't throw any exception.
     */
    public void safeCheckPersistentNotification() {
        try {
            checkPersistentNotification();
        } catch (Exception e) {
            Logger.warn(e, "exception handled by ACRA");
            ACRA.getErrorReporter().handleException(e);
        }
    }

    /**
     * Check if persistent notification has to be displayed/updated/removed. Only works when "flexi time" enabled!
     */
    private void checkPersistentNotification() {
        Logger.debug("checking persistent notification");
        if (preferences.getBoolean(Key.NOTIFICATION_ENABLED.getName(), false)
            && timerManager.isTracking()) {
            // display/update

            Intent clickIntent = new Intent(context, WorkTimeTrackerActivity.class);
            clickIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
            clickIntent.setAction(Intent.ACTION_MAIN);
            clickIntent.addCategory(Intent.CATEGORY_LAUNCHER);
            Intent buttonOneIntent = new Intent(Constants.CLOCK_IN_ACTION);
            Intent buttonTwoIntent = new Intent(Constants.CLOCK_OUT_ACTION);

            // calculated in home time zone
            int workedTime = (int) timerManager.calculateTimeSum(LocalDate.now(), PeriodEnum.DAY);
            String timeSoFar = DateTimeUtil.formatDuration(workedTime);
            String targetTimeString = "";
            if (preferences.getBoolean(Key.ENABLE_FLEXI_TIME.getName(), false)) {
                Integer minutesRemaining = timerManager.getMinutesRemaining();

                if (minutesRemaining != null) {
                    if (minutesRemaining >= 0) {
                        // target time in future
                        LocalDateTime finishingTime = LocalDateTime.now().plusMinutes(minutesRemaining);

                        if (finishingTime.toLocalDate().isEqual(LocalDate.now())) {
                            String targetTime = DateTimeUtil.formatLocalizedTime(finishingTime);
                            targetTimeString = "possible finishing time: " + targetTime;
                        } else {
                            targetTimeString = "target working time cannot be reached today!";
                        }
                    } else {
                        // target time in past
                        targetTimeString = "regular work time is over since "
                            + TimerManager.formatTime(-minutesRemaining);
                    }
                } // else not a working day
            } else {
                // no second line displayed because no flexi time can be calculated
            }
            Logger.debug("persistent notification: worked={} possiblefinish={}", timeSoFar, targetTimeString);
            showNotification(null, "worked " + timeSoFar + " so far", targetTimeString,
                PendingIntent.getActivity(context, 0, clickIntent, PendingIntent.FLAG_UPDATE_CURRENT +
                    (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M
                        ? PendingIntent.FLAG_IMMUTABLE
                        : 0)),
                Constants.PERSISTENT_STATUS_ID, true,
                PendingIntent.getBroadcast(context, 0, buttonOneIntent, PendingIntent.FLAG_CANCEL_CURRENT +
                    (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M
                        ? PendingIntent.FLAG_IMMUTABLE
                        : 0)),
                R.drawable.ic_menu_forward, context.getString(R.string.clockInChangeShort),
                PendingIntent.getBroadcast(context, 0, buttonTwoIntent, PendingIntent.FLAG_CANCEL_CURRENT +
                    (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M
                        ? PendingIntent.FLAG_IMMUTABLE
                        : 0)),
                R.drawable.ic_menu_close_clear_cancel, context.getString(R.string.clockOutShort));
            Logger.debug("added persistent notification");
        } else {
            // try to remove
            try {
                NotificationManager notificationManager = (NotificationManager) context
                    .getSystemService(Context.NOTIFICATION_SERVICE);
                notificationManager.cancel(Constants.PERSISTENT_STATUS_ID);
                Logger.debug("removed persistent notification");
            } catch (Exception e) {
                Logger.warn(e, "could not remove persistent notification");
            }
        }
    }

    /**
     * Check if location-based tracking has to be en- or disabled.
     */
    private void checkLocationBasedTracking() {
        Logger.debug("checking location-based tracking");
        if (preferences.getBoolean(Key.LOCATION_BASED_TRACKING_ENABLED.getName(), false)) {
            String latitudeString = preferences.getString(Key.LOCATION_BASED_TRACKING_LATITUDE.getName(), "0");
            String longitudeString = preferences.getString(Key.LOCATION_BASED_TRACKING_LONGITUDE.getName(), "0");
            String toleranceString = preferences.getString(Key.LOCATION_BASED_TRACKING_TOLERANCE.getName(), "0");
            double latitude = 0.0;
            boolean valuesParsable = true;

            try {
                latitude = Double.parseDouble(latitudeString);
            } catch (NumberFormatException nfe) {
                Logger.warn("could not parse latitude: {}", latitudeString);
                valuesParsable = false;
            }
            double longitude = 0.0;
            try {
                longitude = Double.parseDouble(longitudeString);
            } catch (NumberFormatException nfe) {
                Logger.warn("could not parse longitude: {}", longitudeString);
                valuesParsable = false;
            }
            double tolerance = 0.0;
            try {
                tolerance = Double.parseDouble(toleranceString);
            } catch (NumberFormatException nfe) {
                Logger.warn("could not parse tolerance: {}", toleranceString);
                valuesParsable = false;
            }
            Boolean vibrate = preferences.getBoolean(Key.LOCATION_BASED_TRACKING_VIBRATE.getName(), Boolean.FALSE);

            if (valuesParsable) {
                startLocationTrackerService(latitude, longitude, tolerance, vibrate);
            } else {
                // just in case
                stopLocationTrackerService();
            }
        } else {
            stopLocationTrackerService();
        }
    }

    /**
     * start the location-based tracking service by serviceIntent
     */
    private void startLocationTrackerService(double latitude, double longitude, double tolerance, Boolean vibrate) {
        try {
            Intent startIntent = buildLocationTrackerServiceIntent(latitude, longitude, tolerance, vibrate);
            // we can start the service again even if it is already running because
            // onStartCommand(...) in LocationTrackerService won't do anything if the service
            // is already running with the current parameters - if the location or the
            // tolerance changed, then it will update the values for the service
            Logger.debug("try to start location-based tracking service");
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
                context.startForegroundService(startIntent);
            } else {
                context.startService(startIntent);
            }
            Logger.debug("location-based tracking service started");
        } catch (Exception e) {
            Logger.warn(e, "could not start location-based tracking service");
        }
    }

    /**
     * stop the location-based tracking service by serviceIntent
     */
    private void stopLocationTrackerService() {
        try {
            Intent stopIntent = buildLocationTrackerServiceIntent(null, null, null, null);
            context.stopService(stopIntent);
            Logger.debug("location-based tracking service stopped");
        } catch (Exception e) {
            Logger.warn(e, "could not stop location-based tracking service");
        }
    }

    /**
     * Check if wifi-based tracking has to be en- or disabled and perform wifi-check
     */
    private void checkWifiBasedTracking() {
        Logger.debug("checking wifi-based tracking");
        if (preferences.getBoolean(Key.WIFI_BASED_TRACKING_ENABLED.getName(), false)) {
            String ssid = preferences.getString(Key.WIFI_BASED_TRACKING_SSID.getName(), null);
            Boolean vibrate = preferences.getBoolean(Key.WIFI_BASED_TRACKING_VIBRATE.getName(), Boolean.FALSE);
            if (ssid != null && ssid.length() > 0) {
                startWifiTrackerService(ssid, vibrate);
            } else {
                Logger.warn("NOT starting wifi-based tracking service, the configured SSID is empty");
                // just in case
                stopWifiTrackerService();
            }
        } else {
            stopWifiTrackerService();
        }
    }

    /**
     * start the wifi-based tracking service by serviceIntent
     */
    private void startWifiTrackerService(String ssid, Boolean vibrate) {
        try {
            Intent startIntent = buildWifiTrackerServiceIntent(ssid, vibrate);
            Logger.debug("try to start wifi-based tracking service");
            // changes to settings will be adopted & wifi-check will be performed
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
                context.startForegroundService(startIntent);
            } else {
                context.startService(startIntent);
            }
            Logger.debug("wifi-based tracking service started");
        } catch (Exception e) {
            Logger.warn(e, "could not start wifi-based tracking service");
        }
    }

    /**
     * stop the wifi-based tracking service by serviceIntent
     */
    private void stopWifiTrackerService() {
        try {
            Intent stopIntent = buildWifiTrackerServiceIntent(null, null);
            context.stopService(stopIntent);
            Logger.debug("wifi-based tracking service stopped");
        } catch (Exception e) {
            Logger.warn(e, "could not stop wifi-based tracking service");
        }
    }

    /**
     * Check the current device location and use that as work place.
     *
     * Only to be called when all necessary permissions are granted!
     */
    @SuppressLint("MissingPermission")
    public void useCurrentLocationAsWorkplace(Activity activity) {
        LocationCallback callback = new LocationCallback() {
            @Override
            public void callback(double latitude, double longitude, int tolerance) {
                boolean locationBasedTrackingEnabled = preferences.getBoolean(Key.LOCATION_BASED_TRACKING_ENABLED
                    .getName(), false);

                Logger
                    .debug(
                        "received current device location: lat={} long={} tol={} / location-based tracking already enabled = {}",
                        latitude, longitude, tolerance, locationBasedTrackingEnabled);

                SharedPreferences.Editor editor = preferences.edit();
                String roundedLatitude = CoordinateUtil.roundCoordinate(latitude);
                editor.putString(Key.LOCATION_BASED_TRACKING_LATITUDE.getName(), roundedLatitude);
                String roundedLongitude = CoordinateUtil.roundCoordinate(longitude);
                editor.putString(Key.LOCATION_BASED_TRACKING_LONGITUDE.getName(), roundedLongitude);
                editor.putString(Key.LOCATION_BASED_TRACKING_TOLERANCE.getName(), String.valueOf(tolerance));
                editor.commit();

                Intent i = new Intent(activity, OptionsActivity.class);
                activity.startActivity(i);

                Intent messageIntent = createMessageIntent(
                    "New values:\n\nLatitude = "
                        + roundedLatitude
                        + "\nLongitude = "
                        + roundedLongitude
                        + "\nTolerance = "
                        + tolerance
                        + "\n\nPlease review the settings in the options. "
                        + (locationBasedTrackingEnabled
                        ? "Location-based tracking was switched on already and is still enabled."
                        : "You can now enable location-based tracking, just check \""
                        + activity.getText(R.string.enableLocationBasedTracking) + "\"."), null);
                activity.startActivity(messageIntent);
            }

            @Override
            public void error(Throwable t) {
                Logger.warn(t, "error receiving the current device location");
                Intent messageIntent = createMessageIntent(
                    "Could not get the current location. Please ensure that this app can access the coarse location.",
                    null);
                activity.startActivity(messageIntent);
            }
        };

        try {
            final LocationManager locationManager = (LocationManager) activity.getSystemService(Context.LOCATION_SERVICE);
            List<String> enabledProviders = locationManager.getProviders(true);
            String provider = LocationManager.PASSIVE_PROVIDER;
            if (enabledProviders.contains(LocationManager.NETWORK_PROVIDER)) {
                // preferred: coarse location, determined by cell towers
                provider = LocationManager.NETWORK_PROVIDER;
            } else if (enabledProviders.contains(LocationManager.FUSED_PROVIDER)
                && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
                // second best: the location provider preferred by Android for low power consumption
                provider = LocationManager.FUSED_PROVIDER;
            } else if (enabledProviders.contains(LocationManager.GPS_PROVIDER)) {
                // if the above providers can't be used: GPS
                // (this seems appropriate here because it's only a one-time request)
                provider = LocationManager.GPS_PROVIDER;
            }
            Logger.info("using location provider \"{}\" out of {}", provider, enabledProviders);
            locationManager.requestLocationUpdates(provider, 0, 0, new LocationListener() {
                @Override
                public void onLocationChanged(Location location) {
                    callback.callback(location.getLatitude(), location.getLongitude(), Math.round(location
                        .getAccuracy()));
                    try {
                        // detach listener on first received location
                        locationManager.removeUpdates(this);
                    } catch (SecurityException se) {
                        Logger.error("could not remove updates because of missing rights");
                    }
                }

                @Override
                public void onStatusChanged(String provider, int status, Bundle extras) {
                    // nothing to do
                }

                @Override
                public void onProviderEnabled(String provider) {
                    // nothing to do
                }

                @Override
                public void onProviderDisabled(String provider) {
                    callback.error(new IllegalAccessException("provider disabled"));
                }
            });
        } catch (Throwable t) {
            callback.error(t);
        }
    }

    /**
     * Show a notification.
     *
     * @param scrollingText        the text which appears in the status line when the notification is first displayed
     * @param notificationTitle    the title line of the notification
     * @param notificationSubtitle the smaller line of text below the title
     * @param clickIntent          the intent to be executed when the notification is clicked
     * @param notificationId       a unique number to identify the notification
     */
    public void showNotification(String scrollingText, String notificationTitle, String notificationSubtitle,
                                 PendingIntent clickIntent, @NonNull Integer notificationId, boolean persistent,
                                 PendingIntent buttonOneIntent, Integer buttonOneIcon, String buttonOneText,
                                 PendingIntent buttonTwoIntent, Integer buttonTwoIcon, String buttonTwoText) {
        try {
            NotificationManager notificationManager = (NotificationManager) context
                .getSystemService(Context.NOTIFICATION_SERVICE);
            Notification.Builder notificationBuilder = new Notification.Builder(context)
                .setContentTitle(notificationTitle)
                .setContentText(notificationSubtitle)
                .setContentIntent(clickIntent)
                .setSmallIcon(R.drawable.ic_notification)
                .setTicker(scrollingText)
                .setOnlyAlertOnce(true)
                .setOngoing(persistent)
                .setPriority(Notification.PRIORITY_DEFAULT)
                .setSortKey("A is first");
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
                notificationBuilder.setChannelId(getNotificationChannel().getId());
            }
            if (buttonOneIntent != null && buttonOneIcon != null) {
                notificationBuilder.addAction(buttonOneIcon, buttonOneText, buttonOneIntent);
            }
            if (buttonTwoIntent != null && buttonTwoIcon != null) {
                notificationBuilder.addAction(buttonTwoIcon, buttonTwoText, buttonTwoIntent);
            }
            Notification notification = notificationBuilder.build();
            Logger.debug("prepared notification {} / {} with button1={} and button2={}",
                notificationTitle,
                notificationSubtitle, buttonOneText, buttonTwoText);
            notificationManager.notify(notificationId, notification);
        } catch (Exception e) {
            Logger.warn(e, "could not display notification");
        }
    }

    public Boolean isNotificationActive(int id) {
        try {
            NotificationManager notificationManager = (NotificationManager) context
                .getSystemService(Context.NOTIFICATION_SERVICE);
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
                for (StatusBarNotification n : notificationManager.getActiveNotifications()) {
                    if (n.getId() == id) {
                        return Boolean.TRUE;
                    }
                }
                return Boolean.FALSE;
            } else {
                return null;
            }
        } catch (Exception e) {
            Logger.warn(e, "could not check for notification");
            return null;
        }
    }

    public void removeNotification(int id) {
        try {
            NotificationManager notificationManager = (NotificationManager) context
                .getSystemService(Context.NOTIFICATION_SERVICE);
            notificationManager.cancel(id);
        } catch (Exception e) {
            Logger.warn(e, "could not remove notification {}", id);
        }
    }

    public Notification createNotificationTracking() {
        Intent clickIntent = new Intent(context, WorkTimeTrackerActivity.class);
        clickIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
        clickIntent.setAction(Intent.ACTION_MAIN);
        clickIntent.addCategory(Intent.CATEGORY_LAUNCHER);
        PendingIntent pendingIntent = PendingIntent.getActivity(context, 0, clickIntent, PendingIntent.FLAG_UPDATE_CURRENT +
            (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M
                ? PendingIntent.FLAG_IMMUTABLE
                : 0));

        Notification.Builder notificationBuilder = new Notification.Builder(context)
            .setContentTitle("automatic clock-in by location and/or WiFi active")
            .setContentIntent(pendingIntent)
            .setSmallIcon(R.drawable.ic_menu_mylocation)
            .setOngoing(true)
            .setPriority(Notification.PRIORITY_LOW)
            .setSortKey("B is second");
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            notificationBuilder.setChannelId(getServiceNotificationChannel().getId());
        }
        Logger.debug("created service notification");
        return notificationBuilder.build();
    }

    /**
     * Create an intent which shows a message dialog.
     *
     * @param text the message to display
     * @param id   the unique number, should correspond to the source notification (if any)
     */
    public Intent createMessageIntent(String text, Integer id) {
        Intent messageIntent = new Intent(context, MessageActivity.class);
        messageIntent.putExtra(Constants.MESSAGE_EXTRA_KEY, text);
        if (id != null) {
            messageIntent.putExtra(Constants.ID_EXTRA_KEY, id.intValue());
        }
        return messageIntent;
    }

    public PendingIntent createMessagePendingIntent(String text, Integer id) {
        return PendingIntent.getActivity(context, 0, createMessageIntent(text, id), PendingIntent.FLAG_UPDATE_CURRENT +
            (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M
                ? PendingIntent.FLAG_IMMUTABLE
                : 0));
    }

    /**
     * Disable the location-based tracking.
     */
    public void disableLocationBasedTracking() {
        SharedPreferences.Editor editor = preferences.edit();
        editor.putBoolean(Key.LOCATION_BASED_TRACKING_ENABLED.getName(), false);
        editor.commit();
    }

    /**
     * Enable the location-based tracking if it's disabled.
     */
    public void enableLocationBasedTracking() {
        if (!preferences.getBoolean(Key.LOCATION_BASED_TRACKING_ENABLED.getName(), true)) {
            SharedPreferences.Editor editor = preferences.edit();
            editor.putBoolean(Key.LOCATION_BASED_TRACKING_ENABLED.getName(), true);
            editor.commit();
        }
    }

    /**
     * Disable the wifi-based tracking.
     */
    public void disableWifiBasedTracking() {
        SharedPreferences.Editor editor = preferences.edit();
        editor.putBoolean(Key.WIFI_BASED_TRACKING_ENABLED.getName(), false);
        editor.commit();
    }

    /**
     * Set home time zone for cache database and week display.
     */
    public void setHomeTimeZone(ZoneId zoneId) {
        SharedPreferences.Editor editor = preferences.edit();
        editor.putString(Key.HOME_TIME_ZONE.getName(), zoneId.getId());
        editor.commit();
    }

    private Intent buildLocationTrackerServiceIntent(Double latitude, Double longitude, Double tolerance,
                                                     Boolean vibrate) {
        Intent intent = new Intent(context, LocationTrackerService.class);
        intent.putExtra(Constants.INTENT_EXTRA_LATITUDE, latitude);
        intent.putExtra(Constants.INTENT_EXTRA_LONGITUDE, longitude);
        intent.putExtra(Constants.INTENT_EXTRA_TOLERANCE, tolerance);
        intent.putExtra(Constants.INTENT_EXTRA_VIBRATE, vibrate);
        return intent;
    }

    private Intent buildWifiTrackerServiceIntent(String ssid, Boolean vibrate) {
        Intent intent = new Intent(context, WifiTrackerService.class);
        intent.putExtra(Constants.INTENT_EXTRA_SSID, ssid);
        intent.putExtra(Constants.INTENT_EXTRA_VIBRATE, vibrate);
        return intent;
    }

    /**
     * The app's preferences.
     */
    public SharedPreferences getPreferences() {
        return preferences;
    }

    /**
     * The app's DAO.
     */
    public DAO getDao() {
        return dao;
    }

    /**
     * The app's timer manager.
     */
    public TimerManager getTimerManager() {
        return timerManager;
    }

    /**
     * The app's time calculator.
     */
    public TimeCalculator getTimeCalculator() {
        return timeCalculator;
    }

    /**
     * The wrapper for Android's {@link Vibrator} and Pebble's Notification API.
     */
    public ExternalNotificationManager getExternalNotificationManager() {
        return externalNotificationManager;
    }

    /**
     * Get the context.
     */
    public Context getContext() {
        return context;
    }

    public boolean isDevelopmentVersion() {
        String versionName = getVersionName();
        return versionName != null && versionName.contains("-SNAPSHOT");
    }

    public String getVersionName() {
        try {
            return context.getPackageManager().getPackageInfo(context.getPackageName(), 0).versionName;
        } catch (PackageManager.NameNotFoundException nnfe) {
            Logger.error(nnfe, "could not get version name from manifest");
            return "?";
        }
    }

    public void openBatterySettings() {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
            Intent intent = new Intent();
            intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
            intent.setAction(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS);
            context.startActivity(intent);
        }
    }

    public Uri getDocumentTree() {
        if (documentTree == null) {
            String documenTreeString = preferences.getString(getContext().getString(R.string.keyGrantedDocumentTree), null);
            documentTree = documenTreeString == null
                ? null
                : Uri.parse(documenTreeString);
        }
        return documentTree;
    }
} public class Constants {

	/**
	 * The time to sleep between checks for automatic clock-in/clock-out and between calls to
	 * {@link Basics#periodicHook()} (in milliseconds).
	 */
	public static final long REPEAT_TIME = 1000 * 60;

	/** The pattern for vibration on automatic clock-in/clock-out (values in milliseconds). */
	public static final long[] VIBRATION_PATTERN = { 0, 200, 250, 500, 250, 200 };

	/** the directory in which backups and log files should be saved */
	public static final String DATA_DIR = "trackworktime";
	/** the name of the currently active log file */
	public static final String CURRENT_LOG_FILE_NAME = "log.txt";
	/** the path of the currently active log file */
	public static final String CURRENT_LOG_FILE_PATH = DATA_DIR + "/" + CURRENT_LOG_FILE_NAME;

	// notification & permission request IDs
	/** used for the persistent notification when tracking via location and/or WiFi */
	public static final int PERSISTENT_TRACKING_ID = 1;
	/** used for the status notification when clocked in */
	public static final int PERSISTENT_STATUS_ID = 2;
	/** used for the message about ACCESS_*_LOCATION */
	public static final int MISSING_PRIVILEGE_ACCESS_LOCATION_ID = 4;
	public static final int MISSING_PRIVILEGE_ACCESS_LOCATION_IN_BACKGROUND_ID = 5;
	/** used for the message about ACCESS_*_LOCATION when using "here = work" menu item */
	public static final int MISSING_PRIVILEGE_ACCESS_LOCATION_FOR_CURRENT_LOCATION_ID = 8;
	/** used for the message about ACCESS_WIFI_STATE */
	public static final int MISSING_PRIVILEGE_ACCESS_WIFI_STATE_ID = 16;
	public static final int PERMISSION_REQUEST_CODE_BACKUP = 32;
	public static final int PERMISSION_REQUEST_CODE_RESTORE = 64;
	public static final int PERMISSION_REQUEST_CODE_DOCUMENT_TREE_ON_STARTUP = 128;
	public static final int PERMISSION_REQUEST_CODE_DOCUMENT_TREE_ON_MANUAL_BACKUP = 256;
	public static final int PERMISSION_REQUEST_CODE_DOCUMENT_TREE_ON_MANUAL_RESTORE = 512;
	public static final int PERMISSION_REQUEST_CODE_DOCUMENT_TREE_ON_REPORT = 1024;
	public static final int PERMISSION_REQUEST_CODE_DOCUMENT_TREE_ON_LOGEXPORT = 2048;

	// intent extra keys
	/** used to transport the date via an intent's extended data */
	public static final String DATE_EXTRA_KEY = "date";
	/** key for the intent extra "week start" */
	public static final String WEEK_START_EXTRA_KEY = "WEEK_START_EXTRA_KEY";
	/** key for the intent extra "event id" */
	public static final String EVENT_ID_EXTRA_KEY = "EVENT_ID_EXTRA_KEY";
	/** used to transport the message via an intent's extended data */
	public static final String MESSAGE_EXTRA_KEY = "message";
	/** used to transport the notification' ID via an intent's extended data */
	public static final String ID_EXTRA_KEY = "notificationId";
	/** used to transport the type via an intent's extended data: 0 = clock in / 1 = clock out */
	public static final String TYPE_EXTRA_KEY = "type";
	/** used to transport the type string via an intent's extended data */
	public static final String TYPE_STRING_EXTRA_KEY = "typeString";
	/** the key for the {@link String} which determines the ssid to look for */
	public static final String INTENT_EXTRA_SSID = "SSID";
	/** the key for the {@link Boolean} which determines if vibration should be used */
	public static final String INTENT_EXTRA_VIBRATE = "VIBRATE";
	/** the key for the {@link Double} which determines the latitude */
	public static final String INTENT_EXTRA_LATITUDE = "LATITUDE";
	/** the key for the {@link Double} which determines the longitude */
	public static final String INTENT_EXTRA_LONGITUDE = "LONGITUDE";
	/** the key for the {@link Double} which determines the tolerance */
	public static final String INTENT_EXTRA_TOLERANCE = "TOLERANCE";

	/** the key for the new event's task (used for third-party events) */
	public static final String INTENT_EXTRA_TASK = "task";
	/** the key for the new event's text (used for third-party events) */
	public static final String INTENT_EXTRA_TEXT = "text";
	/** the key for the intent name to reply to on status request (used for third-party events) */
	public static final String INTENT_EXTRA_REPLY_INTENT = "reply_intent";
	/** the key in the reply to on status request for status (checked-in or checked-out) */
	public static final String INTENT_EXTRA_REPLY_STATUS = "status";
	/** the key in the reply to on status request for current task (only if checked in currently) */
	public static final String INTENT_EXTRA_REPLY_CURRENT_TASK_NAME = "current-task-name";
	/** the key in the reply to on status request for current task (only if checked in currently) */
	public static final String INTENT_EXTRA_REPLY_CURRENT_TASK_ID = "current-task-id";
	/** the key in the reply to on status request for remaining time (in minutes) */
	public static final String INTENT_EXTRA_REPLY_MINUTES_REMAINING = "minutes-remaining";

	public static final String CLOCK_IN_ACTION = BuildConfig.APPLICATION_ID + ".ClockIn";
    public static final String CLOCK_OUT_ACTION = BuildConfig.APPLICATION_ID + ".ClockOut";
    public static final String STATUS_REQUEST_ACTION = BuildConfig.APPLICATION_ID + ".StatusRequest";

	// Work manager unique name
	public static final String WORK_AUTOBACKUP = "AUTOBACKUP";
} public class EventListActivity extends AppCompatActivity {

	private static final int NEW_EVENT = 0;

	private static final String BUNDLE_KEY_WEEK_START_DATE = "BUNDLE_KEY_WEEK_START_DATE";

	private static EventListActivity instance = null;

	private DAO dao = null;
	private TimerManager timerManager = null;

	private Week week;
	private List<Event> events = null;
	private final Map<Integer, Task> taskIdToTaskMap = new HashMap<>();

	private EventAdapter myEventAdapter;
	private SelectionTracker<Long> selectionTracker;

	@Override
	protected void onPause() {
		dao.close();
		super.onPause();
	}

	@Override
	protected void onCreate(Bundle savedInstanceState) {
		super.onCreate(savedInstanceState);

		ListActivityBinding binding = ListActivityBinding.inflate(getLayoutInflater());
		setContentView(binding.getRoot());

		ActionBar actionBar = getSupportActionBar();
		if (actionBar != null) {
			actionBar.setDisplayHomeAsUpEnabled(true);
		}

		RecyclerView myRecyclerView = binding.recyclerView;

		instance = this;

		dao = Basics.getInstance().getDao();
		timerManager = Basics.getInstance().getTimerManager();

		long epochDay = getIntent().getLongExtra(Constants.WEEK_START_EXTRA_KEY, -1);
		if (epochDay >= 0) {
			week = new Week(epochDay);
		} else if (savedInstanceState != null && savedInstanceState.containsKey(BUNDLE_KEY_WEEK_START_DATE)) {
			Logger.debug("using week from bundle");
			week = new Week(savedInstanceState.getLong(BUNDLE_KEY_WEEK_START_DATE));
		} else {
			// we don't know which week is meant - let's just use the current one
			Logger.debug("using current week");
			week = WeekIndexConverter.getWeekForDate(LocalDate.now());
		}

		events = new ArrayList<>();
		refreshView();
		myEventAdapter = new EventAdapter();
		myEventAdapter.setHasStableIds(true);
		myRecyclerView.setHasFixedSize(true);
		myRecyclerView.setLayoutManager(new LinearLayoutManager(this));
		myRecyclerView.setAdapter(myEventAdapter);

		selectionTracker = new SelectionTracker.Builder<>(
				"event-selection",
				myRecyclerView,
				new EventKeyProvider(myRecyclerView),
				new EventDetailsLookup(myRecyclerView),
				StorageStrategy.createLongStorage()
		).withSelectionPredicate(new SelectionTracker.SelectionPredicate<>() {
			@Override
			public boolean canSetStateForKey(@NonNull Long key, boolean nextState) {
				if (key.intValue() >= 0 && key.intValue() < events.size()) {
					return !(events.get(key.intValue()) instanceof EventSeparator);
				}

				return false;
			}

			@Override
			public boolean canSetStateAtPosition(int position, boolean nextState) {
				if (position >=0 && position < events.size()){
					return !(events.get(position) instanceof EventSeparator);
				}
				return false;
			}

			@Override
			public boolean canSelectMultiple() {
				return true;
			}
		}).build();

		// restore previous state
		if (savedInstanceState != null) {
			selectionTracker.onRestoreInstanceState(savedInstanceState);
		}

		selectionTracker.addObserver(new SelectionTracker.SelectionObserver<>() {
			@Override
			public void onSelectionChanged() {
				if (selectionTracker.hasSelection() && myActionMode == null) {
					myActionMode = startSupportActionMode(mActionModeCallback);

				} else if (!selectionTracker.hasSelection() && myActionMode != null) {
					myActionMode.finish();
					myActionMode = null;
				}
			}
		});
	}

	@Override
	protected void onSaveInstanceState(@NonNull Bundle outState) {
		super.onSaveInstanceState(outState);

		outState.putLong(BUNDLE_KEY_WEEK_START_DATE, week.toEpochDay());
		selectionTracker.onSaveInstanceState(outState);
	}

	@Override
	public boolean onOptionsItemSelected(@NonNull MenuItem item) {
		switch (item.getItemId()) {
			case NEW_EVENT:
				Logger.debug("starting to enter a new event");
				Intent i = new Intent(this, EventEditActivity.class);
				i.putExtra(Constants.WEEK_START_EXTRA_KEY, week.toEpochDay());
				startActivity(i);
				return true;
			case android.R.id.home:
				finish();
				return true;
			default:
				throw new IllegalArgumentException("options menu: unknown item selected");
		}
	}

	@Override
	public boolean onCreateOptionsMenu(Menu menu) {
		menu.add(Menu.NONE, NEW_EVENT, NEW_EVENT, getString(R.string.new_event)).setIcon(R.drawable.ic_menu_add);
		return super.onCreateOptionsMenu(menu);
	}

	private void startEditing(Event event) {
		Logger.debug("starting to edit the existing event with ID {} ({} @ {})", event.getId(),
				TypeEnum.byValue(event.getType()).toString(), event.getDateTime());

		Intent i = new Intent(this, EventEditActivity.class);
		i.putExtra(Constants.EVENT_ID_EXTRA_KEY, event.getId());
		startActivity(i);
	}

	/**
	 * Refresh the event list and the main activity.
	 */
	public void refreshView() {
		refreshEvents();
		refreshTasks();
		refreshAdapter();
	}

	private void refreshEvents() {
		events.clear();
		events.addAll(dao.getEventsInWeek(week, timerManager.getHomeTimeZone()));
		insertSeparators(events);
	}

	private static void insertSeparators(List<Event> eventList) {
		ListIterator<Event> iter = eventList.listIterator();
		Event prev = null;
		while (iter.hasNext()) {
			Event cur = iter.next();
			if (prev == null || !isOnSameDay(prev, cur)) {
				iter.previous();
				String caption = DateTimeUtil.formatLocalizedDayAndDate(cur.getDateTime());
				iter.add(new EventSeparator(caption));
				iter.next();
			}
			prev = cur;
		}
	}

	private void refreshTasks() {
		taskIdToTaskMap.clear();
		List<Task> tasks = dao.getAllTasks();
		for(Task t : tasks) {
			Integer id = t.getId();
			taskIdToTaskMap.put(id, t);
		}
	}

	@SuppressLint("NotifyDataSetChanged")
	private void refreshAdapter() {
		if (myEventAdapter != null) {
			myEventAdapter.notifyDataSetChanged();
		}
	}

	private static boolean isOnSameDay(Event e1, Event e2) {
		return e1.getDateTime().toLocalDate().isEqual(e2.getDateTime().toLocalDate());
	}

	/**
	 * Getter for the singleton.
	 */
	public static EventListActivity getInstance() {
		return instance;
	}

	private ActionMode myActionMode;
	private final ActionMode.Callback mActionModeCallback = new ActionMode.Callback() {
		@Override
		public boolean onCreateActionMode(ActionMode actionMode, Menu menu) {
			getMenuInflater().inflate(R.menu.list_context_menu, menu);
			return true;
		}

		@Override
		public boolean onPrepareActionMode(ActionMode actionMode, Menu menu) {
			return false;
		}

		@Override
		public boolean onActionItemClicked(ActionMode actionMode, MenuItem menuItem) {
			if (menuItem.getItemId() == R.id.menu_item_delete) {
				AlertDialog.Builder alert = new AlertDialog.Builder(EventListActivity.this);
				alert.setTitle(getString(R.string.delete_event));
				alert.setMessage(getString(R.string.really_delete_event));

				alert.setPositiveButton(getString(R.string.ok),
						(dialog, whichButton) -> {
							OffsetDateTime cacheInvalidationStart = null;

							for (int i = 0; i < events.size(); i++) {
								if (selectionTracker.isSelected((long) i)) {
									Event event = events.get(i);
									boolean success = dao.deleteEvent(event);

									if (success) {
										Logger.debug("deleted event with ID {}", event.getId());
										if (cacheInvalidationStart == null
											|| event.getDateTime().isBefore(cacheInvalidationStart)) {
											cacheInvalidationStart = event.getDateTime();
										}
									} else {
										Logger.warn("could not delete event with ID {}", event.getId());
									}
								}
							}
							selectionTracker.clearSelection();

							if (cacheInvalidationStart != null) {
								// we have to call this manually when using the DAO directly:
								timerManager.invalidateCacheFrom(cacheInvalidationStart);
								// now reload the data
								refreshView();
							}

							Basics.getInstance().safeCheckExternalControls();
						});
				alert.setNegativeButton(getString(R.string.cancel),
						(dialog, which) -> {
							// do nothing
						});
				alert.show();

				return true;

			} else {
				return false;
			}
		}

		@Override
		public void onDestroyActionMode(ActionMode actionMode) {
			selectionTracker.clearSelection();
		}
	};

	private class EventAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder> {
		private static final int VIEW_TYPE_SEPARATOR = 0;
		private static final int VIEW_TYPE_EVENT = 1;

		class EventViewHolder extends RecyclerView.ViewHolder
				implements View.OnClickListener {

			private final ListItemBinding binding;

			public EventViewHolder(ListItemBinding binding) {
				super(binding.getRoot());
				this.binding = binding;
				itemView.setOnClickListener(this);
			}

			@Override
			public void onClick(View v) {
				Logger.debug("View onClick");

				if (!selectionTracker.hasSelection()) {
					Event event = events.get((int)getItemId());
					if (!(event instanceof EventSeparator)) {
						startEditing(event);
					}
				}
			}

			public ItemDetailsLookup.ItemDetails<Long> getItemDetails() {

				return new ItemDetailsLookup.ItemDetails<>() {
					@Override
					public int getPosition() {
						return getBindingAdapterPosition();
					}

					@Override
					public Long getSelectionKey() {
						return getItemId();
					}
				};
			}

			public void bind(Event event, Boolean isSelected) {
				binding.time.setText(formatTime(event.getTime()));
				binding.type.setText(formatType(event.getTypeEnum()));
				binding.task.setText(getTaskName(event.getTask()));
				itemView.setActivated(isSelected);
			}

			private String formatTime(OffsetDateTime time) {
				return DateTimeUtil.formatLocalizedTime(time);
			}

			private String formatType(TypeEnum type) {
				switch (type) {
					case CLOCK_IN:
						return "IN";
					case CLOCK_OUT:
						return "OUT";
					default:
						throw new IllegalStateException("unrecognized event type");
				}
			}

			private String getTaskName(Integer taskId) {
				if (taskId == null) {
					return null;
				}
				Task task = taskIdToTaskMap.get(taskId);
				if (task == null) {
					Logger.error("No task for id: {}", taskId);
					return null;
				}
				return task.getName();
			}
		}

		class EventSeparatorHolder extends RecyclerView.ViewHolder {
			private final ListItemSeparatorBinding binding;

			public EventSeparatorHolder(ListItemSeparatorBinding binding) {
				super(binding.getRoot());
				this.binding = binding;
			}

			public void bind(EventSeparator event) {
				binding.title.setText(event.toString());
			}
		}

		@Override
		public int getItemViewType(int position) {
			if (events.get(position) instanceof EventSeparator) {
				return VIEW_TYPE_SEPARATOR;
			} else {
				return VIEW_TYPE_EVENT;
			}
		}

		@Override
		@NonNull
		public RecyclerView.ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
			LayoutInflater inflater = LayoutInflater.from(parent.getContext());
			switch(viewType) {
				case VIEW_TYPE_SEPARATOR: {
					ListItemSeparatorBinding binding = ListItemSeparatorBinding.inflate(inflater,
							parent, false);
					return new EventSeparatorHolder(binding);
				} case VIEW_TYPE_EVENT: {
					ListItemBinding binding = ListItemBinding.inflate(inflater, parent, false);
					return new EventViewHolder(binding);
				} default: {
					throw new RuntimeException("Not implemented type: " + viewType);
				}
			}
		}

		@Override
		public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) {
			final Event event = events.get(position);
			if (holder instanceof EventViewHolder) {
				EventViewHolder eventHolder = (EventViewHolder) holder;
				boolean isSelected = selectionTracker.isSelected((long)position);
				eventHolder.bind(event, isSelected);
			} else if (holder instanceof EventSeparatorHolder) {
				EventSeparatorHolder eventHolder = (EventSeparatorHolder) holder;
				eventHolder.bind((EventSeparator) event);
			} else {
				throw new RuntimeException("Not implemented view holder type: " + holder);
			}
		}

		@Override
		public long getItemId(int position) {
			return position;
		}

		@Override
		public int getItemCount() {
			return events.size();
		}
	}

	private final class EventDetailsLookup extends ItemDetailsLookup<Long> {

		private final RecyclerView mRecyclerView;

		EventDetailsLookup(RecyclerView recyclerView) {
			mRecyclerView = recyclerView;
		}

		@Override
		public ItemDetails<Long> getItemDetails(MotionEvent e) {
			View view = mRecyclerView.findChildViewUnder(e.getX(), e.getY());

			if (view != null) {
				ViewHolder holder = mRecyclerView.getChildViewHolder(view);

				if (holder instanceof EventAdapter.EventViewHolder) {
					return ((EventAdapter.EventViewHolder) holder).getItemDetails();
				}
			}

			return null;
		}
	}

	private final class EventKeyProvider extends ItemKeyProvider<Long> {

		private final RecyclerView mRecyclerView;

		public EventKeyProvider(RecyclerView recyclerView) {
			super(SCOPE_MAPPED);
			mRecyclerView = recyclerView;
		}

		@Override
		public Long getKey(int position) {
			return myEventAdapter.getItemId(position);
		}

		@Override
		public int getPosition(@NonNull Long key) {
			RecyclerView.ViewHolder viewHolder = mRecyclerView.findViewHolderForItemId(key);
			return (viewHolder == null) ? RecyclerView.NO_POSITION : viewHolder.getLayoutPosition();
		}
	}
} public class DAO {

	// TODO use prepared statements as described here: http://stackoverflow.com/questions/7255574

	private volatile SQLiteDatabase db;
	private final MySQLiteHelper dbHelper;
	private final Context context;
	private final WorkTimeTrackerBackupManager backupManager;
	private final Basics basics;

	private static final Pattern RESTORE_PATTERN = Pattern.compile(";");

	/**
	 * Constructor
	 */
	public DAO(Context context) {
		this.context = context;
		dbHelper = new MySQLiteHelper(context);
		backupManager = new WorkTimeTrackerBackupManager(context);
		basics = Basics.getOrCreateInstance(context);
	}

	/**
	 * Open the underlying database. Implicitly called before any database operation.
	 */
	private synchronized void open() throws SQLException {
		if(db == null || !db.isOpen()) {
			db = dbHelper.getWritableDatabase();
		}
	}

	/**
	 * Close the underlying database.
	 */
	public synchronized void close() {
		dbHelper.close();
	}

	// =======================================================

	private static final String[] TASK_FIELDS = { TASK_ID, TASK_NAME, TASK_ACTIVE, TASK_ORDERING, TASK_DEFAULT };

	private Task cursorToTask(Cursor cursor) {
		Task task = new Task();
		task.setId(cursor.getInt(0));
		task.setName(cursor.getString(1));
		task.setActive(cursor.getInt(2));
		task.setOrdering(cursor.getInt(3));
		task.setIsDefault(cursor.getInt(4));
		return task;
	}

	private ContentValues taskToContentValues(Task task) {
		ContentValues ret = new ContentValues();
		ret.put(TASK_NAME, task.getName());
		ret.put(TASK_ACTIVE, task.getActive());
		ret.put(TASK_ORDERING, task.getOrdering());
		ret.put(TASK_DEFAULT, task.getIsDefault());
		return ret;
	}

	/**
	 * Insert a new task.
	 *
	 * @param task
	 *            the task to add
	 * @return the newly created task as read from the database (complete with ID)
	 */
	public synchronized Task insertTask(Task task) {
		open();
		ContentValues args = taskToContentValues(task);
		long insertId = db.insert(TASK, null, args);
		// now fetch the newly inserted row and return it as Task object
		List<Task> created = getTasksWithConstraint(TASK_ID + "=" + insertId);
		dataChanged();
		return created.get(0);
	}

	/**
	 * Get all tasks.
	 *
	 * @return all existing tasks
	 */
	public List<Task> getAllTasks() {
		return getTasksWithConstraint(null);
	}

	/**
	 * Get all active tasks.
	 *
	 * @return all existing tasks that are active at the moment
	 */
	public List<Task> getActiveTasks() {
		return getTasksWithConstraint(TASK_ACTIVE + "!=0");
	}

	/**
	 * Get the default task.
	 *
	 * @return the default task or {@code null} (if no task was marked as default or if the default task is deactivated)
	 */
	public Task getDefaultTask() {
		List<Task> tasks = getTasksWithConstraint(TASK_ACTIVE + "!=0 AND " + TASK_DEFAULT + "!=0");
		return tasks.isEmpty() ? null : tasks.get(0);
	}

	/**
	 * Get the task with a specific ID.
	 *
	 * @param id
	 *            the ID
	 * @return the task or {@code null} if the specified ID does not exist
	 */
	public Task getTask(Integer id) {
		List<Task> tasks = getTasksWithConstraint(TASK_ID + "=" + id);
		return tasks.isEmpty() ? null : tasks.get(0);
	}

	/**
	 * Get the first task with a specific name.
	 *
	 * @param name
	 *            the name
	 * @return the task (first if more than one exist) or {@code null} if the specified name does not exist at all
	 */
	public Task getTask(String name) {
		List<Task> tasks = getTasksWithConstraint(TASK_NAME + "=\"" + name + "\"");
		return tasks.isEmpty() ? null : tasks.get(0);
	}

	/**
	 * Return if the task with the given ID is used in an event.
	 */
	public synchronized boolean isTaskUsed(Integer id) {
		Cursor cursor = db.query(EVENT, new String[] { "count(*)" }, EVENT_TASK + " = " + id, null,
			null, null, null, null);
		cursor.moveToFirst();
		int count = 0;
		if (!cursor.isAfterLast()) {
			count = cursor.getInt(0);
		}
		cursor.close();
		return count > 0;
	}

	private synchronized List<Task> getTasksWithConstraint(String constraint) {
		open();
		List<Task> ret = new ArrayList<>();
		// TODO sort tasks by TASK_ORDERING when the UI supports manual ordering of tasks
		Cursor cursor = db.query(TASK, TASK_FIELDS, constraint, null, null, null, TASK_NAME);
		cursor.moveToFirst();
		while (!cursor.isAfterLast()) {
			Task task = cursorToTask(cursor);
			ret.add(task);
			cursor.moveToNext();
		}
		cursor.close();
		return ret;
	}

	/**
	 * Update a task.
	 *
	 * @param task
	 *            the task to update - the ID has to be set!
	 * @return the task as newly read from the database
	 */
	public synchronized Task updateTask(Task task) {
		open();
		ContentValues args = taskToContentValues(task);
		db.update(TASK, args, TASK_ID + "=" + task.getId(), null);
		// now fetch the newly updated row and return it as Task object
		List<Task> updated = getTasksWithConstraint(TASK_ID + "=" + task.getId() + "");
		dataChanged();
		return updated.get(0);
	}

	/**
	 * Remove a task.
	 *
	 * @param task
	 *            the task to delete - the ID has to be set!
	 * @return {@code true} if successful, {@code false} if not
	 */
	public synchronized boolean deleteTask(Task task) {
		open();
		final boolean result = db.delete(TASK, TASK_ID + "=" + task.getId(), null) > 0;
		dataChanged();
		return result;
	}


	// =======================================================

	private static final String[] EVENT_FIELDS = { EVENT_ID, EVENT_TIME, EVENT_ZONE_OFFSET, EVENT_TYPE, EVENT_TASK, EVENT_TEXT };
	private static final String[] MAX_EVENT_FIELDS = { EVENT_ID, "max(" + EVENT_TIME + ")", EVENT_ZONE_OFFSET, EVENT_TYPE,
		EVENT_TASK, EVENT_TEXT };

	private Event cursorToEvent(Cursor cursor) {
		Event event = new Event();
		event.setId(cursor.getInt(0));

		Instant instant = Instant.ofEpochSecond(cursor.getLong(1));
		ZoneOffset zoneOffset = ZoneOffset.ofTotalSeconds(cursor.getInt(2) * 60);
		event.setDateTime(instant.atOffset(zoneOffset));

		event.setType(cursor.getInt(3));
		event.setTask(cursor.getInt(4));
		event.setText(cursor.getString(5));
		return event;
	}

	private ContentValues eventToContentValues(Event event) {
		ContentValues ret = new ContentValues();

		OffsetDateTime dateTime = event.getDateTime();
		ret.put(EVENT_TIME, dateTime.toEpochSecond());
		ret.put(EVENT_ZONE_OFFSET, dateTime.getOffset().getTotalSeconds() / 60);

		ret.put(EVENT_TYPE, event.getType());
		ret.put(EVENT_TASK, event.getTask());
		ret.put(EVENT_TEXT, event.getText());
		return ret;
	}

	/**
	 * Insert a new event.
	 *
	 * @param event
	 *            the event to add
	 * @return the newly created event as read from the database (complete with ID)
	 */
	public synchronized Event insertEvent(Event event) {
		open();
		ContentValues args = eventToContentValues(event);
		long insertId = db.insert(EVENT, null, args);
		// now fetch the newly created row and return it as Event object
		List<Event> created = getEventsWithConstraint(EVENT_ID + "=" + insertId);
		if (created.size() > 0) {
			dataChanged();
			return created.get(0);
		} else {
			return null;
		}
	}

	/**
	 * Return all events - attention: this may be slow if many events exist!
	 */
	public List<Event> getAllEvents() {
		return getEventsWithConstraint(null);
	}

	/**
	 * Return the events that are in the specified time frame.
	 */
	public List<Event> getEvents(Instant beginOfTimeFrame, Instant endOfTimeFrame) {
		return getEventsWithConstraint(EVENT_TIME + " >= \"" + beginOfTimeFrame.getEpochSecond()
			+ "\" AND " + EVENT_TIME + " < \"" + endOfTimeFrame.getEpochSecond() + "\"");
	}

	/**
	 * Return all events in a certain week.
	 *
	 * @param week
	 *            the week in which the events are searched - the ID has to be set!
	 */
	public List<Event> getEventsInWeek(Week week, ZoneId zoneId) {
		// FIXME
		if (week == null) {
			return Collections.emptyList();
		} else {
			Instant instantA = week.getStart().atStartOfDay(zoneId).toInstant();
			Instant instantB = week.getEnd().atTime(LocalTime.MAX).atZone(zoneId).toInstant();

			return getEvents(instantA, instantB);
		}
	}

	/**
	 * Return all events on a certain day.
	 *
	 * @param date
	 *            the day on which the events are searched
	 */
	public List<Event> getEventsOnDay(ZonedDateTime date) {
		return getEventsOnDayBefore(date.with(LocalTime.MAX));
	}

	/**
	 * Return all events on a certain day before the given timestamp.
	 *
	 * @param date the day on which the events are searched
	 */
	public List<Event> getEventsOnDayBefore(ZonedDateTime date) {
		long instantA = date.with(LocalTime.MIN).toEpochSecond();
		long instantB = date.toEpochSecond();

		return getEventsWithConstraint(EVENT_TIME + " >= \"" + instantA +
			"\" AND " + EVENT_TIME + " < \"" + instantB + "\"");
	}

	/**
	 * Return all events on a certain day up to and including the given timestamp.
	 *
	 * @param date the day on which the events are searched
	 */
	public List<Event> getEventsOnDayUpTo(ZonedDateTime date) {
		long instantA = date.with(LocalTime.MIN).toEpochSecond();
		long instantB = date.toEpochSecond();

		return getEventsWithConstraint(EVENT_TIME + " >= \"" + instantA +
				"\" AND " + EVENT_TIME + "<= \"" + instantB + "\"");
	}

	/**
	 * Return all events on a certain day after the given timestamp.
	 *
	 * @param date the day on which the events are searched
	 */
	public List<Event> getEventsOnDayAfter(ZonedDateTime date) {
		long instantA = date.toEpochSecond();
		long instantB = date.with(LocalTime.MAX).toEpochSecond();


		return getEventsWithConstraint(EVENT_TIME + " > \"" + instantA +
				"\" AND " + EVENT_TIME + "<= \"" + instantB + "\"");
	}

	/**
	 * Fetch a specific event.
	 *
	 * @param id
	 *            the ID of the event
	 * @return the event, or {@code null} if the id does not exist
	 */
	public Event getEvent(Integer id) {
		List<Event> event = getEventsWithParameters(EVENT_FIELDS, EVENT_ID + " = " + id, false, true);
		// if event is empty, then there is no such event in the database
		return event.isEmpty() ? null : event.get(0);
	}

	/**
	 * Return the last event before a certain date and time or {@code null} if there is no such event.
	 *
	 * @param dateTime
	 *            the date and time before which the event is searched
	 */
	public Event getLastEventBefore(OffsetDateTime dateTime) {
		List<Event> lastEvent = getEventsWithParameters(EVENT_FIELDS, EVENT_TIME + " < \""
			+ dateTime.toEpochSecond() + "\"", true, true);
		// if lastEvent is empty, then there is no such event in the database
		return lastEvent.isEmpty() ? null : lastEvent.get(0);
	}

	/**
	 * Return the last event before a certain date and time (including the hour and minute given!)
	 * or {@code null} if there is no such event.
	 *
	 * @param dateTime
	 *            the date and time before which the event is searched
	 */
	public Event getLastEventUpTo(OffsetDateTime dateTime) {
		List<Event> lastEvent = getEventsWithParameters(EVENT_FIELDS, EVENT_TIME + " <= \""
			+ dateTime.toEpochSecond() + "\"", true, true);
		// if lastEvent is empty, then there is no such event in the database
		return lastEvent.isEmpty() ? null : lastEvent.get(0);
	}

	/**
	 * Return the first event after a certain date and time or {@code null} if there is no such event.
	 *
	 * @param dateTime
	 *            the date and time after which the event is searched
	 */
	public Event getFirstEventAfter(OffsetDateTime dateTime) {
		List<Event> firstEvent = getEventsWithParameters(EVENT_FIELDS, EVENT_TIME + " > \""
			+ dateTime.toEpochSecond() + "\"", false, true);
		// if firstEvent is empty, then there is no such event in the database
		return firstEvent.isEmpty() ? null : firstEvent.get(0);
	}

	/**
	 * Return the first event with the given type on a certain day after a certain time
	 * or {@code null} if there is no such event.
	 *
	 * @param dateTime
	 *            the date and time after which the event is searched
	 */
	public Event getFirstEventAfterWithType(ZonedDateTime dateTime, TypeEnum type) {
		long instantA = dateTime.toEpochSecond();

		String constraint =
				EVENT_TIME + " > \"" + instantA + "\" AND " + EVENT_TYPE + " = " + type.toString();

		List<Event> firstEvent = getEventsWithParameters(EVENT_FIELDS, constraint, false, true);

		// if firstEvent is empty, then there is no such event in the database
		return firstEvent.isEmpty() ? null : firstEvent.get(0);
	}

	/**
	 * Return the first recorded event or {@code null} if no event exists.
	 */
	public Event getFirstEvent() {
		List<Event> firstEvent = getEventsWithParameters(EVENT_FIELDS, null, false, true);
		// if firstEvent is empty, then there is no event in the database
		return firstEvent.isEmpty() ? null : firstEvent.get(0);
	}

	/**
	 * Return the last recorded event or {@code null} if no event exists.
	 */
	public Event getLatestEvent() {
		List<Event> latestEvent = getEventsWithParameters(MAX_EVENT_FIELDS, null, false, true);
		// if latestEvent is empty, then there is no event in the database
		return latestEvent.isEmpty() ? null : latestEvent.get(0);
	}

	private synchronized List<Event> getEventsWithParameters(String[] fields, String constraint,
			boolean descending,
		boolean limitedToOne) {
		open();
		List<Event> ret = new ArrayList<>();
		Cursor cursor = db.query(EVENT, fields, constraint, null, null, null, EVENT_TIME + (descending ? " desc" : "")
			+ "," + EVENT_ID + (descending ? " desc" : ""), (limitedToOne ? "1" : null));
		cursor.moveToFirst();
		while (!cursor.isAfterLast()) {
			Event event = cursorToEvent(cursor);
			ret.add(event);
			cursor.moveToNext();
		}
		cursor.close();
		return ret;
	}

	private List<Event> getEventsWithConstraint(String constraint) {
		return getEventsWithParameters(EVENT_FIELDS, constraint, false, false);
	}

	/**
	 * Update an event.
	 *
	 * @param event
	 *            the event to update - the ID has to be set!
	 * @return the event as newly read from the database
	 */
	public synchronized Event updateEvent(Event event) {
		open();
		ContentValues args = eventToContentValues(event);
		db.update(EVENT, args, EVENT_ID + "=" + event.getId(), null);
		// now fetch the newly updated row and return it as Event object
		List<Event> updated = getEventsWithConstraint(EVENT_ID + "=" + event.getId());
		dataChanged();
		return updated.get(0);
	}

	/**
	 * Remove an event.
	 *
	 * @param event
	 *            the event to delete - the ID has to be set!
	 * @return {@code true} if successful, {@code false} if not
	 */
	public synchronized boolean deleteEvent(Event event) {
		open();
		final boolean result = db.delete(EVENT, EVENT_ID + "=" + event.getId(), null) > 0;
		dataChanged();
		return result;
	}

	private synchronized boolean deleteAll() {
		open();
		boolean result = db.delete(TASK, null, null) > 0;
		result |= db.delete(EVENT, null, null) > 0;
		dataChanged();
		return result;
	}

	public synchronized Cursor getAllEventsAndTasks() {
		open();
		final String querySelectPart = "SELECT"
			+ " " + MySQLiteHelper.EVENT + "." + MySQLiteHelper.EVENT_ID + " AS eventId"
			+ ", " + MySQLiteHelper.EVENT_TYPE
			+ ", " + MySQLiteHelper.EVENT_TIME
			+ ", " + MySQLiteHelper.EVENT_ZONE_OFFSET
			+ ", " + MySQLiteHelper.EVENT_TASK
			+ ", " + MySQLiteHelper.EVENT_TEXT
			+ ", " + MySQLiteHelper.TASK + "." + MySQLiteHelper.TASK_ID + " AS taskId"
			+ ", " + MySQLiteHelper.TASK_NAME
			+ ", " + MySQLiteHelper.TASK_ACTIVE
			+ ", " + MySQLiteHelper.TASK_ORDERING
			+ ", " + MySQLiteHelper.TASK_DEFAULT;

		// this is a FULL OUTER JOIN.
		// see http://stackoverflow.com/questions/1923259/full-outer-join-with-sqlite
		final String query = ""
			+ querySelectPart
			+ " FROM"
			+ " " + MySQLiteHelper.EVENT
			+ " LEFT JOIN"
			+ " " + MySQLiteHelper.TASK
			+ " ON "
			+ " taskId = " + MySQLiteHelper.EVENT_TASK

			+ " UNION ALL "

			+ querySelectPart
			+ " FROM"
			+ " " + MySQLiteHelper.TASK
			+ " LEFT JOIN"
			+ " " + MySQLiteHelper.EVENT
			+ " ON "
			+ " taskId = " + MySQLiteHelper.EVENT_TASK

			+ " WHERE"
			+ " eventId IS NULL"

			+ " ORDER BY"
			+ " eventId";

		return db.rawQuery(query, new String[] {});
	}

	// ---------------------------------------------------------------------------------------------
	// backup/restore methods for Google servers
	// ---------------------------------------------------------------------------------------------
	public long getLastDbModification() {
		final File dbFile = context.getDatabasePath(MySQLiteHelper.DATABASE_NAME);
		return dbFile.lastModified();
	}

	/**
	 * Called internally by the data base methods where data is changed.
	 */
	private void dataChanged() {
		backupManager.dataChanged();
	}

	// ---------------------------------------------------------------------------------------------
	// backup/restore methods
	// ---------------------------------------------------------------------------------------------
	public void backupEventsToWriter(final Writer writer) throws IOException {
		final String eol = System.getProperty("line.separator");
		final Cursor cur = getAllEventsAndTasks();
		cur.moveToFirst();

		final int eventIdCol = cur.getColumnIndex("eventId");
		final int eventTypeCol = cur.getColumnIndex(MySQLiteHelper.EVENT_TYPE);
		final int eventTimeCol = cur.getColumnIndex(MySQLiteHelper.EVENT_TIME);
		final int eventZoneCol = cur.getColumnIndex(MySQLiteHelper.EVENT_ZONE_OFFSET);
		final int eventTaskCol = cur.getColumnIndex(MySQLiteHelper.EVENT_TASK);
		final int eventTextCol = cur.getColumnIndex(MySQLiteHelper.EVENT_TEXT);

		final int taskIdCol = cur.getColumnIndex("taskId");
		final int taskNameCol = cur.getColumnIndex(MySQLiteHelper.TASK_NAME);
		final int taskActiveCol = cur.getColumnIndex(MySQLiteHelper.TASK_ACTIVE);
		final int taskOrderingCol = cur.getColumnIndex(MySQLiteHelper.TASK_ORDERING);
		final int taskDefaultCol = cur.getColumnIndex(MySQLiteHelper.TASK_DEFAULT);

		writer.write(
			MySQLiteHelper.EVENT_TYPE
				+ ";" + MySQLiteHelper.EVENT_TIME
				+ ";" + MySQLiteHelper.EVENT_TASK
				+ ";" + MySQLiteHelper.EVENT_TEXT
				+ ";taskId"
				+ ";" + MySQLiteHelper.TASK_NAME
				+ ";" + MySQLiteHelper.TASK_ACTIVE
				+ ";" + MySQLiteHelper.TASK_ORDERING
				+ ";" + MySQLiteHelper.TASK_DEFAULT
				+ eol);

		final StringBuilder buf = new StringBuilder();
		while (!cur.isAfterLast()) {
			if (!cur.isNull(eventIdCol)) {
				buf.append(TypeEnum.byValue(cur.getInt(eventTypeCol)).getReadableName());
				buf.append(";");

				Instant instant = Instant.ofEpochSecond(cur.getLong(eventTimeCol));
				ZoneOffset offset = ZoneOffset.ofTotalSeconds(cur.getInt(eventZoneCol) * 60);
				buf.append(instant.atOffset(offset).format(DateTimeFormatter.ISO_OFFSET_DATE_TIME));
				buf.append(";");

				buf.append(cur.getInt(eventTaskCol));
				buf.append(";");

				String eventComment = cur.getString(eventTextCol);
				buf.append(eventComment == null ? "" : eventComment);
				buf.append(";");
			} else {
				// this is a task that has no events
				buf.append(";;;;");
			}
			if (!cur.isNull(taskIdCol)) {
				buf.append(cur.getInt(taskIdCol));
				buf.append(";");
				buf.append(cur.getString(taskNameCol));
				buf.append(";");
				buf.append(cur.getInt(taskActiveCol));
				buf.append(";");
				buf.append(cur.getInt(taskOrderingCol));
				buf.append(";");
				buf.append(cur.getInt(taskDefaultCol));
				buf.append(";");
			} else {
				// this is an event that has no task (TypeEnum.CLOCK_OUT)
				buf.append(";;;;;");
			}
			buf.append(eol);
			writer.write(buf.toString());
			buf.setLength(0);
			cur.moveToNext();
		}
		cur.close();
	}

	private static final int INDEX_EVENT_TYPE = 0;
	private static final int INDEX_EVENT_TIME = 1;
	private static final int INDEX_EVENT_TASK = 2;
	private static final int INDEX_EVENT_TEXT = 3;
	private static final int INDEX_TASK_ID = 4;
	private static final int INDEX_TASK_NAME = 5;
	private static final int INDEX_TASK_ACTIVE = 6;
	private static final int INDEX_TASK_ORDERING = 7;
	private static final int INDEX_TASK_DEFAULT = 8;

	/** ONLY FOR PARSING! reads new and old backup timestamp format */
	private static final DateTimeFormatter BACKUP_DATETIME_PARSER = new DateTimeFormatterBuilder()
		.parseCaseInsensitive()
		.append(DateTimeFormatter.ISO_LOCAL_DATE)
		.optionalStart()
		.appendLiteral('T')
		.optionalEnd()
		.optionalStart()
		.appendLiteral(' ')
		.optionalEnd()
		.append(DateTimeFormatter.ISO_LOCAL_TIME)
		.optionalStart()
		.appendOffsetId()
		.optionalEnd()
		.toFormatter()
		.withResolverStyle(ResolverStyle.STRICT)
		.withChronology(IsoChronology.INSTANCE);

	public void restoreEventsFromReader(final BufferedReader reader) throws IOException {
        final String eol = System.getProperty("line.separator");
        final TimerManager timerManager = Basics.getInstance().getTimerManager();

        deleteAll();

		reader.readLine();	// skip header

        String line;
        // cache values
        final String clockInReadableName = TypeEnum.CLOCK_IN.getReadableName();
        final String clockOutNowReadableName = TypeEnum.CLOCK_OUT_NOW.getReadableName();

		while ((line = reader.readLine()) != null) {
            final String[] columns = RESTORE_PATTERN.split(line, -1);
            try {
                if (columns.length > 8 && columns[INDEX_TASK_ID].length() > 0) {
                    final int taskId = Integer.parseInt(columns[INDEX_TASK_ID]);
                    if (getTask(taskId) == null) {
                        final Task task = new Task(
                                taskId,
                                columns[INDEX_TASK_NAME],
                                Integer.parseInt(columns[INDEX_TASK_ACTIVE]),
                                Integer.parseInt(columns[INDEX_TASK_ORDERING]),
                                Integer.parseInt(columns[INDEX_TASK_DEFAULT])
                        );
                        final ContentValues args = taskToContentValues(task);
                        args.put(MySQLiteHelper.TASK_ID, taskId);
                        db.insert(TASK, null, args);
                    }
				}

                if (columns.length > 2 && columns[INDEX_EVENT_TYPE].length() > 0) {
					OffsetDateTime dateTime = parseOffsetDateTime(timerManager, columns[INDEX_EVENT_TIME]);
					final TypeEnum typeEnum;
                    if (clockInReadableName.equalsIgnoreCase(columns[INDEX_EVENT_TYPE])) {
                        typeEnum = TypeEnum.CLOCK_IN;
                    } else if (clockOutNowReadableName.equalsIgnoreCase(columns[INDEX_EVENT_TYPE])) {
                        typeEnum = TypeEnum.CLOCK_OUT_NOW;
                    } else {
                        typeEnum = TypeEnum.CLOCK_OUT;
                    }
                    timerManager.createEvent(dateTime,
                            Integer.parseInt(columns[INDEX_EVENT_TASK]),
                            typeEnum,
                            columns.length > INDEX_EVENT_TEXT ?
                                    columns[INDEX_EVENT_TEXT] : "", true);
                }
            } catch (NumberFormatException e) {
                // ignore rest of current row
            }
        }
	}

	@VisibleForTesting
	static OffsetDateTime parseOffsetDateTime(TimerManager timerManager, String timestamp) {
		TemporalAccessor eventTimestamp = BACKUP_DATETIME_PARSER.parse(timestamp);
		if (!eventTimestamp.isSupported(ChronoField.OFFSET_SECONDS)) {
			LocalDateTime localDateTime = LocalDateTime.from(eventTimestamp);
			return localDateTime
				.atOffset(timerManager.getHomeTimeZoneOffset(localDateTime));
		} else {
			return OffsetDateTime.from(eventTimestamp);
		}
	}


	// =======================================================

	private static final String[] TARGET_FIELDS = { TARGET_ID, TARGET_TIME, TARGET_TYPE, TARGET_VALUE, TARGET_TEXT };

	private Target cursorToTarget(Cursor cursor) {
		Target target = new Target();
		target.setId(cursor.getInt(0));
		target.setDate(LocalDate.ofEpochDay(cursor.getLong(1)));
		target.setType(cursor.getInt(2));
		target.setValue(cursor.getInt(3));
		target.setComment(cursor.getString(4));
		return target;
	}

	private ContentValues targetToContentValues(Target target) {
		ContentValues ret = new ContentValues();
		ret.put(TARGET_TIME, target.getDate().toEpochDay());
		ret.put(TARGET_TYPE, target.getType());
		ret.put(TARGET_VALUE, target.getValue());
		ret.put(TARGET_TEXT, target.getComment());
		return ret;
	}


	/**
	 * Insert a new target.
	 *
	 * @param target the target to add
	 * @return the newly created target as read from the database (complete with ID)
	 */
	public synchronized Target insertTarget(Target target) {
		open();
		ContentValues args = targetToContentValues(target);
		long insertId = db.insert(TARGET, null, args);

		// now fetch the newly created row and return it as Target object
		List<Target> created = getTargetsWithConstraint(TARGET_ID + "=" + insertId);
		if (created.size() > 0) {
			//dataChanged();    // TODO
			return created.get(0);
		} else {
			return null;
		}
	}

	private List<Target> getTargetsWithConstraint(String constraint) {
		open();
		List<Target> ret = new ArrayList<>();

		Cursor cursor = db.query(TARGET, TARGET_FIELDS, constraint, null, null, null, TARGET_TIME);
		cursor.moveToFirst();

		while (!cursor.isAfterLast()) {
			Target target = cursorToTarget(cursor);
			ret.add(target);
			cursor.moveToNext();
		}
		cursor.close();

		return ret;
	}

	public Target getDayTarget(LocalDate day) {
		List<Target> ret = getTargetsWithConstraint(TARGET_TIME + "=" + day.toEpochDay() +
				" AND " + TARGET_TYPE + "<=" + TargetEnum.DAY_IGNORE.getValue());

		if (ret.size() == 1) {
			Logger.debug("Got day target: {}", ret.get(0));

			return ret.get(0);
		} else {
			return null;
		}
	}

	/**
	 * Update an target.
	 *
	 * @param target the target to update - the ID has to be set!
	 * @return the target as newly read from the database
	 */
	public synchronized Target updateTarget(Target target) {
		open();
		ContentValues args = targetToContentValues(target);
		db.update(TARGET, args, TARGET_ID + "=" + target.getId(), null);

		// now fetch the newly updated row and return it as Target object
		List<Target> updated = getTargetsWithConstraint(TARGET_ID + "=" + target.getId());
		//dataChanged(); // TODO
		return updated.get(0);
	}

	/**
	 * Remove an target.
	 *
	 * @param target the target to delete - the ID has to be set!
	 * @return {@code true} if successful, {@code false} if not
	 */
	public boolean deleteTarget(Target target) {
		open();
		final boolean result = db.delete(TARGET, TARGET_ID + "=" + target.getId(), null) > 0;
		//dataChanged(); // TODO
		return result;
	}

	private synchronized boolean deleteAllTargets() {
		open();
		boolean result = db.delete(TARGET, null, null) > 0;
		dataChanged();
		return result;
	}

	public synchronized Cursor getAllTargets() {
		open();
		final String query = "SELECT"
				+ " "  + TARGET_TIME
				+ ", " + TARGET_TYPE
				+ ", " + TARGET_VALUE
				+ ", " + TARGET_TEXT
				+ " FROM " + MySQLiteHelper.TARGET
				+ " ORDER BY " + TARGET_TIME;

		return db.rawQuery(query, new String[] {});
	}

	public List<Target> getTargets(Instant from, Instant to) {
		return getTargetsWithConstraint(TARGET_TIME + " >= "
			+ ChronoUnit.DAYS.between(Instant.EPOCH, from) +
			" AND " + TARGET_TIME + " <= "
			+ ChronoUnit.DAYS.between(Instant.EPOCH, to));
	}


	// ---------------------------------------------------------------------------------------------
	// backup/restore methods
	// ---------------------------------------------------------------------------------------------
	public void backupTargetsToWriter(final Writer writer) throws IOException {
		final String eol = System.getProperty("line.separator");
		final Cursor cur = getAllTargets();
		cur.moveToFirst();

		final int targetTimeCol = cur.getColumnIndex(TARGET_TIME);
		final int targetTypeCol = cur.getColumnIndex(TARGET_TYPE);
		final int targetValueCol = cur.getColumnIndex(TARGET_VALUE);
		final int targetTextCol = cur.getColumnIndex(TARGET_TEXT);

		writer.write(
				TARGET_TIME
						+ ";" + TARGET_TYPE
						+ ";" + TARGET_VALUE
						+ ";" + TARGET_TEXT
						+ eol);

		final StringBuilder buf = new StringBuilder();
		while (!cur.isAfterLast()) {
			LocalDate date = LocalDate.ofEpochDay(cur.getLong(targetTimeCol));
			buf.append(date.format(DateTimeFormatter.ISO_DATE));
			buf.append(";");

			buf.append(TargetEnum.byValue(cur.getInt(targetTypeCol)).toString());
			buf.append(";");

			buf.append(cur.getInt(targetValueCol));
			buf.append(";");

			String targetComment = cur.getString(targetTextCol);
			buf.append(targetComment == null ? "" : targetComment);
			buf.append(eol);

			writer.write(buf.toString());
			buf.setLength(0);
			cur.moveToNext();
		}
		cur.close();
	}

	private static final int INDEX_TARGET_TIME = 0;
	private static final int INDEX_TARGET_TYPE = 1;
	private static final int INDEX_TARGET_VALUE = 2;
	private static final int INDEX_TARGET_TEXT = 3;

	public void restoreTargetsFromReader(final BufferedReader reader) throws IOException {
		deleteAllTargets();

		reader.readLine();	// skip header
		String line;
		while ((line = reader.readLine()) != null) {
			final String[] columns = RESTORE_PATTERN.split(line, -1);

			try {
				if (columns.length >= 4 && columns[INDEX_TARGET_TIME].length() > 0) {

					final Target target = new Target(0,
							TargetEnum.byName(columns[INDEX_TARGET_TYPE]).getValue(),
							Integer.parseInt(columns[INDEX_TARGET_VALUE]),
							LocalDate.parse(columns[INDEX_TARGET_TIME]),
							columns[INDEX_TARGET_TEXT]
					);
					final ContentValues args = targetToContentValues(target);
					db.insert(TARGET, null, args);
				} else {
					Logger.debug("could not restore target line: {}", line);
				}
			} catch (NumberFormatException e) {
				// ignore rest of current row
			}
		}
	}


	// =======================================================

	private static final String[] CACHE_FIELDS = {CACHE_DATE, CACHE_WORKED, CACHE_TARGET};

	private CalcCacheEntry cursorToCache(Cursor cursor) {
		CalcCacheEntry cache = new CalcCacheEntry();
		cache.setDateFromId(cursor.getLong(0));
		cache.setWorked(cursor.getLong(1));
		cache.setTarget(cursor.getLong(2));
		return cache;
	}

	private ContentValues cacheToContentValues(CalcCacheEntry cache) {
		ContentValues ret = new ContentValues();
		ret.put(CACHE_DATE, cache.getDateAsId());
		ret.put(CACHE_WORKED, cache.getWorked());
		ret.put(CACHE_TARGET, cache.getTarget());
		return ret;
	}


	/**
	 * Insert a new target.
	 *
	 * @param cache the cache entry to add
	 * @return the newly created cache entry as read from the database (complete with ID)
	 */
	public synchronized CalcCacheEntry insertCache(CalcCacheEntry cache) {
		open();
		ContentValues args = cacheToContentValues(cache);
		long insertId = db.insert(CACHE, null, args);

		// now fetch the newly created row and return it as Target object
		return getCacheWithConstraint(CACHE_DATE + "=" + insertId);
	}


	private CalcCacheEntry getCacheWithConstraint(String constraint) {
		open();

		CalcCacheEntry ret = null;

		Cursor cursor = db.query(CACHE, CACHE_FIELDS, constraint, null, null, null, CACHE_DATE + " DESC", "1");
		cursor.moveToFirst();

		if (!cursor.isAfterLast()) {
			ret = cursorToCache(cursor);
		}

		cursor.close();

		return ret;
	}

	public CalcCacheEntry getCacheAt(LocalDate date) {
		return getCacheWithConstraint(CACHE_DATE + "<=" + date.toEpochDay());
	}

	/**
	 * Remove cache entries.
	 *
	 * @param date the date from which to delete, deletes everything if {@code null}
	 * @return {@code true} if successful, {@code false} if not
	 */
	public boolean deleteCacheFrom(LocalDate date) {
		open();

		if (date != null) {
			return db.delete(CACHE, CACHE_DATE + ">=" + date.toEpochDay(), null) > 0;
		} else {
			return db.delete(CACHE, null, null) > 0;
		}
	}


	public void executePendingMigrations() {
		open();

		SQLiteStatement s = db.compileStatement("SELECT count(*) FROM sqlite_master WHERE type=\"table\" AND name=\"" + EVENT_V1 + "\"");

		if (s.simpleQueryForLong() > 0) {
			Logger.debug("Starting upgrade activity.");
			//close();

			Intent i = new Intent(context, UpgradeActivity.class);
			if (!(context instanceof Activity)) {
				i.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS);
			}
			context.startActivity(i);
		}
	}

	/*
	Migrate events to v2
	 */
	public void migrateEventsToV2(ZoneId zoneId, MigrationCallback callback) {
		new MigrateEventsV2(zoneId, callback).execute();
	}

    private class MigrateEventsV2 extends AsyncTask<Void, Integer, Void> {

		private final DateTimeFormatter DATETIME_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSSS");
		private final int TYPE_FLEX = 2;

		private final ZoneId zoneId;
		private final WeakReference<MigrationCallback> callback;

		private MigrateEventsV2(ZoneId zoneId, MigrationCallback callback) {
			this.zoneId = zoneId;
			this.callback = new WeakReference<>(callback);
		}

		@Override
		protected Void doInBackground(Void... voids) {
			open();

			long numEntries = DatabaseUtils.queryNumEntries(db, EVENT_V1);
			long count = 0;

			Logger.debug("Migrating {} rows.", numEntries);

			db.beginTransaction();
			try {
				Cursor cursor = db.query(EVENT_V1, null, null, null,
						null, null, EVENT_TIME + "," + EVENT_ID, null);
				cursor.moveToFirst();

				while (!cursor.isAfterLast()) {
					publishProgress((int)((count * 100) / numEntries));

					int type = cursor.getInt(2);
					LocalDateTime eventDateTime =
							LocalDateTime.parse(cursor.getString(3), DATETIME_FORMAT);

					String text = cursor.getString(5);

					Integer task = null;

					if (type == TYPE_FLEX) {
						// migrate flex event to target table, time -> target work time for the day
						LocalTime localTime = eventDateTime.toLocalTime();
						long targetTime = localTime.getHour() * 60 + localTime.getMinute();

						ContentValues cv = new ContentValues();
						cv.put(TARGET_TIME, eventDateTime.toLocalDate().toEpochDay());
						cv.put(TARGET_TYPE,TargetEnum.DAY_SET.getValue());
						cv.put(TARGET_VALUE, targetTime);
						cv.put(TARGET_TEXT, text);

						db.insert(TARGET, null, cv);

					} else {
						if (!cursor.isNull(4)) {
							task = cursor.getInt(4);
						}

						// migrate local datetime assuming home time zone
						OffsetDateTime newTime = eventDateTime.atZone(zoneId).toOffsetDateTime();

						// entry in new event table
						ContentValues cv = new ContentValues();
						cv.put(EVENT_TIME, newTime.toInstant().getEpochSecond());
						cv.put(EVENT_ZONE_OFFSET, newTime.getOffset().getTotalSeconds() / 60);
						cv.put(EVENT_TYPE, type);
						cv.put(EVENT_TASK, task);
						cv.put(EVENT_TEXT, text);

						db.insert(EVENT, null, cv);
					}

					count++;
					cursor.moveToNext();
				}
				cursor.close();

				// for now only rename the old event table
				db.execSQL("ALTER TABLE " + EVENT_V1 + " RENAME TO " + EVENT_V1 + "_mig");

				db.setTransactionSuccessful();
			} finally {
				db.endTransaction();
			}

			return null;
		}

		@Override
		protected void onProgressUpdate(Integer... values) {
			MigrationCallback cb = callback.get();

			if (cb != null) {
				cb.onProgressUpdate(values[0]);
			}
		}

		@Override
		protected void onPostExecute(Void v) {
			Logger.debug("Migration done.");

			MigrationCallback cb = callback.get();
			if (cb != null) {
				cb.migrationDone();
			}
		}
	}
} public class Event extends Base implements Comparable<Event> {
	private Integer id = null;
	private Integer task = null;
	private Integer type = null;
	private OffsetDateTime time = null;
	private String text = null;

	public Event() {
		// do nothing
	}

	public Event(Integer id, Integer task, Integer type, OffsetDateTime time, String text) {
		this.id = id;
		this.task = task;
		this.type = type;
		this.time = time;
		this.text = text;
	}

	public Integer getId() {
		return id;
	}

	public Integer getTask() {
		return task;
	}

	public Integer getType() {
		return type;
	}

	public TypeEnum getTypeEnum() {
		return TypeEnum.byValue(type);
	}

	// used for report generation
	public OffsetDateTime getTime() {
		return time;
	}

	public OffsetDateTime getDateTime() {
		return time;
	}

	public void setId(Integer id) {
		this.id = id;
	}

	public void setTask(Integer task) {
		this.task = task;
	}

	public void setType(Integer type) {
		this.type = type;
	}

	public void setDateTime(OffsetDateTime datetime) {
		this.time = datetime;
	}

	public String getText() {
		return text;
	}

	public void setText(String text) {
		this.text = text;
	}

	@Override
	public int compareTo(Event another) {
		return compare(getDateTime(), another.getDateTime(), compare(getId(), another.getId(), 0));
	}

	/**
	 * This is used e.g. by an ArrayAdapter in a ListView and it is also useful for debugging.
	 * 
	 * @see org.zephyrsoft.trackworktime.model.Base#toString()
	 */
	@Override
	public String toString() {
		return getDateTime() + " / " + TypeEnum.byValue(getType()).name() + " / " + getTask() + " - " + getText();
	}

} public class Task extends Base implements Comparable<Task> {
	private Integer id = null;
	private String name = null;
	private Integer active = null;
	private Integer ordering = null;
	private Integer isDefault = null;

	public Task() {
		// do nothing
	}

	public Task(Integer id, String name, Integer active, Integer ordering, Integer isDefault) {
		this.id = id;
		this.name = name;
		this.active = active;
		this.ordering = ordering;
		this.isDefault = isDefault;
	}

	public Integer getId() {
		return id;
	}

	public String getName() {
		return name;
	}

	public Integer getActive() {
		return active;
	}

	public void setId(Integer id) {
		this.id = id;
	}

	public void setName(String name) {
		this.name = name;
	}

	public void setActive(Integer active) {
		this.active = active;
	}

	public Integer getOrdering() {
		return ordering;
	}

	public void setOrdering(Integer ordering) {
		this.ordering = ordering;
	}

	public Integer getIsDefault() {
		return isDefault;
	}

	public void setIsDefault(Integer isDefault) {
		this.isDefault = isDefault;
	}

	@Override
	public int compareTo(Task another) {
		return compare(getName(), another.getName(), compare(getId(), another.getId(), 0));
	}

	/**
	 * This is used e.g. by an ArrayAdapter in a ListView and it is also useful for debugging.
	 * 
	 * @see org.zephyrsoft.trackworktime.model.Base#toString()
	 */
	@Override
	public String toString() {
		return getName() + (isDefault != null && isDefault.equals(1) ? " *" : "");
	}

	@Override
	public int hashCode() {
		final int prime = 31;
		int result = 1;
		result = prime * result + ((id == null) ? 0 : id.hashCode());
		return result;
	}

	@Override
	public boolean equals(Object obj) {
		if (this == obj)
			return true;
		if (obj == null)
			return false;
		if (getClass() != obj.getClass())
			return false;
		Task other = (Task) obj;

		if (id == null) {
			return other.id == null;
		} else {
			return id.equals(other.id);
		}
	}
} public enum TypeEnum {

	/**
	 * clock-in type of event
	 */
	CLOCK_IN(Values.CLOCK_IN_VALUE, "in"),
	/**
	 * clock-out type of event
	 */
	CLOCK_OUT(Values.CLOCK_OUT_VALUE, "out"),
	/**
	 * clock-out now type of event used to display correct amount of worked time on current day when currently clocked
	 * in - THIS TYPE NEVER COMES FROM THE DATABASE
	 */
	CLOCK_OUT_NOW(Values.CLOCK_OUT_NOW_VALUE, "out (current time)");

	private final Integer value;
	private final String readableName;

	TypeEnum(Integer value, String readableName) {
		this.value = value;
		this.readableName = readableName;
	}

	/**
	 * Gets the value of this enum for storing it in database.
	 */
	public Integer getValue() {
		return value;
	}

	public String getReadableName() {
		return readableName;
	}

	@Override
	public String toString() {
		return String.valueOf(getValue());
	}

	/**
	 * Gets the "default" types, meaning those that get saved. At the moment, only CLOCK_IN and CLOCK_OUT make sense
	 * here.
	 */
	public static List<TypeEnum> getDefaultTypes() {
		return Arrays.asList(CLOCK_IN, CLOCK_OUT);
	}

	/**
	 * Get the enum for a specific value.
	 *
	 * @param value
	 *            the value
	 * @return the corresponding enum
	 */
	public static TypeEnum byValue(Integer value) {
		if (value == null) {
			return null;
		} else if (value == Values.CLOCK_IN_VALUE) {
			return CLOCK_IN;
		} else if (value == Values.CLOCK_OUT_VALUE) {
			return CLOCK_OUT;
		} else if (value == Values.CLOCK_OUT_NOW_VALUE) {
			return CLOCK_OUT_NOW;
		} else {
			throw new IllegalArgumentException("unknown value");
		}
	}

	private static class Values {
		private static final int CLOCK_IN_VALUE = 1;
		private static final int CLOCK_OUT_VALUE = 0;
		private static final int CLOCK_OUT_NOW_VALUE = -1;
	}
} public class Week extends Base implements Comparable<Week> {
	private final LocalDate startDay;

	public Week(LocalDate date) {
		// TODO consider locale
		startDay = date.with(DayOfWeek.MONDAY);
	}

	public Week(long epochDay) {
		startDay = LocalDate.ofEpochDay(epochDay);
	}

	public LocalDate getStart() {
		return startDay;
	}

	public LocalDate getEnd() {
		// TODO consider locale
		return startDay.with(DayOfWeek.SUNDAY);
	}

	public long toEpochDay() {
		return startDay.toEpochDay();
	}

	public Week plusWeeks(long weeksToAdd) {
		return new Week(startDay.plusWeeks(weeksToAdd));
	}

	public boolean isInWeek(LocalDate date) {
		return !date.isBefore(startDay) && !date.isAfter(getEnd());
	}

	@Override
	public int compareTo(Week another) {
		//return compare(getStart(), another.getStart(), compare(getId(), another.getId(), 0));
		return compare(getStart(), another.getStart(), 0);
	}

	/**
	 * This is used e.g. by an ArrayAdapter in a ListView and it is also useful for debugging.
	 *
	 * @see Base#toString()
	 */
	@Override
	public String toString() {
		return startDay.toString();
	}
} public class TimerManager {

	private final DAO dao;
	private final SharedPreferences preferences;
	private final Context context;

	/**
	 * Constructor
	 */
	public TimerManager(DAO dao, SharedPreferences preferences, Context context) {
		this.dao = dao;
		this.preferences = preferences;
		this.context = context;
	}

	public ZoneId getHomeTimeZone() {
		String homeTimeZone = preferences.getString(Key.HOME_TIME_ZONE.getName(),  null);

		if (homeTimeZone == null) {
			// TODO start activity to select time zone?
			return ZoneId.systemDefault();
		} else {
			return ZoneId.of(homeTimeZone);
		}
	}

	public ZoneOffset getHomeTimeZoneOffset(LocalDateTime localDateTime) {
		return getHomeTimeZone().getRules().getOffset(localDateTime);
	}

	public FlexiReset getFlexiReset() {
		return FlexiReset.loadFromPreferences(preferences);
	}

	public boolean insertDefaultWorkTimes(LocalDate from, LocalDate to, Integer taskId, String text) {
		try {
			LocalDate running = from;

			while (!running.isAfter(to)) {
				// clock-in at start of day (00:00) in home time zone
				// TODO current time zone?

				DayOfWeek weekDay = running.getDayOfWeek();
				int workDuration = getNormalWorkDurationFor(weekDay);
				if (workDuration > 0) {
					OffsetDateTime clockInTime = running.atStartOfDay(getHomeTimeZone()).toOffsetDateTime();
					createEvent(clockInTime, taskId, TypeEnum.CLOCK_IN, text);

					OffsetDateTime clockOutTime = clockInTime.plusMinutes(workDuration);
					if (isAutoPauseApplicable(clockOutTime)) {
						long pauseDuration = getAutoPauseDuration();
						clockOutTime = clockOutTime.plusMinutes(pauseDuration);
					}
					createEvent(clockOutTime, null, TypeEnum.CLOCK_OUT, null);
				}

				running = running.plusDays(1);
			}
			return true;
		} catch(Exception e) {
			return false;
		}
	}

	/**
	 * Checks the current state.
	 *
	 * @return {@code true} if currently clocked in, {@code false} otherwise
	 */
	public boolean isTracking() {
		Event latestEvent = dao.getLastEventUpTo(OffsetDateTime.now());
		return latestEvent != null && latestEvent.getType().equals(TypeEnum.CLOCK_IN.getValue());
	}

	/**
	 * Returns {@code true} if the options are set in a way that an event is in the defined time before/after an
	 * existing event (not counting CLOCK_OUT_NOW).
	 */
	public boolean isInIgnorePeriodForLocationBasedTracking() {
		OffsetDateTime now = OffsetDateTime.now();

		// get first event AFTER now, subtract the minutes to ignore before events and check if the result is BEFORE now
		Event firstAfterNow = dao.getFirstEventAfter(now);
		String ignoreBeforeString = preferences.getString(Key.LOCATION_BASED_TRACKING_IGNORE_BEFORE_EVENTS.getName(),
			"0");
		int ignoreBefore = 0;
		try {
			ignoreBefore = Integer.parseInt(ignoreBeforeString);
		} catch (NumberFormatException nfe) {
			Logger.warn("illegal value - ignore before events: {}", ignoreBeforeString);
		}
		if (firstAfterNow != null) {
			OffsetDateTime firstAfterNowTime = firstAfterNow.getDateTime();

			if (firstAfterNowTime.minusMinutes(ignoreBefore).isBefore(now)) {
				return true;
			}
		}
		// get the last event BEFORE now, add the minutes to ignore after events and check if the result is AFTER now
		Event lastBeforeNow = dao.getLastEventBefore(OffsetDateTime.now());
		String ignoreAfterString = preferences
			.getString(Key.LOCATION_BASED_TRACKING_IGNORE_AFTER_EVENTS.getName(), "0");
		int ignoreAfter = 0;
		try {
			ignoreAfter = Integer.parseInt(ignoreAfterString);
		} catch (NumberFormatException nfe) {
			Logger.warn("illegal value - ignore after events: {}", ignoreAfterString);
		}
		if (lastBeforeNow != null) {
			OffsetDateTime lastBeforeNowTime = lastBeforeNow.getDateTime();

			return lastBeforeNowTime.plusMinutes(ignoreAfter).isAfter(now);
		}

		return false;
	}

	/**
	 * Returns the currently active task or {@code null} if tracking is disabled at the moment.
	 */
	public Task getCurrentTask() {
		Event latestEvent = dao.getLastEventBefore(OffsetDateTime.now());
		if (latestEvent != null && latestEvent.getType().equals(TypeEnum.CLOCK_IN.getValue())) {
			return dao.getTask(latestEvent.getTask());
		} else {
			return null;
		}
	}

	/**
	 * Returns the default task or {@code null} if no task is configured.
	 */
	public Task getDefaultTask() {
		return dao.getDefaultTask();
	}

	/**
	 * Either starts tracking (from non-tracked time) or changes the task and/or text (from already tracked time).
	 * If the task is {@code null}, the default task is taken. If there is no default task, no task will be taken.
	 *
	 * @param minutesToPredate
	 *            how many minutes in the future should the event be
	 * @param selectedTask
	 *            the task for which the time shall be tracked
	 * @param text
	 *            free text to describe in detail what was done
	 */
	public void startTracking(int minutesToPredate, Task selectedTask, String text) {
		Task taskToLink = selectedTask;
		if (taskToLink == null) {
			taskToLink = dao.getDefaultTask();
		}
		createEvent(minutesToPredate, (taskToLink == null ? null : taskToLink.getId()), TypeEnum.CLOCK_IN, text);
		Basics.getInstance().safeCheckExternalControls();
	}

	/**
	 * Stops tracking time.
	 *
	 * @param minutesToPredate
	 *            how many minutes in the future should the event be
	 */
	public void stopTracking(int minutesToPredate) {
		createEvent(minutesToPredate, null, TypeEnum.CLOCK_OUT, null);
		Basics.getInstance().safeCheckExternalControls();
	}

	/**
	 * Is the event a clock-in event? {@code null} as argument will return {@code false}.
	 */
	public static boolean isClockInEvent(Event event) {
		return event != null && event.getType().equals(TypeEnum.CLOCK_IN.getValue());
	}

	/**
	 * Is the event a clock-out event? {@code null} as argument will return {@code false}.
	 */
	public static boolean isClockOutEvent(Event event) {
		return event != null
			&& (event.getType().equals(TypeEnum.CLOCK_OUT.getValue()) || event.getType().equals(
				TypeEnum.CLOCK_OUT_NOW.getValue()));
	}

	/**
	 * Calculate a time sum for a given period.
	 */
	public long calculateTimeSum(LocalDate date, PeriodEnum periodEnum) {
		// TODO restructure for clarity!
		// TODO improve performance
		if (periodEnum != PeriodEnum.ALL_TIME) {
			Logger.debug("calculating time sum for {} containing {}", periodEnum.name(), date);
		} else {
			Logger.debug("calculation time sum for all time");
		}

		TimeInfo infoStart;
		TimeInfo infoEnd;

		switch (periodEnum) {
			case DAY: {
				TimeCalculatorV2 timeCalc = new TimeCalculatorV2(dao, this, date, true);
				timeCalc.calculatePeriod(PeriodEnum.DAY, false);
				return timeCalc.getTimeWorked();
			}

			case WEEK: {
				TimeCalculatorV2 timeCalc = new TimeCalculatorV2(dao, this, date, true);
				timeCalc.calculatePeriod(PeriodEnum.WEEK, false);
				return timeCalc.getTimeWorked();
			}

			case MONTH:
				infoStart = getTimesAt(date.with(firstDayOfMonth()));

				LocalDate endDate = date.with(date.with(TemporalAdjusters.firstDayOfNextMonth()));
				if (endDate.isAfter(LocalDate.now())) {
					endDate = LocalDate.now().plusDays(1);
				}

				infoEnd = getTimesAt(endDate);
				break;
			case ALL_TIME:
				// FIXME better solution
				infoStart = getTimesAt(null);
				infoEnd = getTimesAt(LocalDate.now().plusDays(1));
				break;
			default:
				throw new IllegalArgumentException("unknown period type");
		}

		return (int) (infoEnd.getActual() - infoStart.getActual());
	}

	/**
	 * Get the remaining time for today (in minutes). Takes into account the target work time for
	 * the week and also if this is the last day in the working week.
	 */
	public Integer getMinutesRemaining() {
		boolean toZeroEveryDay = preferences.getBoolean(Key.FLEXI_TIME_TO_ZERO_ON_EVERY_DAY.getName(),
			false);
		OffsetDateTime dateTime = OffsetDateTime.now();
		DayOfWeek weekDay = dateTime.getDayOfWeek();
		if (isWorkDay(weekDay)) {
			int minutesRemaining = 0;
			Logger.debug("isAutoPauseEnabled={}", isAutoPauseEnabled());
			Logger.debug("isAutoPauseTheoreticallyApplicable={}", isAutoPauseTheoreticallyApplicable(dateTime));
			Logger.debug("isAutoPauseApplicable={}", isAutoPauseApplicable(dateTime));
			if (isAutoPauseEnabled() && isAutoPauseTheoreticallyApplicable(dateTime)
					&& !isAutoPauseApplicable(dateTime)) {
				// auto-pause is necessary, but was NOT already taken into account by calculatePeriod():
				Logger.debug("auto-pause is necessary, but was NOT already taken into account by calculateTimeSum()");
				minutesRemaining += getAutoPauseDuration();
			}

			if (!isFollowedByWorkDay(weekDay) || toZeroEveryDay) {
				// reach zero today
				TimeCalculatorV2 timeCalc = new TimeCalculatorV2(dao, this, dateTime.toLocalDate(), true);
				timeCalc.calculatePeriod(PeriodEnum.DAY, true);

				minutesRemaining += (int)-timeCalc.getBalance();
			} else {
				// not the last work day of the week, distribute remaining work time
				TimeCalculatorV2 timeCalc = new TimeCalculatorV2(dao, this, dateTime.toLocalDate(), true);
				timeCalc.calculatePeriod(PeriodEnum.WEEK, false);

				long remainingWeekPerDay = -timeCalc.getBalance() / (timeCalc.getFutureWorkDays() + 1);
				long remainingToday      = -timeCalc.getCurrentDayBalance() + minutesRemaining;

				minutesRemaining = (int)Math.min(remainingToday,remainingWeekPerDay);
			}

			Logger.debug("minutesRemaining={}", minutesRemaining);

			return minutesRemaining;
		} else {
			return null;
		}
	}

	/**
	 * Get the time info at the start of the specific day.
	 */
	public synchronized TimeInfo getTimesAt(LocalDate targetDate) {
		Logger.debug("Calculating times at {}", targetDate);

		TimeInfo ret = new TimeInfo();
		LocalDate startDate;
		CalcCacheEntry cache = null;

		Event event = dao.getFirstEvent();
		if (event == null) {
			return ret;
		}

		if (targetDate != null) {
			cache = dao.getCacheAt(targetDate);
		}

		if (cache == null) {
			Logger.debug("No cache for date {}", targetDate);

			// Start value assumed at start of week
			startDate =  DateTimeUtil.getWeekStart(event.getDateTime().toLocalDate());

			String startValueString = preferences.getString(Key.FLEXI_TIME_START_VALUE.getName(), "0:00");
			int startValue = parseHoursMinutesString(startValueString);

			ret.setActual(startValue);
		} else {
			startDate = cache.getDate();
			Logger.debug("Cache entry found for date {}: {}", startDate, cache.getWorked());

			ret.setActual(cache.getWorked());
			ret.setTarget(cache.getTarget());
		}

		if (targetDate == null || !startDate.isBefore(targetDate)) {
			return ret;
		}

		Logger.debug("Start sum: {}", formatTime(ret.getBalance()));

		long iter = DAYS.between(startDate, targetDate);
		Logger.debug("Date range to calculate: {} -> {}", startDate, targetDate);
		Logger.debug("Number of days to calculate: {}", iter);

		TimeCalculatorV2 timeCalc = new TimeCalculatorV2(dao, this, startDate,true);
		timeCalc.setStartSums(ret);

		// FIXME background task
		LocalDate currentDate = startDate;
		iter = 0;
		while (currentDate.isBefore(targetDate)) {
			timeCalc.calculateNextDay();

			Logger.debug("Sum at {}: {} = {} - {}", currentDate, timeCalc.getBalance(), timeCalc.getTotalTimeWorked(), timeCalc.getTotalTarget());


			currentDate = currentDate.plusDays(1);

			// save checkpoint in cache on Mondays and first day of month,
			// data from the **beginning** of the day (= 0:00)
			// currentDay is one ahead, so if it is today, we can already write the cache

			if (!currentDate.isAfter(LocalDate.now()) &&
					(currentDate.getDayOfWeek() == DayOfWeek.MONDAY || currentDate.getDayOfMonth() == 1)) {
				Logger.debug("Saving checkpoint for date: {}", currentDate.format(DateTimeFormatter.ISO_LOCAL_DATE));

				CalcCacheEntry cacheEntry = new CalcCacheEntry(currentDate, timeCalc.getTotalTimeWorked(), timeCalc.getTotalTarget());
				Logger.debug("Data: {}", cacheEntry);

				dao.insertCache(cacheEntry);
			}

			iter++;
		}
		Logger.debug("Calculated {} days", iter);

		if (timeCalc.withFlexiTime()) {
			Logger.debug("Calculated flexi time:       {}", timeCalc.getBalance());
		}

		ret.setActual(timeCalc.getTotalTimeWorked());
		ret.setTarget(timeCalc.getTotalTarget());

		Logger.debug("DONE getTimesAt({}): actual={}, target={}",
				targetDate, ret.getActual(), ret.getTarget());
		Logger.debug("--");

		return ret;
	}

	/**
	 * Parse a value of hours and minutes (positive or negative).
	 */
	public static int parseHoursMinutesString(String hoursMinutes) {
		if (hoursMinutes != null) {
			String[] startValueArray = hoursMinutes.replaceAll("[- ]", "").split("[:.]");
			int hours = Integer.parseInt(startValueArray[0]);
			int minutes = startValueArray.length > 1 ? Integer.parseInt(startValueArray[1]) : 0;

			if (hoursMinutes.trim().startsWith("-")) {
				return -1 * (hours * 60 + minutes);
			} else {
				return hours * 60 + minutes;
			}
		}
		return 0;
	}

	/**
	 * Get the normal work time (in minutes) for a specific week day.
	 */
	public int getNormalWorkDurationFor(DayOfWeek weekDay) {
		if (isWorkDay(weekDay)) {
			String targetValueString = preferences.getString(Key.FLEXI_TIME_TARGET.getName(), "0:00");
			targetValueString = DateTimeUtil.refineHourMinute(targetValueString);
			int targetValue = parseHoursMinutesString(targetValueString);
			BigDecimal minutes = new BigDecimal(targetValue).divide(new BigDecimal(countWorkDays()),
				RoundingMode.HALF_UP);
			return minutes.intValue();
		} else {
			return 0;
		}
	}

	public int countWorkDays() {
		int ret = 0;
		for (DayOfWeek day : DayOfWeek.values()) {
			if (isWorkDay(day)) {
				ret++;
			}
		}
		return ret;
	}

	/**
	 * Is the next day after the given day marked as work day?
	 */
	private boolean isFollowedByWorkDay(DayOfWeek day) {
		DayOfWeek nextDay = day.plus(1);
		return isWorkDay(nextDay);
	}

	/**
	 * Is this a work day?
	 */
	public boolean isWorkDay(DayOfWeek weekDay) {
		Key key;
		switch (weekDay) {
			case MONDAY:
				key = Key.FLEXI_TIME_DAY_MONDAY;
				break;
			case TUESDAY:
				key = Key.FLEXI_TIME_DAY_TUESDAY;
				break;
			case WEDNESDAY:
				key = Key.FLEXI_TIME_DAY_WEDNESDAY;
				break;
			case THURSDAY:
				key = Key.FLEXI_TIME_DAY_THURSDAY;
				break;
			case FRIDAY:
				key = Key.FLEXI_TIME_DAY_FRIDAY;
				break;
			case SATURDAY:
				key = Key.FLEXI_TIME_DAY_SATURDAY;
				break;
			case SUNDAY:
				key = Key.FLEXI_TIME_DAY_SUNDAY;
				break;
			default:
				throw new IllegalArgumentException("unknown weekday");
		}
		return preferences.getBoolean(key.getName(), false);
	}

	/**
	 * Create a new event at current time + the given minute amount.
	 *
	 * @param minutesToPredate
	 *            how many minutes to add to "now"
	 * @param taskId
	 *            the task id (may be {@code null})
	 * @param type
	 *            the type
	 * @param text
	 *            the text (may be {@code null})
	 */
	public void createEvent(int minutesToPredate, Integer taskId, TypeEnum type, String text) {
		if (minutesToPredate < 0) {
			throw new IllegalArgumentException("no negative minute amount allowed");
		}
		ZonedDateTime targetTime = ZonedDateTime.now().plusMinutes(minutesToPredate);
		createEvent(targetTime.toOffsetDateTime(), taskId, type, text);
	}

	/**
	 * Create a new event at the given time.
	 *
	 * @param dateTime
	 *            the time for which the new event should be created
	 * @param taskId
	 *            the task id (may be {@code null})
	 * @param type
	 *            the type
	 * @param text
	 *            the text (may be {@code null})
	 */
	public void createEvent(OffsetDateTime dateTime, Integer taskId, TypeEnum type, String text) {
		createEvent(dateTime, taskId, type, text, false);
	}

	/**
	 * Create a new event at the given time.
	 *
	 * @param dateTime
	 *            the time for which the new event should be created
	 * @param taskId
	 *            the task id (may be {@code null})
	 * @param type
	 *            the type
	 * @param text
	 *            the text (may be {@code null})
	 * @param insertedByRestore
	 *            true if the event is inserted by a restore. In that case, auto pause and refresh of notifications are
	 *            suppressed.
	 */
	public void createEvent(OffsetDateTime dateTime, Integer taskId, TypeEnum type, String text, boolean insertedByRestore) {
		if (dateTime == null) {
			throw new IllegalArgumentException("date/time has to be given");
		}
		if (type == null) {
			throw new IllegalArgumentException("type has to be given");
		}

		if (!insertedByRestore && type == TypeEnum.CLOCK_OUT) {
			tryToInsertAutoPause(dateTime);
		}

		Event event = new Event(null, taskId, type.getValue(), dateTime, text);
		Logger.debug("TRACKING: {} @ {} taskId={} text={}", type.name(), dateTime, taskId, text);
		dao.insertEvent(event);
		dao.deleteCacheFrom(event.getDateTime().toLocalDate());

		if (!insertedByRestore) {
			Basics.getInstance().safeCheckExternalControls();
		}
	}

	// TODO General invalidate function (possibly with notification)
	public void invalidateCacheFrom(OffsetDateTime date) {
		// convert date to home time zone
		LocalDate dbDate = date.atZoneSameInstant(getHomeTimeZone()).toLocalDate();
		dao.deleteCacheFrom(dbDate);
	}

	public void invalidateCacheFrom(LocalDate date) {
		dao.deleteCacheFrom(date);
	}

	/**
	 * Create a new NON-PERSISTENT (!) event of the type CLOCK_OUT_NOW.
	 */
	public static Event createClockOutNowEvent() {
		return new Event(null, null, TypeEnum.CLOCK_OUT_NOW.getValue(), OffsetDateTime.now(), null);
	}

	private void tryToInsertAutoPause(OffsetDateTime dateTime) {
		if (isAutoPauseEnabled() && isAutoPauseApplicable(dateTime)) {
			// insert auto-pause events
			OffsetDateTime begin = dateTime.with(getAutoPauseBegin());
			OffsetDateTime end   = dateTime.with(getAutoPauseEnd());
			Logger.debug("inserting auto-pause, begin={}, end={}", begin, end);

			Event lastBeforePause = dao.getLastEventBefore(begin);
			createEvent(begin, null,TypeEnum.CLOCK_OUT, null);
			createEvent(end, (lastBeforePause == null ? null : lastBeforePause.getTask()), TypeEnum.CLOCK_IN,
				(lastBeforePause == null ? null : lastBeforePause.getText()));
		} else {
			Logger.debug("NOT inserting auto-pause");
		}
	}

	/**
	 * Determines if the auto-pause mechanism is enabled.
	 */
	public boolean isAutoPauseEnabled() {
		return preferences.getBoolean(Key.AUTO_PAUSE_ENABLED.getName(), false);
	}

	/**
	 * Determines if the auto-pause can be applied to the given day.
	 */
	public boolean isAutoPauseApplicable(OffsetDateTime dateTime) {
		OffsetDateTime end = dateTime.with(getAutoPauseEnd());
		// auto-pause is theoretically applicable
		return isAutoPauseTheoreticallyApplicable(dateTime)
			// given time is after auto-pause end, so auto-pause should really be applied
			&& dateTime.isAfter(end);
	}

	private boolean isAutoPauseTheoreticallyApplicable(OffsetDateTime date) {
		OffsetDateTime begin = date.with(getAutoPauseBegin());
		OffsetDateTime end   = date.with(getAutoPauseEnd());

		if (begin.isBefore(end)) {
			Event lastEventBeforeBegin = dao.getLastEventBefore(begin);
			Event lastEventBeforeEnd = dao.getLastEventBefore(end);
				// is clocked in before begin
			return lastEventBeforeBegin != null && lastEventBeforeBegin.getType().equals(TypeEnum.CLOCK_IN.getValue())
				// no event is in auto-pause interval
				&& lastEventBeforeBegin.getId().equals(lastEventBeforeEnd.getId());
		} else {
			// begin is equal to end or (even worse) begin is after end => no auto-pause
			return false;
		}
	}

	/**
	 * Calculates the begin of the auto-pause
	 */
	public LocalTime getAutoPauseBegin() {
		return DateTimeUtil.parseTime(getAutoPauseData(Key.AUTO_PAUSE_BEGIN.getName(), "23.59"));
	}

	/**
	 * Calculates the end of the auto-pause
	 */
	public LocalTime getAutoPauseEnd() {
		return DateTimeUtil.parseTime(getAutoPauseData(Key.AUTO_PAUSE_END.getName(), "00.00"));
	}

	/**
	 * Calculates the length of the auto-pause for the given day (in minutes).<br>
	 * Note: does *not* account for time changeovers
	 */
	public long getAutoPauseDuration() {
		return TimerManager.timeDiff(getAutoPauseBegin(), getAutoPauseEnd());
	}

	private String getAutoPauseData(String key, String defaultTime) {
		String ret = preferences.getString(key, defaultTime);
		ret = DateTimeUtil.refineTime(ret);
		return ret;
	}

	// ======== registration of automatic work time tracking methods ========

	public void activateTrackingMethod(TrackingMethod method) {
		Collection<TrackingMethod> activeMethods = readCurrentlyActiveTrackingMethods();
		if (!activeMethods.contains(method)) {
			activeMethods.add(method);
			writeCurrentlyActiveTrackingMethods(activeMethods);
		}
	}

	public void deactivateTrackingMethod(TrackingMethod method) {
		Collection<TrackingMethod> activeMethods = readCurrentlyActiveTrackingMethods();
		if (activeMethods.contains(method)) {
			activeMethods.remove(method);
			writeCurrentlyActiveTrackingMethods(activeMethods);
		}
	}

	private Collection<TrackingMethod> readCurrentlyActiveTrackingMethods() {
		String activeMethodsString = preferences.getString(context.getString(R.string.keyActiveMethods), "");
		String[] activeMethodsStrings = StringUtils.split(activeMethodsString, ',');
		Collection<TrackingMethod> result = new ArrayList<>(activeMethodsStrings.length);
		for (String activeMethodString : activeMethodsStrings) {
			result.add(TrackingMethod.valueOf(activeMethodString));
		}
		return result;
	}

	private void writeCurrentlyActiveTrackingMethods(Collection<TrackingMethod> methods) {
		StringBuilder result = new StringBuilder();
		for (TrackingMethod method : methods) {
			if (result.length() > 0) {
				result.append(",");
			}
			result.append(method.name());
		}
		final Editor editor = preferences.edit();
		editor.putString(context.getString(R.string.keyActiveMethods), result.toString());
		editor.commit();
	}

	public boolean clockInWithTrackingMethod(TrackingMethod method) {
		boolean currentlyClockedInWithMethod = getTrackingMethodClockInState(method);
		if (getTrackingMethodsGenerateEventsSeparately()) {
			Logger.debug("clocking in with method {} forcibly", method.name());
			return setTrackingMethodClockInStateForcibly(method, true);
		} else if (currentlyClockedInWithMethod) {
			Logger.debug("already clocked in with method {}", method.name());
			return false;
		} else {
			Logger.debug("clocking in with method {}", method.name());
			return setTrackingMethodClockInState(method, true);
		}
	}

	public boolean clockOutWithTrackingMethod(TrackingMethod method) {
		boolean currentlyClockedInWithMethod = getTrackingMethodClockInState(method);
		if (getTrackingMethodsGenerateEventsSeparately()) {
			Logger.debug("clocking out with method {} forcibly", method.name());
			return setTrackingMethodClockInStateForcibly(method, false);
		} else if (!currentlyClockedInWithMethod) {
			Logger.debug("not clocked in with method {}", method.name());
			return false;
		} else {
			Logger.debug("clocking out with method {}", method.name());
			return setTrackingMethodClockInState(method, false);
		}
	}

	private boolean getTrackingMethodClockInState(TrackingMethod method) {
		return preferences.getBoolean(context.getString(method.getPreferenceKeyId()), false);
	}

	private boolean getTrackingMethodsGenerateEventsSeparately() {
		return preferences
			.getBoolean(context.getString(R.string.keyEachTrackingMethodGeneratesEventsSeparately), false);
	}

	private boolean setTrackingMethodClockInState(TrackingMethod method, boolean state) {
		final Editor editor = preferences.edit();
		editor.putBoolean(context.getString(method.getPreferenceKeyId()), state);
		editor.commit();
		return createEventIfNecessary(method, state);
	}

	private boolean setTrackingMethodClockInStateForcibly(TrackingMethod method, boolean state) {
		final Editor editor = preferences.edit();
		editor.putBoolean(context.getString(method.getPreferenceKeyId()), state);
		editor.commit();
		return createEventForcibly(method, state);
	}

	private boolean createEventIfNecessary(TrackingMethod method, boolean state) {
		if (state) {
			// method is clocked in now - should we generate a clock-in event?
			if (!isClockedInWithAnyOtherTrackingMethod(method) && !isTracking()) {
				// we are not clocked in already by hand or by another method, so generate an event (first method
				// clocking in)
				startTracking(0, null, null);
				Logger.debug("method {}: started tracking", method);
				return true;
			} else {
				Logger.debug("method {}: NOT started tracking (was not first method or already clocked in manually)",
					method);
				return false;
			}
		} else {
			// method is clocked out now - should we generate a clock-out event?
			if (!isClockedInWithAnyOtherTrackingMethod(method) && isTracking()) {
				// we are not clocked in by hand or by another method, so generate an event (last method clocking out)
				stopTracking(0);
				Logger.debug("method {}: stopped tracking", method);
				return true;
			} else {
				Logger.debug("method {}: NOT stopped tracking (was not last method or already clocked out manually)",
					method);
				return false;
			}
		}
	}

	private boolean createEventForcibly(TrackingMethod method, boolean state) {
		if (state) {
			// method is clocked in now - should we generate a clock-in event?
			if (!isTracking()) {
				startTracking(0, null, null);
				Logger.debug("method {}: started tracking forcibly", method);
				return true;
			} else {
				Logger.debug("method {}: NOT started tracking forcibly (already clocked in)",
					method);
				return false;
			}
		} else {
			// method is clocked out now - should we generate a clock-out event?
			if (isTracking()) {
				stopTracking(0);
				Logger.debug("method {}: stopped tracking forcibly", method);
				return true;
			} else {
				Logger.debug("method {}: NOT stopped tracking forcibly (already clocked out)",
					method);
				return false;
			}
		}
	}

	private boolean isClockedInWithAnyOtherTrackingMethod(TrackingMethod methodToIgnore) {
		Collection<TrackingMethod> activeMethods = readCurrentlyActiveTrackingMethods();
		for (TrackingMethod method : activeMethods) {
			if (method.equals(methodToIgnore)) {
				continue;
			}
			if (isClockedInWithTrackingMethod(method)) {
				return true;
			}
		}
		return false;
	}

	private boolean isClockedInWithTrackingMethod(TrackingMethod method) {
		return getTrackingMethodClockInState(method);
	}

	public static String formatTime(long time) {
		String sgn = (time < 0) ? "-" : "";
		return String.format(Locale.US, "%s%02d:%02d", sgn, Math.abs(time / 60), Math.abs(time % 60));
	}

	public static long timeDiff(OffsetDateTime startTime, OffsetDateTime endTime) {
		return MINUTES.between(startTime.truncatedTo(MINUTES), endTime.truncatedTo(MINUTES));
	}

	public static long timeDiff(LocalTime startTime, LocalTime endTime) {
		return MINUTES.between(startTime.truncatedTo(MINUTES), endTime.truncatedTo(MINUTES));
	}
} public class DateTextViewController {

	private final TextView view;

	@Nullable
	private LocalDate date;

	@Nullable
	private ZonedDateTime min;

	@Nullable
	private ZonedDateTime max;

	@Nullable
	private final Consumer<LocalDate> externalListener;

	public DateTextViewController(@NonNull TextView view, @Nullable Consumer<LocalDate> externalListener) {
		this.view = view;
		this.externalListener = externalListener;
		view.setOnClickListener(v -> showDatePicker());
	}

	public DateTextViewController(@NonNull TextView view) {
		this(view, null);
	}

	private void showDatePicker() {
		LocalDate date = getInitialPickerDate();
		DatePickerDialog dialog = new DatePickerDialog(
				view.getContext(),
				this::onNewDateSelected,
				date.getYear(),
				date.getMonthValue() - 1,
				date.getDayOfMonth()
		);
		setDateLimits(dialog);
		dialog.show();
	}

	private LocalDate getInitialPickerDate() {
		if (date == null) {
			return LocalDate.now();
	 	} else {
			return date;
		}
	}

	private void onNewDateSelected(DatePicker picker, int year, int month, int day) {
		LocalDate newDate = LocalDate.of(year, month + 1, day);
		setDate(newDate);
		if (externalListener != null) {
			externalListener.accept(newDate);
		}
	}

	private void setDateLimits(DatePickerDialog dialog) {
		DatePicker picker = dialog.getDatePicker();
		if (min != null) {
			picker.setMinDate(dateToEpoch(min));
		}
		if (max != null) {
			picker.setMaxDate(dateToEpoch(max));
		}
	}

	public void setDate(LocalDate date) {
		String text = DateTimeUtil.formatLocalizedDayAndDate(date);
		view.setText(text);
		this.date = date;
	}

	@Nullable
	public LocalDate getDate() {
		return date;
	}

	public void setDateLimits(
			@Nullable ZonedDateTime minInclusive,
			@Nullable ZonedDateTime maxInclusive
	) {
		min = minInclusive;
		max = maxInclusive;
	}

}
import android.os.Bundle;
import android.view.MenuItem;
import android.view.View;
import android.widget.ArrayAdapter;
import android.widget.EditText;
import android.widget.Spinner;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.ActionBar;
import androidx.appcompat.app.AppCompatActivity;
import org.pmw.tinylog.Logger;
import org.threeten.bp.LocalDate;
import org.threeten.bp.LocalDateTime;
import org.threeten.bp.LocalTime;
import org.threeten.bp.OffsetDateTime;
import org.threeten.bp.ZoneId;
import org.threeten.bp.ZonedDateTime;
import org.zephyrsoft.trackworktime.Basics;
import org.zephyrsoft.trackworktime.Constants;
import org.zephyrsoft.trackworktime.EventListActivity;
import org.zephyrsoft.trackworktime.R;
import org.zephyrsoft.trackworktime.database.DAO;
import org.zephyrsoft.trackworktime.databinding.EventBinding;
import org.zephyrsoft.trackworktime.model.Event;
import org.zephyrsoft.trackworktime.model.Task;
import org.zephyrsoft.trackworktime.model.TypeEnum;
import org.zephyrsoft.trackworktime.model.Week;
import org.zephyrsoft.trackworktime.timer.TimerManager;
import org.zephyrsoft.trackworktime.ui.DateTextViewController;
import java.util.List;
/*
 * This file is part of TrackWorkTime (TWT).
 *
 * TWT is free software: you can redistribute it and/or modify it
 * under the terms of the GNU General Public License 3.0 as published by
 * the Free Software Foundation.
 *
 * TWT is distributed in the hope that it will be useful, but
 * WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
 * GNU General Public License 3.0 for more details.
 *
 * You should have received a copy of the GNU General Public License 3.0
 * along with TWT. If not, see <http://www.gnu.org/licenses/>.
 */
package org.zephyrsoft.trackworktime.editevent;





/**
 * Activity for managing the events of a week.
 */
public class EventEditActivity extends AppCompatActivity {

	private DAO dao = null;
	private TimerManager timerManager = null;