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 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 enum TrackingMethod {

	LOCATION(R.string.keyClockedInByLocation),
	WIFI(R.string.keyClockedInByWifi);

	private final int preferenceKeyId;

	TrackingMethod(int preferenceKeyId) {
		this.preferenceKeyId = preferenceKeyId;
	}

	public int getPreferenceKeyId() {
		return preferenceKeyId;
	}

} public class CalcCacheEntry extends Base implements Comparable<CalcCacheEntry> {
	private LocalDate date = null;
	private Long worked = null;
	private Long target = null;

	public CalcCacheEntry() {
		// do nothing
	}

	public CalcCacheEntry(LocalDate date, Long worked, Long target) {
		this.date = date;
		this.worked = worked;
		this.target = target;
	}

	public Long getDateAsId() {
		return this.date.toEpochDay();
	}

	public LocalDate getDate() {
		return this.date;
	}

	public Long getWorked() {
		return worked;
	}

	public Long getTarget() {
		return target;
	}

	public void setDateFromId(Long id) {
		this.date = LocalDate.ofEpochDay(id);
	}

	public void setDate(LocalDate date) {
		this.date = date;
	}

	public void setWorked(Long worked) {
		this.worked = worked;
	}

	public void setTarget(Long target) {
		this.target = target;
	}

	@Override
	public int compareTo(CalcCacheEntry another) {
		return compare(getDate(), another.getDate(), compare(getDateAsId(), another.getDateAsId(), 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 date.toString() + " / " + getWorked() + " / " + getTarget();
	}

} 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 enum FlexiReset {
	NONE(1, Unit.NULL, "none"),
	DAILY(1, Unit.DAY, "daily"),
	WEEKLY(1, Unit.WEEK, "weekly"),
	MONTHLY(1, Unit.MONTH, "monthly"),
	QUARTERLY(3, Unit.MONTH, "quarterly"),
	HALF_YEARLY(6, Unit.MONTH, "half-yearly"),
	YEARLY(12, Unit.MONTH, "yearly");

	private final int intervalSize;
	private final Unit intervalUnit;
	private final String friendlyName;

	FlexiReset(@IntRange(from=1) int intervalSize, @NonNull Unit intervalUnit,
			@NonNull String friendlyName) {
		this.intervalSize = intervalSize;
		this.intervalUnit = intervalUnit;
		this.friendlyName = friendlyName;
	}

	public String getFriendlyName() {
		return friendlyName;
	}

	public @NonNull LocalDate getLastResetDate(LocalDate fromDate) {
		switch (intervalUnit) {
			case NULL:
				return LocalDate.ofEpochDay(0);
			case DAY:
				return calcLastResetDayForDay(fromDate);
			case WEEK:
				return calcLastResetDayForWeek(fromDate);
			case MONTH:
				return calcLastResetDayForMonth(fromDate);
			default:
				throw new UnsupportedOperationException(intervalUnit.toString());
		}
	}

	public @NonNull LocalDate getNextResetDate(LocalDate fromDate) {
		switch (intervalUnit) {
			case NULL:
				return LocalDate.ofEpochDay(0);
			case DAY:
				return calcLastResetDayForDay(fromDate).plusDays(intervalSize);
			case WEEK:
				return calcLastResetDayForWeek(fromDate).plusWeeks(intervalSize);
			case MONTH:
				return calcLastResetDayForMonth(fromDate).plusMonths(intervalSize);
			default:
				throw new UnsupportedOperationException(intervalUnit.toString());
		}
	}

	public boolean isResetDay(LocalDate day) {
		LocalDate resetDay;
		switch(intervalUnit) {
			case NULL:
				return false;
			case DAY:
				resetDay = calcLastResetDayForDay(day); break;
			case WEEK:
				resetDay = calcLastResetDayForWeek(day); break;
			case MONTH:
				resetDay = calcLastResetDayForMonth(day); break;
			default: throw new UnsupportedOperationException(intervalUnit.toString());
		}

		return resetDay.isEqual(day);
	}

	private LocalDate calcLastResetDayForDay(LocalDate fromDate) {
		int daysDelta = (fromDate.getDayOfYear() - 1) % intervalSize;
		return fromDate.minusDays(daysDelta);
	}

	private LocalDate calcLastResetDayForWeek(LocalDate fromDate) {
		// starting point is the first Monday of the year
		// FIXME fails with intervalSize != 1
		LocalDate startDate =
				fromDate.withDayOfYear(1).with(TemporalAdjusters.nextOrSame(DayOfWeek.MONDAY));

		long weekDelta = ChronoUnit.WEEKS.between(startDate, fromDate) % intervalSize;

		return fromDate.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)).minusWeeks(weekDelta);
	}

	private LocalDate calcLastResetDayForMonth(LocalDate fromDate) {
		int monthsDelta = (fromDate.getMonthValue() - 1) % intervalSize;
		return fromDate.minusMonths(monthsDelta).withDayOfMonth(1);
	}

	public static FlexiReset loadFromPreferences(SharedPreferences preferences) {
		String key = Key.FLEXI_TIME_RESET_INTERVAL.getName();
		String defaultValue = FlexiReset.NONE.name();
		String string = preferences.getString(key, defaultValue);
		return valueOf(string);
	}

} public enum PeriodEnum {
	/** only one day */
	DAY,
	/** one week */
	WEEK,
	/** one month */
	MONTH,
	/** all time */
	ALL_TIME
} 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 class TimeInfo {
	private long actual = 0;
	private long target = 0;

	public Long getActual() {
		return actual;
	}

	public void setActual(long actual) {
		this.actual = actual;
	}

	public Long getTarget() {
		return target;
	}

	public void setTarget(long target) {
		this.target = target;
	}

	public Long getBalance() {
		return actual - target;
	}
} 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 enum Key {

	HOME_TIME_ZONE("keyHomeTimezone", DataType.TIMEZONEID, null, R.string.homeTimezone),

	ENABLE_FLEXI_TIME("keyEnableFlexiTime", DataType.BOOLEAN, null, R.string.enableFlexiTime),
	FLEXI_TIME_START_VALUE("keyFlexiTimeStartValue", DataType.HOUR_MINUTE, ENABLE_FLEXI_TIME,
		R.string.flexiTimeStartValue),
	FLEXI_TIME_TARGET("keyFlexiTimeTarget", DataType.HOUR_MINUTE, ENABLE_FLEXI_TIME, R.string.flexiTimeTarget),
	FLEXI_TIME_DAY_MONDAY("keyFlexiTimeDayMonday", DataType.BOOLEAN, ENABLE_FLEXI_TIME, R.string.mondayLong),
	FLEXI_TIME_DAY_TUESDAY("keyFlexiTimeDayTuesday", DataType.BOOLEAN, ENABLE_FLEXI_TIME, R.string.tuesdayLong),
	FLEXI_TIME_DAY_WEDNESDAY("keyFlexiTimeDayWednesday", DataType.BOOLEAN, ENABLE_FLEXI_TIME, R.string.wednesdayLong),
	FLEXI_TIME_DAY_THURSDAY("keyFlexiTimeDayThursday", DataType.BOOLEAN, ENABLE_FLEXI_TIME, R.string.thursdayLong),
	FLEXI_TIME_DAY_FRIDAY("keyFlexiTimeDayFriday", DataType.BOOLEAN, ENABLE_FLEXI_TIME, R.string.fridayLong),
	FLEXI_TIME_DAY_SATURDAY("keyFlexiTimeDaySaturday", DataType.BOOLEAN, ENABLE_FLEXI_TIME, R.string.saturdayLong),
	FLEXI_TIME_DAY_SUNDAY("keyFlexiTimeDaySunday", DataType.BOOLEAN, ENABLE_FLEXI_TIME, R.string.sundayLong),
	FLEXI_TIME_RESET_INTERVAL("keyFlexiTimeResetInterval", DataType.ENUM_NAME,
			ENABLE_FLEXI_TIME, R.string.flexiTimeResetInterval),

	FLATTENING_ENABLED("keyFlatteningEnabled", DataType.BOOLEAN, null, R.string.flatteningEnabled),
	SMALLEST_TIME_UNIT("keySmallestTimeUnit", DataType.INTEGER, FLATTENING_ENABLED, R.string.smallestTimeUnit),

	LOCATION_BASED_TRACKING_ENABLED("keyLocationBasedTrackingEnabled", DataType.BOOLEAN, null,
		R.string.enableLocationBasedTracking),
	LOCATION_BASED_TRACKING_VIBRATE("keyLocationBasedTrackingVibrate", DataType.BOOLEAN,
		LOCATION_BASED_TRACKING_ENABLED, R.string.locationBasedTrackingVibrate),
	LOCATION_BASED_TRACKING_LATITUDE("keyLocationBasedTrackingLatitude", DataType.DOUBLE,
		LOCATION_BASED_TRACKING_ENABLED, R.string.workplaceLatitude),
	LOCATION_BASED_TRACKING_LONGITUDE("keyLocationBasedTrackingLongitude", DataType.DOUBLE,
		LOCATION_BASED_TRACKING_ENABLED, R.string.workplaceLongitude),
	LOCATION_BASED_TRACKING_TOLERANCE("keyLocationBasedTrackingTolerance", DataType.INTEGER,
		LOCATION_BASED_TRACKING_ENABLED, R.string.trackingTolerance),
	LOCATION_BASED_TRACKING_IGNORE_BEFORE_EVENTS("keyLocationBasedTrackingIgnoreBeforeEvents",
		DataType.INTEGER_OR_EMPTY, LOCATION_BASED_TRACKING_ENABLED, R.string.ignoreBefore),
	LOCATION_BASED_TRACKING_IGNORE_AFTER_EVENTS("keyLocationBasedTrackingIgnoreAfterEvents", DataType.INTEGER_OR_EMPTY,
		LOCATION_BASED_TRACKING_ENABLED, R.string.ignoreAfter),

	AUTO_PAUSE_ENABLED("keyAutoPauseEnabled", DataType.BOOLEAN, null, R.string.autoPauseEnabled),
	AUTO_PAUSE_BEGIN("keyAutoPauseBegin", DataType.TIME, AUTO_PAUSE_ENABLED, R.string.autoPauseBegin),
	AUTO_PAUSE_END("keyAutoPauseEnd", DataType.TIME, AUTO_PAUSE_ENABLED, R.string.autoPauseEnd),

	NOTIFICATION_ENABLED("keyNotificationEnabled", DataType.BOOLEAN, null, R.string.notificationEnabled),
	NOTIFICATION_USES_FLEXI_TIME_AS_TARGET("keyNotificationUsesFlexiTimeAsTarget", DataType.BOOLEAN,
		NOTIFICATION_ENABLED, R.string.notificationUsesFlexiTimeAsTarget),
	FLEXI_TIME_TO_ZERO_ON_EVERY_DAY("keyFlexiTimeToZeroOnEveryDay", DataType.BOOLEAN, NOTIFICATION_ENABLED,
		R.string.flexiTimeToZeroOnEveryDay),
	NOTIFICATION_ON_PEBBLE("keyPebbleNotification", DataType.BOOLEAN, null, R.string.pebbleNotification),

	WIFI_BASED_TRACKING_ENABLED("keyWifiBasedTrackingEnabled", DataType.BOOLEAN, null, R.string.enableWifiBasedTracking),
	WIFI_BASED_TRACKING_VIBRATE("keyWifiBasedTrackingVibrate", DataType.BOOLEAN, WIFI_BASED_TRACKING_ENABLED,
		R.string.wifiBasedTrackingVibrate),
	WIFI_BASED_TRACKING_SSID("keyWifiBasedTrackingSSID", DataType.SSID, WIFI_BASED_TRACKING_ENABLED,
		R.string.workplaceWifiSSID),

	AUTOMATIC_TRACKING_METHODS_GENERATE_EVENTS_SEPARATELY(
		"keyEachTrackingMethodGeneratesEventsSeparately", DataType.BOOLEAN, null,
		R.string.methodsGenerateEventsSeparately),

	REPORT_LAST_RANGE("keyReportLastUsedRange", DataType.INTEGER, null, null),
	REPORT_LAST_UNIT("keyReportLastUsedUnit", DataType.INTEGER, null, null),
	REPORT_LAST_GROUPING("keyReportLastUsedGrouping", DataType.INTEGER, null, null);

	private final String name;
	private final DataType dataType;
	private final Key parent;
	private final Integer readableNameResourceId;

	Key(String name, DataType dataType, Key parent, Integer readableNameResourceId) {
		this.name = name;
		this.dataType = dataType;
		this.parent = parent;
		this.readableNameResourceId = readableNameResourceId;
	}

	/**
	 * Get the name for use in SharedPreferences.getXXX(name, ...).
	 */
	public String getName() {
		return name;
	}

	public DataType getDataType() {
		return dataType;
	}

	public Key getParent() {
		return parent;
	}

	public Integer getReadableNameResourceId() {
		return readableNameResourceId;
	}

	public static Key getKeyWithName(String name) {
		for (Key key : values()) {
			if (key.getName().equals(name)) {
				return key;
			}
		}
		return null;
	}

	public static Set<Key> getChildKeys(Key parentKey) {
		Set<Key> ret = new HashSet<>();
		for (Key key : values()) {
			if (key.getParent() == parentKey) {
				ret.add(key);
			}
		}
		return ret;
	}

} public class DateTimeUtil {
	private static final DateTimeFormatter LOCALIZED_DATE = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM);
	private static final DateTimeFormatter LOCALIZED_DATE_SHORT = DateTimeFormatter.ofLocalizedDate(FormatStyle.SHORT);
	private static final DateTimeFormatter LOCALIZED_TIME = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT);
	private static final DateTimeFormatter DATE = DateTimeFormatter.ofPattern("yyyy-MM-dd");
	private static final DateTimeFormatter TIMESTAMP = DateTimeFormatter.ofPattern("yyyy-MM-dd-HH-mm-ss");
	private static final DateTimeFormatter LOCALIZED_DAY_AND_DATE = new DateTimeFormatterBuilder()
			.appendPattern("eeee")
			.appendLiteral(", ")
			.appendLocalized(FormatStyle.SHORT, null)
			.toFormatter();

	/** E.g. Fri, 12.9 */
	private static final DateTimeFormatter LOCALIZED_DAY_AND_SHORT_DATE =
			createLocalizedDayAndShortDateFormat();

	private static DateTimeFormatter createLocalizedDayAndShortDateFormat() {
		String shortDate = DateTimeFormatterBuilder.getLocalizedDateTimePattern(
				FormatStyle.SHORT,
				null,
				IsoChronology.INSTANCE,
				Locale.getDefault())
				// remove year and some year-separators (at least in german dates, the dot after the month has to exist)
				.replaceAll("[ /-]? *[yY]+ *[ 年/.-]?", "");
		String pattern = "eee, " + shortDate;
		return DateTimeFormatter.ofPattern(pattern);
	}

	/**
	 * Determines if the given {@link LocalDateTime} is in the future.
	 */
	public static boolean isInFuture(LocalDateTime dateTime) {
		return dateTime.isAfter(LocalDateTime.now());
	}

	public static boolean isInFuture(OffsetDateTime dateTime) {
		return dateTime.isAfter(OffsetDateTime.now());
	}

	/**
	 * Determines if the given {@link LocalDateTime} is in the past.
	 */
	public static boolean isInPast(LocalDateTime dateTime) {
		return dateTime.isBefore(LocalDateTime.now());
	}

	public static boolean isInPast(OffsetDateTime dateTime) {
		return dateTime.isBefore(OffsetDateTime.now());
	}

	public static LocalDate getWeekStart(LocalDate date) {
		return date.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY));
	}

	public static LocalDateTime getWeekStart(LocalDateTime dateTime) {
		return dateTime.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY));
	}

	public static ZonedDateTime getWeekStart(ZonedDateTime dateTime) {
		return dateTime.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY));
	}

	/**
	 * Formats a {@link OffsetDateTime} to a String.
	 *
	 * @param dateTime the input (may not be null)
	 * @return the String which corresponds to the given input
	 */
	public static String formatLocalizedDateTime(OffsetDateTime dateTime) {
		return formatLocalizedDate(dateTime.toLocalDate()) + " / " + formatLocalizedTime(dateTime);
	}

	/**
	 * Formats a {@link LocalDate} to a String.
	 *
	 * @param date the input (may not be null)
	 * @return the String which corresponds to the given input
	 */
	public static String formatLocalizedDate(LocalDate date) {
		return date.format(LOCALIZED_DATE);
	}

	public static String formatLocalizedDateShort(TemporalAccessor temporal) {
		return LOCALIZED_DATE_SHORT.format(temporal);
	}

	/**
	 * Formats a {@link TemporalAccessor} to a String, containing only hours and minutes.
	 *
	 * @param temporal the input (may not be null)
	 * @return the String which corresponds to the given input
	 */
	public static String formatLocalizedTime(TemporalAccessor temporal) {
		return LOCALIZED_TIME.format(temporal);
	}

	/**
	 * Formats a {@link TemporalAccessor} to a String.
	 *
	 * @param date the input (may not be null)
	 * @return the String which corresponds to the given input. E.g. "Friday, 22.2.2222".
	 */
	public static String formatLocalizedDayAndDate(TemporalAccessor date) {
		String dateString = LOCALIZED_DAY_AND_DATE.format(date);
		return StringUtils.capitalize(dateString);
	}

	public static String formatLocalizedDayAndShortDate(TemporalAccessor date) {
		String dateString = LOCALIZED_DAY_AND_SHORT_DATE.format(date)
				// remove possible abbreviation point ("Mo., 27.09." (german) or "Lun., 27/09" (french) looks odd):
				.replaceAll("^(\\p{Alpha}+)\\., ", "$1, ");
		return StringUtils.capitalize(dateString);
	}

	/**
	 * Formats a {@link ZonedDateTime} to a String.
	 *
	 * @param dateTime the input (may not be null)
	 * @return the String which corresponds to the given input
	 */
	public static String dateToULString(ZonedDateTime dateTime) {
		return dateTime.format(DATE);
	}

	/**
	 * Parse a time string to a LocalTime
	 *
	 * @param timeString a String which contains the hour and minute only, e.g. "14:30"
	 * @return a LocalTime which represents the given time
	 */
	public static LocalTime parseTime(String timeString) {
		return LocalTime.parse(refineTime(timeString));
	}

	/**
	 * Prepare the a user-entered time string to suffice for {@link #parseTime}.
	 */
	public static String refineTime(String timeString) {
		String ret = refineHourMinute(timeString);
		// append seconds
		ret += ":00";
		return ret;
	}

	/**
	 * Prepare the a user-entered time string to represent "hours:minutes".
	 * TODO DateTimeFormatterBuilder
	 */
	public static String refineHourMinute(String timeString) {
		if (timeString == null || timeString.isEmpty()) {
			// empty means midnight
			return "00:00";
		}
		String ret = timeString.replace('.', ':');
		// cut off seconds if present
		ret = ret.replaceAll("^(\\d\\d?):(\\d\\d?):.*$", "$1:$2");
		// fix only one digit as hour
		ret = ret.replaceAll("^(\\d):", "0$1:");
		// fix only one digit as minute
		ret = ret.replaceAll(":(\\d)$", ":0$1");
		return ret;
	}

	/**
	 * Return the given number left-padded with 0 if applicable.
	 */
	public static String padToTwoDigits(int number) {
		if (number < 0) {
			throw new IllegalArgumentException("number has to be >= 0");
		} else if (number < 10) {
			return "0" + number;
		} else {
			return String.valueOf(number);
		}
	}

	public static long dateToEpoch(ZonedDateTime accessor) {
		return Instant.from(accessor).toEpochMilli();
	}

	public static boolean isDurationValid(String value) {
		String[] pieces = value.split("[:.]");
		if (pieces.length == 2) {
			try {
				Integer.parseInt(pieces[0]);
				Integer.parseInt(pieces[1]);
				return true;
			} catch (NumberFormatException e) {
				// ignore and return false
			}
		}
		return false;
	}

	public static String formatDuration(Integer duration) {
		if (duration != null) {
			return String.format(Locale.US, "%d:%02d", duration / 60, duration % 60);
		} else {
			return "0:00";
		}
	}

	public static String timestampNow() {
		return LocalDateTime.now().format(TIMESTAMP);
	}
}
import static org.threeten.bp.temporal.ChronoUnit.DAYS;
import static org.threeten.bp.temporal.ChronoUnit.MINUTES;
import static org.threeten.bp.temporal.TemporalAdjusters.firstDayOfMonth;
import android.content.Context;
import android.content.SharedPreferences;
import android.content.SharedPreferences.Editor;
import org.apache.commons.lang3.StringUtils;
import org.pmw.tinylog.Logger;
import org.threeten.bp.DayOfWeek;
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.ZoneOffset;
import org.threeten.bp.ZonedDateTime;
import org.threeten.bp.format.DateTimeFormatter;
import org.threeten.bp.temporal.TemporalAdjusters;
import org.zephyrsoft.trackworktime.Basics;
import org.zephyrsoft.trackworktime.R;
import org.zephyrsoft.trackworktime.database.DAO;
import org.zephyrsoft.trackworktime.location.TrackingMethod;
import org.zephyrsoft.trackworktime.model.CalcCacheEntry;
import org.zephyrsoft.trackworktime.model.Event;
import org.zephyrsoft.trackworktime.model.FlexiReset;
import org.zephyrsoft.trackworktime.model.PeriodEnum;
import org.zephyrsoft.trackworktime.model.Task;
import org.zephyrsoft.trackworktime.model.TimeInfo;
import org.zephyrsoft.trackworktime.model.TypeEnum;
import org.zephyrsoft.trackworktime.options.Key;
import org.zephyrsoft.trackworktime.util.DateTimeUtil;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Locale;

	/**
	 * 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());