diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 000000000..7925f4518 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,48 @@ +name: Build calendar APK + +on: + pull_request: + branches: [main] + push: + branches: [feature/business-calendar-month-view] + workflow_dispatch: + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-java@v4 + with: + java-version: 17 + distribution: "temurin" + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Restore signing keystore + env: + SIGNING_STORE_B64: ${{ secrets.SIGNING_STORE_B64 }} + SIGNING_KEY_PASSWORD: ${{ secrets.SIGNING_KEY_PASSWORD }} + SIGNING_STORE_PASSWORD: ${{ secrets.SIGNING_STORE_PASSWORD }} + run: | + if [ -z "$SIGNING_STORE_B64" ]; then + echo "No signing secret present; using default debug signing." + exit 0 + fi + mkdir -p debug-signing + echo "$SIGNING_STORE_B64" | base64 --decode > debug-signing/calendar-release.p12 + printf 'storeFile=debug-signing/calendar-release.p12\nstorePassword=%s\nkeyAlias=calendar\nkeyPassword=%s\nstoreType=pkcs12\n' "$SIGNING_STORE_PASSWORD" "$SIGNING_KEY_PASSWORD" > keystore.properties + + - name: Build debug APK + run: ./gradlew assembleFossDebug + + - name: Upload debug APK + uses: actions/upload-artifact@v4 + with: + name: calendar-foss-debug + path: app/build/outputs/apk/foss/debug/*.apk \ No newline at end of file diff --git a/.gitignore b/.gitignore index 72ae8c3bc..bf9412a97 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,8 @@ /captures keystore.jks keystore.properties +/debug-signing/ fastlane/fastlane.json fastlane/report.xml .kotlin/ +build-apk/ diff --git a/ACCESS-CHECK.md b/ACCESS-CHECK.md new file mode 100644 index 000000000..a1ccfb155 --- /dev/null +++ b/ACCESS-CHECK.md @@ -0,0 +1,2 @@ +# Access check +Written by Cleo at 2026-10-27T22:47:16+11:00 to confirm push rights. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 265ecb738..1a073e6c1 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -47,8 +47,9 @@ android { register("release") { keyAlias = keystoreProperties.getProperty("keyAlias") keyPassword = keystoreProperties.getProperty("keyPassword") - storeFile = file(keystoreProperties.getProperty("storeFile")) + storeFile = rootProject.file(keystoreProperties.getProperty("storeFile")) storePassword = keystoreProperties.getProperty("storePassword") + storeType = keystoreProperties.getProperty("storeType", "jks") } } else if (hasSigningVars()) { register("release") { @@ -70,6 +71,9 @@ android { buildTypes { debug { applicationIdSuffix = ".debug" + if (keystorePropertiesFile.exists() || hasSigningVars()) { + signingConfig = signingConfigs.getByName("release") + } } release { isMinifyEnabled = true diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 4e4a2cd94..326c69ee7 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -64,8 +64,169 @@ + android:theme="@style/SplashTheme"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/kotlin/org/fossify/calendar/activities/MainActivity.kt b/app/src/main/kotlin/org/fossify/calendar/activities/MainActivity.kt index 7b9964b61..dd3db044d 100644 --- a/app/src/main/kotlin/org/fossify/calendar/activities/MainActivity.kt +++ b/app/src/main/kotlin/org/fossify/calendar/activities/MainActivity.kt @@ -6,12 +6,14 @@ import android.content.pm.ShortcutInfo import android.graphics.drawable.ColorDrawable import android.graphics.drawable.Icon import android.graphics.drawable.LayerDrawable +import android.os.Build import android.os.Bundle import android.os.Handler import android.provider.ContactsContract.CommonDataKinds import android.provider.ContactsContract.Contacts import android.provider.ContactsContract.Data import android.view.MenuItem +import android.widget.RelativeLayout import android.widget.Toast import org.fossify.calendar.R import org.fossify.calendar.adapters.EventListAdapter @@ -50,6 +52,9 @@ import org.fossify.calendar.helpers.EVENT_OCCURRENCE_TS import org.fossify.calendar.helpers.FETCH_INTERVAL import org.fossify.calendar.helpers.FLAG_ALL_DAY import org.fossify.calendar.helpers.FLAG_MISSING_YEAR +import org.fossify.calendar.helpers.FOLD_POSTURE_COVER +import org.fossify.calendar.helpers.FOLD_POSTURE_OPEN +import org.fossify.calendar.helpers.FOLD_POSTURE_UNSUPPORTED import org.fossify.calendar.helpers.Formatter import org.fossify.calendar.helpers.Formatter.DAYCODE_PATTERN import org.fossify.calendar.helpers.HOLIDAY_EVENT @@ -114,6 +119,7 @@ import org.fossify.commons.extensions.launchMoreAppsFromUsIntent import org.fossify.commons.extensions.queryCursor import org.fossify.commons.extensions.shortcutManager import org.fossify.commons.extensions.showErrorToast +import org.fossify.commons.extensions.showKeyboard import org.fossify.commons.extensions.toast import org.fossify.commons.extensions.updateTextColors import org.fossify.commons.extensions.viewBinding @@ -140,6 +146,7 @@ import java.text.SimpleDateFormat import java.util.Locale import androidx.core.graphics.drawable.toDrawable import androidx.fragment.app.FragmentManager +import kotlin.math.abs class MainActivity : SimpleActivity(), RefreshRecyclerViewListener { override var isSearchBarEnabled = true @@ -162,12 +169,14 @@ class MainActivity : SimpleActivity(), RefreshRecyclerViewListener { private var mStoredHighlightWeekends = false private var mStoredStartWeekWithCurrentDay = false private var mStoredHighlightWeekendsColor = 0 + private var mStoredMonthViewFontSize = 0 // search results have endless scrolling, so reaching the top/bottom fetches further results private var minFetchedSearchTS = 0L private var maxFetchedSearchTS = 0L private var searchResultEvents = ArrayList() private var bottomItemAtRefresh: ListItem? = null + private var mOpeningFromIntent = false private val binding by viewBinding(ActivityMainBinding::inflate) @@ -176,6 +185,13 @@ class MainActivity : SimpleActivity(), RefreshRecyclerViewListener { setContentView(binding.root) appLaunched(org.fossify.calendar.BuildConfig.APPLICATION_ID) setupOptionsMenu() + binding.mainMenu.requireToolbar().setOnClickListener { + showGoToDateDialog() + } + binding.appBarMonthLabel.setOnClickListener { + showGoToDateDialog() + } + centerAppBarMonthLabel() refreshMenuItems() setupEdgeToEdge( padBottomImeAndSystem = listOf(binding.searchHolder, binding.quickCalendarFilter), @@ -229,7 +245,9 @@ class MainActivity : SimpleActivity(), RefreshRecyclerViewListener { checkIsViewIntent() - if (!checkIsOpenIntent() && savedInstanceState == null) { + mOpeningFromIntent = checkIsOpenIntent() + if (!mOpeningFromIntent && savedInstanceState == null) { + setupDefaultViewForFoldable() updateViewPager() } @@ -254,9 +272,13 @@ class MainActivity : SimpleActivity(), RefreshRecyclerViewListener { override fun onResume() { super.onResume() + if (!mOpeningFromIntent) { + setupDefaultViewForFoldableIfNeeded() + } if (mStoredTextColor != getProperTextColor() || mStoredBackgroundColor != getProperBackgroundColor() || mStoredPrimaryColor != getProperPrimaryColor() || mStoredDayCode != Formatter.getTodayCode() || mStoredDimPastEvents != config.dimPastEvents || mStoredDimCompletedTasks != config.dimCompletedTasks || mStoredHighlightWeekends != config.highlightWeekends || mStoredHighlightWeekendsColor != config.highlightWeekendsColor + || mStoredMonthViewFontSize != config.monthViewFontSize ) { updateViewPager() } @@ -343,6 +365,7 @@ class MainActivity : SimpleActivity(), RefreshRecyclerViewListener { mainMenu.requireToolbar().inflateMenu(R.menu.menu_main) mainMenu.toggleHideOnScroll(false) mainMenu.setupMenu() + setupSearchBar() mainMenu.onSearchTextChangedListener = { text -> searchQueryChanged(text) @@ -372,6 +395,59 @@ class MainActivity : SimpleActivity(), RefreshRecyclerViewListener { } } + private fun setupSearchBar() { + val searchMenu = binding.mainMenu + searchMenu.binding.apply { + topToolbarSearch.beGone() + + val iconParams = topToolbarSearchIcon.layoutParams as RelativeLayout.LayoutParams + iconParams.addRule(RelativeLayout.START_OF, topToolbar.id) + topToolbarSearchIcon.layoutParams = iconParams + + searchMenu.onSearchOpenListener = { + topToolbarSearch.beVisible() + this@MainActivity.binding.appBarMonthLabel.beGone() + } + + searchMenu.onSearchClosedListener = { + topToolbarSearch.beGone() + this@MainActivity.binding.appBarMonthLabel.beVisible() + } + + topToolbarSearchIcon.setOnClickListener { + when { + searchMenu.isSearchOpen -> searchMenu.closeSearch() + + searchMenu.useArrowIcon && searchMenu.onNavigateBackClickListener != null -> + searchMenu.onNavigateBackClickListener!!.invoke() + + else -> { + topToolbarSearch.beVisible() + this@MainActivity.showKeyboard(topToolbarSearch) + } + } + } + } + } + + fun setToolbarTitle(text: String) { + this@MainActivity.binding.appBarMonthLabel.text = text + binding.mainMenu.requireToolbar().contentDescription = text + centerAppBarMonthLabel() + } + + private fun centerAppBarMonthLabel() { + val label = binding.appBarMonthLabel + label.post { + if (label.isVisible()) { + val menu = binding.mainMenu + val pill = menu.binding.toolbarContainer + val pillCenterY = menu.top + menu.binding.searchBarContainer.top + pill.top + pill.height / 2 + label.translationY = (pillCenterY - (label.top + label.height / 2)).toFloat() + } + } + } + override fun onBackPressedCompat(): Boolean { return if (binding.mainMenu.isSearchOpen) { closeSearch() @@ -415,6 +491,7 @@ class MainActivity : SimpleActivity(), RefreshRecyclerViewListener { mStoredHighlightWeekendsColor = highlightWeekendsColor mStoredMidnightSpan = showMidnightSpanningEventsAtTop mStoredStartWeekWithCurrentDay = startWeekWithCurrentDay + mStoredMonthViewFontSize = monthViewFontSize } mStoredDayCode = Formatter.getTodayCode() } @@ -1087,6 +1164,7 @@ class MainActivity : SimpleActivity(), RefreshRecyclerViewListener { } private fun updateView(view: Int) { + config.viewSelectionWasCustomized = true binding.calendarFab.beVisibleIf(view != YEARLY_VIEW && view != WEEKLY_VIEW) val dateCode = getDateCodeToDisplay(view) config.storedView = view @@ -1098,6 +1176,65 @@ class MainActivity : SimpleActivity(), RefreshRecyclerViewListener { } } + private fun setupDefaultViewForFoldable() { + val posture = getCurrentFoldPosture() + if (posture == FOLD_POSTURE_UNSUPPORTED) { + return + } + + config.viewSelectionWasCustomized = false + config.storedView = if (posture == FOLD_POSTURE_COVER) EVENTS_LIST_VIEW else MONTHLY_VIEW + config.lastFoldPosture = posture + } + + private fun setupDefaultViewForFoldableIfNeeded() { + val posture = getCurrentFoldPosture() + if (posture == FOLD_POSTURE_UNSUPPORTED || config.lastFoldPosture == posture) { + return + } + + val previousView = config.storedView + config.viewSelectionWasCustomized = false + config.storedView = if (posture == FOLD_POSTURE_COVER) EVENTS_LIST_VIEW else MONTHLY_VIEW + config.lastFoldPosture = posture + + if (previousView != config.storedView) { + binding.calendarFab.beVisibleIf(config.storedView != YEARLY_VIEW && config.storedView != WEEKLY_VIEW) + checkSwipeRefreshAvailability() + refreshMenuItems() + updateViewPager() + } + } + + private fun getCurrentFoldPosture(): Int { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { + return FOLD_POSTURE_UNSUPPORTED + } + + val smallestScreenWidthDp = resources.configuration.smallestScreenWidthDp + val lastSwdp = config.lastSwdp + if (lastSwdp == 0) { + config.lastSwdp = smallestScreenWidthDp + val maxWidth = windowManager.maximumWindowMetrics.bounds.width().toFloat() + val currentWidth = windowManager.currentWindowMetrics.bounds.width().toFloat() + if (maxWidth > currentWidth * 1.3f) { + return FOLD_POSTURE_COVER + } + return FOLD_POSTURE_UNSUPPORTED + } + + if (abs(smallestScreenWidthDp - lastSwdp) > 250) { + config.lastSwdp = smallestScreenWidthDp + return if (smallestScreenWidthDp < 500) FOLD_POSTURE_COVER else FOLD_POSTURE_OPEN + } + + if (config.lastFoldPosture != FOLD_POSTURE_UNSUPPORTED) { + return if (smallestScreenWidthDp < 500) FOLD_POSTURE_COVER else FOLD_POSTURE_OPEN + } + + return FOLD_POSTURE_UNSUPPORTED + } + private fun getDateCodeToDisplay(newView: Int): String? { val fragment = getCurrentFragment() ?: return null val currentView = fragment.viewType @@ -1231,6 +1368,25 @@ class MainActivity : SimpleActivity(), RefreshRecyclerViewListener { showBackNavigationArrow() } + fun openEventListFromMonthly(dateTime: DateTime) { + if (getCurrentFragment() is EventListFragment) { + return + } + + val fragment = EventListFragment() + val bundle = Bundle() + bundle.putString(DAY_CODE, Formatter.getDayCodeFromDateTime(dateTime)) + fragment.arguments = bundle + try { + supportFragmentManager.beginTransaction().replace(R.id.fragments_holder, fragment) + .addToBackStack(null) + .commit() + supportFragmentManager.executePendingTransactions() + showBackNavigationArrow() + } catch (e: Exception) { + } + } + fun openDayFromMonthly(dateTime: DateTime) { if (getCurrentFragment() is DayFragmentsHolder) { return diff --git a/app/src/main/kotlin/org/fossify/calendar/activities/SettingsActivity.kt b/app/src/main/kotlin/org/fossify/calendar/activities/SettingsActivity.kt index ebb569eb0..704837b5e 100644 --- a/app/src/main/kotlin/org/fossify/calendar/activities/SettingsActivity.kt +++ b/app/src/main/kotlin/org/fossify/calendar/activities/SettingsActivity.kt @@ -16,6 +16,7 @@ import org.fossify.calendar.dialogs.ManageAutomaticBackupsDialog import org.fossify.calendar.dialogs.ManageSyncedCalendarsDialog import org.fossify.calendar.dialogs.SelectCalendarDialog import org.fossify.calendar.dialogs.SelectCalendarsDialog +import org.fossify.calendar.dialogs.ReorderCalendarsDialog import org.fossify.calendar.extensions.calDAVHelper import org.fossify.calendar.extensions.calendarsDB import org.fossify.calendar.extensions.cancelScheduledAutomaticBackup @@ -62,6 +63,7 @@ import org.fossify.calendar.helpers.SHOW_GRID import org.fossify.calendar.helpers.SHOW_MIDNIGHT_SPANNING_EVENTS_AT_TOP import org.fossify.calendar.helpers.START_WEEKLY_AT import org.fossify.calendar.helpers.START_WEEK_WITH_CURRENT_DAY +import org.fossify.calendar.helpers.TODAY_CIRCLE_COLOR import org.fossify.calendar.helpers.USE_PREVIOUS_EVENT_REMINDERS import org.fossify.calendar.helpers.VIBRATE import org.fossify.calendar.helpers.WEEKLY_VIEW @@ -184,16 +186,19 @@ class SettingsActivity : SimpleActivity() { setupLanguage() setupManageCalendars() setupManageQuickFilterCalendars() + setupReorderQuickFilterCalendars() setupHourFormat() setupAllowCreatingTasks() setupStartWeekOn() setupHighlightWeekends() setupHighlightWeekendsColor() + setupTodayCircleColor() setupDeleteAllEvents() setupDisplayDescription() setupReplaceDescription() setupWeekNumbers() setupShowGrid() + setupMonthViewFontSize() setupWeeklyStart() setupMidnightSpanEvents() setupAllowCustomizeDayCount() @@ -484,6 +489,40 @@ class SettingsActivity : SimpleActivity() { } } + private fun setupReorderQuickFilterCalendars() = binding.apply { + settingsReorderQuickFilterCalendarsHolder.setOnClickListener { + showReorderQuickFilterPicker() + } + + eventsHelper.getCalendars(this@SettingsActivity, false) { + settingsReorderQuickFilterCalendarsHolder.beGoneIf(it.size < 2) + } + } + + private fun showReorderQuickFilterPicker() { + eventsHelper.getCalendars(this, false) { calendars -> + val quickFilterIds = config.quickFilterCalendars.mapNotNull { it.toLongOrNull() }.toHashSet() + val orderedIds = config.quickFilterCalendarsOrder + val quickFilterCalendars = ArrayList() + + orderedIds.forEach { id -> + calendars.firstOrNull { it.id == id }?.let { quickFilterCalendars.add(it) } + } + + calendars + .filter { calendar -> + val id = calendar.id + id != null && id in quickFilterIds && id !in orderedIds + } + .sortedBy { it.title.lowercase() } + .forEach { quickFilterCalendars.add(it) } + + ReorderCalendarsDialog(this@SettingsActivity, quickFilterCalendars) { + config.quickFilterCalendarsOrder = it + } + } + } + private fun setupStartWeekOn() = binding.apply { val items = arrayListOf( RadioItem(DateTimeConstants.SUNDAY, getString(org.fossify.commons.R.string.sunday)), @@ -508,6 +547,23 @@ class SettingsActivity : SimpleActivity() { } } + private fun setupTodayCircleColor() = binding.apply { + val currentColor = + if (config.todayCircleColor == 0) getProperPrimaryColor() else config.todayCircleColor + settingsTodayCircleColor.setFillWithStroke(currentColor, getProperBackgroundColor()) + settingsTodayCircleColorHolder.setOnClickListener { + ColorPickerDialog( + activity = this@SettingsActivity, + color = currentColor + ) { wasPositivePressed, color -> + if (wasPositivePressed) { + config.todayCircleColor = color + settingsTodayCircleColor.setFillWithStroke(color, getProperBackgroundColor()) + } + } + } + } + private fun setupHighlightWeekends() = binding.apply { settingsHighlightWeekends.isChecked = config.highlightWeekends settingsHighlightWeekendsColorHolder.beVisibleIf(config.highlightWeekends) @@ -625,6 +681,30 @@ class SettingsActivity : SimpleActivity() { } } + private fun setupMonthViewFontSize() = binding.apply { + settingsMonthFontSize.text = getMonthViewFontSizeText() + settingsMonthFontSizeHolder.setOnClickListener { + val items = arrayListOf( + RadioItem(FONT_SIZE_SMALL, getString(org.fossify.commons.R.string.small)), + RadioItem(FONT_SIZE_MEDIUM, getString(org.fossify.commons.R.string.medium)), + RadioItem(FONT_SIZE_LARGE, getString(org.fossify.commons.R.string.large)), + RadioItem(FONT_SIZE_EXTRA_LARGE, getString(org.fossify.commons.R.string.extra_large)) + ) + + RadioGroupDialog(this@SettingsActivity, items, config.monthViewFontSize) { + config.monthViewFontSize = it as Int + settingsMonthFontSize.text = getMonthViewFontSizeText() + } + } + } + + private fun getMonthViewFontSizeText(): String = when (config.monthViewFontSize) { + FONT_SIZE_SMALL -> getString(org.fossify.commons.R.string.small) + FONT_SIZE_LARGE -> getString(org.fossify.commons.R.string.large) + FONT_SIZE_EXTRA_LARGE -> getString(org.fossify.commons.R.string.extra_large) + else -> getString(org.fossify.commons.R.string.medium) + } + @Deprecated("Not used on Oreo+ devices") private fun setupReminderSound() = binding.apply { settingsReminderSoundHolder.beGoneIf(isOreoPlus()) @@ -1156,6 +1236,7 @@ class SettingsActivity : SimpleActivity() { put(FIRST_DAY_OF_WEEK, config.firstDayOfWeek) put(HIGHLIGHT_WEEKENDS, config.highlightWeekends) put(HIGHLIGHT_WEEKENDS_COLOR, config.highlightWeekendsColor) + put(TODAY_CIRCLE_COLOR, config.todayCircleColor) put(ALLOW_CREATING_TASKS, config.allowCreatingTasks) } @@ -1272,6 +1353,7 @@ class SettingsActivity : SimpleActivity() { FIRST_DAY_OF_WEEK -> config.firstDayOfWeek = value.toInt() HIGHLIGHT_WEEKENDS -> config.highlightWeekends = value.toBoolean() HIGHLIGHT_WEEKENDS_COLOR -> config.highlightWeekendsColor = value.toInt() + TODAY_CIRCLE_COLOR -> config.todayCircleColor = value.toInt() ALLOW_CREATING_TASKS -> config.allowCreatingTasks = value.toBoolean() } } diff --git a/app/src/main/kotlin/org/fossify/calendar/activities/SimpleActivity.kt b/app/src/main/kotlin/org/fossify/calendar/activities/SimpleActivity.kt index 59ada6a03..ccf6593dc 100644 --- a/app/src/main/kotlin/org/fossify/calendar/activities/SimpleActivity.kt +++ b/app/src/main/kotlin/org/fossify/calendar/activities/SimpleActivity.kt @@ -24,27 +24,7 @@ open class SimpleActivity : BaseSimpleActivity() { val calDAVRefreshHandler = Handler() var calDAVRefreshCallback: (() -> Unit)? = null - override fun getAppIconIDs() = arrayListOf( - R.mipmap.ic_launcher_red, - R.mipmap.ic_launcher_pink, - R.mipmap.ic_launcher_purple, - R.mipmap.ic_launcher_deep_purple, - R.mipmap.ic_launcher_indigo, - R.mipmap.ic_launcher_blue, - R.mipmap.ic_launcher_light_blue, - R.mipmap.ic_launcher_cyan, - R.mipmap.ic_launcher_teal, - R.mipmap.ic_launcher, - R.mipmap.ic_launcher_light_green, - R.mipmap.ic_launcher_lime, - R.mipmap.ic_launcher_yellow, - R.mipmap.ic_launcher_amber, - R.mipmap.ic_launcher_orange, - R.mipmap.ic_launcher_deep_orange, - R.mipmap.ic_launcher_brown, - R.mipmap.ic_launcher_blue_grey, - R.mipmap.ic_launcher_grey_black - ) + override fun getAppIconIDs() = arrayListOf().apply { repeat(19) { add(R.drawable.ic_launcher_foreground) } } override fun getAppLauncherName() = getString(R.string.app_launcher_name) diff --git a/app/src/main/kotlin/org/fossify/calendar/adapters/QuickFilterCalendarAdapter.kt b/app/src/main/kotlin/org/fossify/calendar/adapters/QuickFilterCalendarAdapter.kt index 1ea6a43ca..fa6a851b6 100644 --- a/app/src/main/kotlin/org/fossify/calendar/adapters/QuickFilterCalendarAdapter.kt +++ b/app/src/main/kotlin/org/fossify/calendar/adapters/QuickFilterCalendarAdapter.kt @@ -46,7 +46,15 @@ class QuickFilterCalendarAdapter( } } - quickFilterCalendars.sortBy { it.title.lowercase() } + val orderIndexes = activity.config.quickFilterCalendarsOrder + .mapIndexed { index, id -> id to index } + .toMap() + quickFilterCalendars.sortWith( + compareBy( + { orderIndexes[it.id] ?: Int.MAX_VALUE }, + { it.title.lowercase() } + ) + ) } private fun toggleItemSelection(select: Boolean, calendar: CalendarEntity, pos: Int) { diff --git a/app/src/main/kotlin/org/fossify/calendar/adapters/ReorderCalendarsAdapter.kt b/app/src/main/kotlin/org/fossify/calendar/adapters/ReorderCalendarsAdapter.kt new file mode 100644 index 000000000..8e93a5c1d --- /dev/null +++ b/app/src/main/kotlin/org/fossify/calendar/adapters/ReorderCalendarsAdapter.kt @@ -0,0 +1,49 @@ +package org.fossify.calendar.adapters + +import android.view.MotionEvent +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import org.fossify.calendar.activities.SimpleActivity +import org.fossify.calendar.databinding.ItemReorderCalendarBinding +import org.fossify.calendar.models.CalendarEntity +import org.fossify.commons.extensions.getProperBackgroundColor +import org.fossify.commons.extensions.getProperTextColor +import org.fossify.commons.extensions.setFillWithStroke + +class ReorderCalendarsAdapter( + val activity: SimpleActivity, + val calendars: ArrayList, + val onStartDrag: (RecyclerView.ViewHolder) -> Unit +) : RecyclerView.Adapter() { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CalendarViewHolder { + return CalendarViewHolder( + binding = ItemReorderCalendarBinding.inflate(activity.layoutInflater, parent, false) + ) + } + + override fun onBindViewHolder(holder: CalendarViewHolder, position: Int) = + holder.bindView(calendar = calendars[position]) + + override fun getItemCount() = calendars.size + + inner class CalendarViewHolder(val binding: ItemReorderCalendarBinding) : + RecyclerView.ViewHolder(binding.root) { + fun bindView(calendar: CalendarEntity) { + binding.apply { + reorderCalendarTitle.text = calendar.getDisplayTitle() + reorderCalendarTitle.setTextColor(activity.getProperTextColor()) + reorderCalendarColor.setFillWithStroke( + calendar.color, + activity.getProperBackgroundColor() + ) + reorderCalendarDragHandle.setOnTouchListener { _, event -> + if (event.actionMasked == MotionEvent.ACTION_DOWN) { + onStartDrag(this@CalendarViewHolder) + } + false + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/fossify/calendar/dialogs/ReorderCalendarsDialog.kt b/app/src/main/kotlin/org/fossify/calendar/dialogs/ReorderCalendarsDialog.kt new file mode 100644 index 000000000..6614e2c77 --- /dev/null +++ b/app/src/main/kotlin/org/fossify/calendar/dialogs/ReorderCalendarsDialog.kt @@ -0,0 +1,74 @@ +package org.fossify.calendar.dialogs + +import androidx.appcompat.app.AlertDialog +import androidx.recyclerview.widget.ItemTouchHelper +import androidx.recyclerview.widget.RecyclerView +import org.fossify.calendar.activities.SimpleActivity +import org.fossify.calendar.adapters.ReorderCalendarsAdapter +import org.fossify.calendar.databinding.DialogReorderCalendarsBinding +import org.fossify.calendar.models.CalendarEntity +import org.fossify.commons.extensions.getAlertDialogBuilder +import org.fossify.commons.extensions.setupDialogStuff +import org.fossify.commons.extensions.viewBinding + +class ReorderCalendarsDialog( + val activity: SimpleActivity, + calendars: List, + val callback: (List) -> Unit +) { + private var dialog: AlertDialog? = null + private lateinit var itemTouchHelper: ItemTouchHelper + private val binding by activity.viewBinding(DialogReorderCalendarsBinding::inflate) + private val orderedCalendars = ArrayList(calendars) + + init { + val adapter = ReorderCalendarsAdapter(activity, orderedCalendars) { holder -> + itemTouchHelper.startDrag(holder) + } + binding.reorderCalendarsList.adapter = adapter + + itemTouchHelper = ItemTouchHelper(object : ItemTouchHelper.Callback() { + override fun getMovementFlags( + recyclerView: RecyclerView, + viewHolder: RecyclerView.ViewHolder + ): Int = makeMovementFlags(ItemTouchHelper.ACTION_STATE_DRAG, 0) + + override fun onMove( + recyclerView: RecyclerView, + viewHolder: RecyclerView.ViewHolder, + target: RecyclerView.ViewHolder + ): Boolean { + val fromPosition = viewHolder.bindingAdapterPosition + val toPosition = target.bindingAdapterPosition + if (fromPosition < 0 || toPosition < 0) { + return false + } + + orderedCalendars.add(toPosition, orderedCalendars.removeAt(fromPosition)) + adapter.notifyItemMoved(fromPosition, toPosition) + return true + } + + override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {} + + override fun isLongPressDragEnabled() = false + + override fun isItemViewSwipeEnabled() = false + }) + itemTouchHelper.attachToRecyclerView(binding.reorderCalendarsList) + + activity.getAlertDialogBuilder() + .setPositiveButton(org.fossify.commons.R.string.ok) { _, _ -> confirmReorder() } + .setNegativeButton(org.fossify.commons.R.string.cancel, null) + .apply { + activity.setupDialogStuff(binding.root, this) { alertDialog -> + dialog = alertDialog + } + } + } + + private fun confirmReorder() { + callback(orderedCalendars.mapNotNull { it.id }) + dialog?.dismiss() + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/fossify/calendar/extensions/Context.kt b/app/src/main/kotlin/org/fossify/calendar/extensions/Context.kt index f9dd6af1e..f21910852 100644 --- a/app/src/main/kotlin/org/fossify/calendar/extensions/Context.kt +++ b/app/src/main/kotlin/org/fossify/calendar/extensions/Context.kt @@ -834,7 +834,8 @@ fun Context.addDayEvents( fun Context.getEventListItems( events: List, addSectionDays: Boolean = true, - addSectionMonths: Boolean = true + addSectionMonths: Boolean = true, + forcedDayCode: String? = null ): ArrayList { val listItems = ArrayList(events.size) val replaceDescription = config.replaceDescription @@ -860,7 +861,7 @@ fun Context.getEventListItems( val todayCode = Formatter.getDayCodeFromTS(now) sorted.forEach { - val code = Formatter.getDayCodeFromTS(it.startTS) + val code = forcedDayCode ?: Formatter.getDayCodeFromTS(it.startTS) if (addSectionMonths) { val monthLabel = Formatter.getLongMonthYear(this, code) if (monthLabel != prevMonthLabel) { @@ -873,7 +874,12 @@ fun Context.getEventListItems( if (code != prevCode && addSectionDays) { val day = Formatter.getDateDayTitle(code) val isToday = code == todayCode - val listSectionDay = ListSectionDay(day, code, isToday, !isToday && it.startTS < now) + val isPastSection = if (forcedDayCode != null) { + !isToday && Formatter.getDayStartTS(code) < now + } else { + !isToday && it.startTS < now + } + val listSectionDay = ListSectionDay(day, code, isToday, isPastSection) listItems.add(listSectionDay) prevCode = code } diff --git a/app/src/main/kotlin/org/fossify/calendar/fragments/DayFragmentsHolder.kt b/app/src/main/kotlin/org/fossify/calendar/fragments/DayFragmentsHolder.kt index 67231c049..97b0fc1ff 100644 --- a/app/src/main/kotlin/org/fossify/calendar/fragments/DayFragmentsHolder.kt +++ b/app/src/main/kotlin/org/fossify/calendar/fragments/DayFragmentsHolder.kt @@ -62,6 +62,7 @@ class DayFragmentsHolder : MyFragmentHolder(), NavigationListener { override fun onPageSelected(position: Int) { currentDayCode = codes[position] + (activity as? MainActivity)?.setToolbarTitle(Formatter.getDayTitle(requireContext(), codes[position])) val shouldGoToTodayBeVisible = shouldGoToTodayBeVisible() if (isGoToTodayVisible != shouldGoToTodayBeVisible) { (activity as? MainActivity)?.toggleGoToTodayVisibility(shouldGoToTodayBeVisible) @@ -70,6 +71,7 @@ class DayFragmentsHolder : MyFragmentHolder(), NavigationListener { } }) currentItem = defaultDailyPage + (activity as? MainActivity)?.setToolbarTitle(Formatter.getDayTitle(requireContext(), codes[defaultDailyPage])) } } diff --git a/app/src/main/kotlin/org/fossify/calendar/fragments/EventListFragment.kt b/app/src/main/kotlin/org/fossify/calendar/fragments/EventListFragment.kt index 5ee1b7792..d3bd3e0d2 100644 --- a/app/src/main/kotlin/org/fossify/calendar/fragments/EventListFragment.kt +++ b/app/src/main/kotlin/org/fossify/calendar/fragments/EventListFragment.kt @@ -21,6 +21,7 @@ import org.fossify.calendar.extensions.getViewBitmap import org.fossify.calendar.extensions.launchNewEventIntent import org.fossify.calendar.extensions.printBitmap import org.fossify.calendar.extensions.seconds +import org.fossify.calendar.helpers.DAY_CODE import org.fossify.calendar.helpers.EVENTS_LIST_VIEW import org.fossify.calendar.helpers.FETCH_INTERVAL import org.fossify.calendar.helpers.Formatter @@ -28,10 +29,12 @@ import org.fossify.calendar.helpers.INITIAL_EVENTS import org.fossify.calendar.helpers.MIN_EVENTS_TRESHOLD import org.fossify.calendar.helpers.UPDATE_BOTTOM import org.fossify.calendar.helpers.UPDATE_TOP +import org.fossify.calendar.helpers.getNowSeconds import org.fossify.calendar.models.Event import org.fossify.calendar.models.ListEvent import org.fossify.calendar.models.ListItem import org.fossify.calendar.models.ListSectionDay +import org.fossify.calendar.models.ListSectionMonth import org.fossify.commons.extensions.areSystemAnimationsEnabled import org.fossify.commons.extensions.beGoneIf import org.fossify.commons.extensions.beVisibleIf @@ -47,6 +50,9 @@ import org.fossify.commons.interfaces.RefreshRecyclerViewListener import org.fossify.commons.views.MyLinearLayoutManager import org.fossify.commons.views.MyRecyclerView import org.joda.time.DateTime +import kotlin.math.max +import kotlin.math.min + class EventListFragment : MyFragmentHolder(), RefreshRecyclerViewListener { private var mEvents = ArrayList() @@ -58,6 +64,10 @@ class EventListFragment : MyFragmentHolder(), RefreshRecyclerViewListener { private var use24HourFormat = false + private var mDayCode = "" + + private var mToolbarTitle = "" + private lateinit var binding: FragmentEventListBinding override val viewType = EVENTS_LIST_VIEW @@ -80,12 +90,16 @@ class EventListFragment : MyFragmentHolder(), RefreshRecyclerViewListener { } use24HourFormat = requireContext().config.use24HourFormat + mDayCode = arguments?.getString(DAY_CODE) ?: "" return binding.root } override fun onResume() { super.onResume() checkEvents() + val defaultTitle = Formatter.getLongMonthYear(requireContext(), Formatter.getTodayCode()) + mToolbarTitle = defaultTitle + (activity as? MainActivity)?.setToolbarTitle(defaultTitle) val use24Hour = requireContext().config.use24HourFormat if (use24Hour != use24HourFormat) { use24HourFormat = use24Hour @@ -102,9 +116,11 @@ class EventListFragment : MyFragmentHolder(), RefreshRecyclerViewListener { private fun checkEvents() { if (!wereInitialEventsAdded) { - minFetchedTS = - DateTime().minusMinutes(requireContext().config.displayPastEvents).seconds() - maxFetchedTS = DateTime().plusMonths(6).seconds() + val now = DateTime() + val defaultMin = now.minusMinutes(requireContext().config.displayPastEvents).seconds() + val defaultMax = now.plusMonths(6).seconds() + minFetchedTS = if (mDayCode.isEmpty()) defaultMin else min(defaultMin, Formatter.getDayStartTS(mDayCode)) + maxFetchedTS = if (mDayCode.isEmpty()) defaultMax else max(defaultMax, Formatter.getDayEndTS(mDayCode)) } requireContext().eventsHelper.getEvents(minFetchedTS, maxFetchedTS) { events -> @@ -155,6 +171,11 @@ class EventListFragment : MyFragmentHolder(), RefreshRecyclerViewListener { } }.apply { binding.calendarEventsList.adapter = this + if (mDayCode.isNotEmpty()) { + scrollToAnchorDay() + } else { + scrollToNextAppointment() + } } if (requireContext().areSystemAnimationsEnabled) { @@ -174,6 +195,11 @@ class EventListFragment : MyFragmentHolder(), RefreshRecyclerViewListener { binding.calendarEventsList.addOnScrollListener(object : RecyclerView.OnScrollListener() { + override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { + super.onScrolled(recyclerView, dx, dy) + updateToolbarMonth() + } + override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) { super.onScrollStateChanged(recyclerView, newState) if (!hasBeenScrolled) { @@ -199,6 +225,35 @@ class EventListFragment : MyFragmentHolder(), RefreshRecyclerViewListener { } } checkPlaceholderVisibility() + updateToolbarMonth() + } + } + + private fun updateToolbarMonth() { + val adapter = binding.calendarEventsList.adapter as? EventListAdapter ?: return + val layoutManager = binding.calendarEventsList.layoutManager as? LinearLayoutManager ?: return + val firstVisiblePosition = layoutManager.findFirstVisibleItemPosition() + if (firstVisiblePosition == RecyclerView.NO_POSITION || firstVisiblePosition >= adapter.listItems.size) { + return + } + + var monthTitle = "" + val firstItem = adapter.listItems.getOrNull(firstVisiblePosition) + if (firstItem is ListSectionMonth) { + monthTitle = firstItem.title + } else { + for (i in firstVisiblePosition downTo 0) { + val item = adapter.listItems.getOrNull(i) + if (item is ListSectionDay) { + monthTitle = Formatter.getLongMonthYear(requireContext(), item.code) + break + } + } + } + + if (monthTitle.isNotEmpty() && monthTitle != mToolbarTitle) { + mToolbarTitle = monthTitle + (activity as? MainActivity)?.setToolbarTitle(monthTitle) } } @@ -220,6 +275,74 @@ class EventListFragment : MyFragmentHolder(), RefreshRecyclerViewListener { } } + private fun scrollToAnchorDay() { + val adapter = binding.calendarEventsList.adapter as? EventListAdapter ?: return + val targetIndex = adapter.listItems.indexOfFirst { it is ListSectionDay && it.code >= mDayCode } + if (targetIndex != -1) { + binding.calendarEventsList.post { + (binding.calendarEventsList.layoutManager as LinearLayoutManager).scrollToPositionWithOffset( + targetIndex, + 0 + ) + binding.calendarEventsList.onGlobalLayout { + hasBeenScrolled = false + (activity as? MainActivity)?.refreshItems() + (activity as? MainActivity)?.refreshMenuItems() + } + } + } + } + + private fun scrollToNextAppointment() { + val adapter = binding.calendarEventsList.adapter as? EventListAdapter ?: return + val now = getNowSeconds() + // Only consider events that STARTED TODAY or later. The fetch window includes + // multi-day events from previous days (e.g. a parking booking that started + // days ago but ends tonight): those sort ABOVE today's sections, so picking + // them scrolled the list into yesterday instead of to the next meeting. + val todayStartTS = Formatter.getDayStartTS(Formatter.getTodayCode()) + val upcoming = adapter.listItems.filterIsInstance().filter { it.endTS > now } + val pick = upcoming.firstOrNull { it.startTS >= todayStartTS && !it.isAllDay } + ?: upcoming.firstOrNull { it.startTS >= todayStartTS } + ?: upcoming.firstOrNull { !it.isAllDay } + ?: upcoming.firstOrNull() + val targetIndex = if (pick != null) { + adapter.listItems.indexOfFirst { it === pick } + } else { + -1 + } + + // Fallback: if nothing has endTS > now (e.g. shifted timestamps), at least + // jump to today's section instead of leaving the list at the top of yesterday. + val scrollIndex = if (targetIndex != -1) { + targetIndex + } else { + adapter.listItems.indexOfFirst { it is ListSectionDay && !it.isPastSection } + } + + if (scrollIndex != -1) { + binding.calendarEventsList.post { + (binding.calendarEventsList.layoutManager as LinearLayoutManager).scrollToPositionWithOffset( + scrollIndex, + 0 + ) + binding.calendarEventsList.onGlobalLayout { + // Re-assert after layout: state restoration or the layout animation + // can win over the post() scroll and dump the list back to the top. + val firstVisible = (binding.calendarEventsList.layoutManager as? LinearLayoutManager) + ?.findFirstVisibleItemPosition() ?: RecyclerView.NO_POSITION + if (firstVisible != scrollIndex && !hasBeenScrolled) { + (binding.calendarEventsList.layoutManager as LinearLayoutManager) + .scrollToPositionWithOffset(scrollIndex, 0) + } + hasBeenScrolled = false + (activity as? MainActivity)?.refreshItems() + (activity as? MainActivity)?.refreshMenuItems() + } + } + } + } + private fun fetchPreviousPeriod() { val lastPosition = (binding.calendarEventsList.layoutManager as MyLinearLayoutManager).findLastVisibleItemPosition() @@ -258,6 +381,13 @@ class EventListFragment : MyFragmentHolder(), RefreshRecyclerViewListener { } override fun goToToday() { + // In the simple all-events list, "Today" means current time: reuse the snap + // so it lands on what is running now or the next appointment. The snap's own + // fallback still jumps to the top of today's section when nothing is upcoming. + if (mDayCode.isEmpty()) { + scrollToNextAppointment() + return + } val listItems = requireContext().getEventListItems(mEvents) val firstNonPastSectionIndex = listItems.indexOfFirst { it is ListSectionDay && !it.isPastSection } @@ -282,7 +412,7 @@ class EventListFragment : MyFragmentHolder(), RefreshRecyclerViewListener { override fun shouldGoToTodayBeVisible() = hasBeenScrolled - override fun getNewEventDayCode() = Formatter.getTodayCode() + override fun getNewEventDayCode() = mDayCode.ifEmpty { Formatter.getTodayCode() } override fun printView() { binding.apply { diff --git a/app/src/main/kotlin/org/fossify/calendar/fragments/MonthDayFragmentsHolder.kt b/app/src/main/kotlin/org/fossify/calendar/fragments/MonthDayFragmentsHolder.kt index b33add80a..0b85eeac8 100644 --- a/app/src/main/kotlin/org/fossify/calendar/fragments/MonthDayFragmentsHolder.kt +++ b/app/src/main/kotlin/org/fossify/calendar/fragments/MonthDayFragmentsHolder.kt @@ -63,6 +63,7 @@ class MonthDayFragmentsHolder : MyFragmentHolder(), NavigationListener { override fun onPageSelected(position: Int) { currentDayCode = codes[position] + updateMonthTitle(codes[position]) val shouldGoToTodayBeVisible = shouldGoToTodayBeVisible() if (isGoToTodayVisible != shouldGoToTodayBeVisible) { (activity as? MainActivity)?.toggleGoToTodayVisibility(shouldGoToTodayBeVisible) @@ -71,9 +72,14 @@ class MonthDayFragmentsHolder : MyFragmentHolder(), NavigationListener { } }) currentItem = defaultMonthlyPage + updateMonthTitle(codes[defaultMonthlyPage]) } } + private fun updateMonthTitle(code: String) { + (activity as? MainActivity)?.setToolbarTitle(Formatter.getMonthTitle(requireContext(), Formatter.getDateTimeFromCode(code))) + } + private fun getMonths(code: String): List { val months = ArrayList(PREFILLED_MONTHS) val today = Formatter.getDateTimeFromCode(code).withDayOfMonth(1) diff --git a/app/src/main/kotlin/org/fossify/calendar/fragments/MonthFragment.kt b/app/src/main/kotlin/org/fossify/calendar/fragments/MonthFragment.kt index 4fcba53b0..df9e3d62c 100644 --- a/app/src/main/kotlin/org/fossify/calendar/fragments/MonthFragment.kt +++ b/app/src/main/kotlin/org/fossify/calendar/fragments/MonthFragment.kt @@ -1,16 +1,13 @@ package org.fossify.calendar.fragments import android.content.Context -import android.content.res.Resources import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.fragment.app.Fragment -import org.fossify.calendar.R import org.fossify.calendar.activities.MainActivity import org.fossify.calendar.databinding.FragmentMonthBinding -import org.fossify.calendar.databinding.TopNavigationBinding import org.fossify.calendar.extensions.config import org.fossify.calendar.extensions.getViewBitmap import org.fossify.calendar.extensions.printBitmap @@ -21,37 +18,27 @@ import org.fossify.calendar.helpers.MonthlyCalendarImpl import org.fossify.calendar.interfaces.MonthlyCalendar import org.fossify.calendar.interfaces.NavigationListener import org.fossify.calendar.models.DayMonthly -import org.fossify.commons.extensions.applyColorFilter -import org.fossify.commons.extensions.beGone -import org.fossify.commons.extensions.beVisible -import org.fossify.commons.extensions.getProperTextColor import org.joda.time.DateTime class MonthFragment : Fragment(), MonthlyCalendar { - private var mTextColor = 0 + var listener: NavigationListener? = null + private var mShowWeekNumbers = false private var mDayCode = "" private var mPackageName = "" private var mLastHash = 0L private var mCalendar: MonthlyCalendarImpl? = null - var listener: NavigationListener? = null - - private lateinit var mRes: Resources private lateinit var mConfig: Config private lateinit var binding: FragmentMonthBinding - private lateinit var topNavigationBinding: TopNavigationBinding override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { binding = FragmentMonthBinding.inflate(inflater, container, false) - topNavigationBinding = TopNavigationBinding.bind(binding.root) - mRes = resources mPackageName = requireActivity().packageName mDayCode = requireArguments().getString(DAY_CODE)!! mConfig = requireContext().config storeStateVariables() - setupButtons() mCalendar = MonthlyCalendarImpl(this, requireContext()) return binding.root @@ -96,74 +83,21 @@ class MonthFragment : Fragment(), MonthlyCalendar { mLastHash = newHash activity?.runOnUiThread { - topNavigationBinding.topValue.apply { - text = month - contentDescription = text - - if (activity != null) { - setTextColor(requireActivity().getProperTextColor()) - } - } updateDays(days) } } - private fun setupButtons() { - mTextColor = requireContext().getProperTextColor() - - topNavigationBinding.topLeftArrow.apply { - applyColorFilter(mTextColor) - background = null - setOnClickListener { - listener?.goLeft() - } - - val pointerLeft = requireContext().getDrawable(org.fossify.commons.R.drawable.ic_chevron_left_vector) - pointerLeft?.isAutoMirrored = true - setImageDrawable(pointerLeft) - contentDescription = getString(R.string.accessibility_previous_month) - } - - topNavigationBinding.topRightArrow.apply { - applyColorFilter(mTextColor) - background = null - setOnClickListener { - listener?.goRight() - } - - val pointerRight = requireContext().getDrawable(org.fossify.commons.R.drawable.ic_chevron_right_vector) - pointerRight?.isAutoMirrored = true - setImageDrawable(pointerRight) - contentDescription = getString(R.string.accessibility_next_month) - } - - topNavigationBinding.topValue.apply { - setTextColor(requireContext().getProperTextColor()) - setOnClickListener { - (activity as MainActivity).showGoToDateDialog() - } - } - } - private fun updateDays(days: ArrayList) { binding.monthViewWrapper.updateDays(days, true) { - (activity as MainActivity).openDayFromMonthly(Formatter.getDateTimeFromCode(it.code)) + (activity as MainActivity).openEventListFromMonthly(Formatter.getDateTimeFromCode(it.code)) } } fun printCurrentView() { - topNavigationBinding.apply { - topLeftArrow.beGone() - topRightArrow.beGone() - topValue.setTextColor(resources.getColor(org.fossify.commons.R.color.theme_light_text_color)) - binding.monthViewWrapper.togglePrintMode() - - requireContext().printBitmap(binding.monthCalendarHolder.getViewBitmap()) - - topLeftArrow.beVisible() - topRightArrow.beVisible() - topValue.setTextColor(requireContext().getProperTextColor()) - binding.monthViewWrapper.togglePrintMode() - } + binding.monthViewWrapper.togglePrintMode() + + requireContext().printBitmap(binding.monthCalendarHolder.getViewBitmap()) + + binding.monthViewWrapper.togglePrintMode() } } diff --git a/app/src/main/kotlin/org/fossify/calendar/fragments/MonthFragmentsHolder.kt b/app/src/main/kotlin/org/fossify/calendar/fragments/MonthFragmentsHolder.kt index 1096cdfcd..99b3c4224 100644 --- a/app/src/main/kotlin/org/fossify/calendar/fragments/MonthFragmentsHolder.kt +++ b/app/src/main/kotlin/org/fossify/calendar/fragments/MonthFragmentsHolder.kt @@ -65,6 +65,7 @@ class MonthFragmentsHolder : MyFragmentHolder(), NavigationListener { override fun onPageSelected(position: Int) { currentDayCode = codes[position] + updateMonthTitle(codes[position]) val shouldGoToTodayBeVisible = shouldGoToTodayBeVisible() if (isGoToTodayVisible != shouldGoToTodayBeVisible) { (activity as? MainActivity)?.toggleGoToTodayVisibility(shouldGoToTodayBeVisible) @@ -73,9 +74,14 @@ class MonthFragmentsHolder : MyFragmentHolder(), NavigationListener { } }) currentItem = defaultMonthlyPage + updateMonthTitle(codes[defaultMonthlyPage]) } } + private fun updateMonthTitle(code: String) { + (activity as? MainActivity)?.setToolbarTitle(Formatter.getMonthTitle(requireContext(), Formatter.getDateTimeFromCode(code))) + } + private fun getMonths(code: String): List { val months = ArrayList(PREFILLED_MONTHS) val today = Formatter.getDateTimeFromCode(code).withDayOfMonth(1) diff --git a/app/src/main/kotlin/org/fossify/calendar/fragments/YearFragmentsHolder.kt b/app/src/main/kotlin/org/fossify/calendar/fragments/YearFragmentsHolder.kt index e91fb6351..206af77f5 100644 --- a/app/src/main/kotlin/org/fossify/calendar/fragments/YearFragmentsHolder.kt +++ b/app/src/main/kotlin/org/fossify/calendar/fragments/YearFragmentsHolder.kt @@ -63,6 +63,7 @@ class YearFragmentsHolder : MyFragmentHolder(), NavigationListener { override fun onPageSelected(position: Int) { currentYear = years[position] + (activity as? MainActivity)?.setToolbarTitle(currentYear.toString()) val shouldGoToTodayBeVisible = shouldGoToTodayBeVisible() if (isGoToTodayVisible != shouldGoToTodayBeVisible) { (activity as? MainActivity)?.toggleGoToTodayVisibility(shouldGoToTodayBeVisible) @@ -71,6 +72,7 @@ class YearFragmentsHolder : MyFragmentHolder(), NavigationListener { } }) currentItem = defaultYearlyPage + (activity as? MainActivity)?.setToolbarTitle(years[defaultYearlyPage].toString()) } } diff --git a/app/src/main/kotlin/org/fossify/calendar/helpers/Config.kt b/app/src/main/kotlin/org/fossify/calendar/helpers/Config.kt index 488929489..ff39dde31 100644 --- a/app/src/main/kotlin/org/fossify/calendar/helpers/Config.kt +++ b/app/src/main/kotlin/org/fossify/calendar/helpers/Config.kt @@ -9,6 +9,7 @@ import org.fossify.calendar.extensions.scheduleCalDAVSync import org.fossify.commons.extensions.getDefaultAlarmTitle import org.fossify.commons.helpers.BaseConfig import org.fossify.commons.helpers.DAY_MINUTES +import org.fossify.commons.helpers.FONT_SIZE_MEDIUM import org.fossify.commons.helpers.YEAR_SECONDS class Config(context: Context) : BaseConfig(context) { @@ -73,6 +74,18 @@ class Config(context: Context) : BaseConfig(context) { get() = prefs.getInt(VIEW, MONTHLY_VIEW) set(view) = prefs.edit().putInt(VIEW, view).apply() + var viewSelectionWasCustomized: Boolean + get() = prefs.getBoolean(VIEW_CUSTOMIZED, false) + set(customized) = prefs.edit().putBoolean(VIEW_CUSTOMIZED, customized).apply() + + var lastFoldPosture: Int + get() = prefs.getInt(LAST_FOLD_POSTURE, FOLD_POSTURE_UNSUPPORTED) + set(posture) = prefs.edit().putInt(LAST_FOLD_POSTURE, posture).apply() + + var lastSwdp: Int + get() = prefs.getInt(LAST_SWDP, 0) + set(swdp) = prefs.edit().putInt(LAST_SWDP, swdp).apply() + var lastEventReminderMinutes1: Int get() = prefs.getInt(LAST_EVENT_REMINDER_MINUTES, 10) set(lastEventReminderMinutes) = prefs.edit() @@ -102,6 +115,14 @@ class Config(context: Context) : BaseConfig(context) { set(quickFilterCalendars) = prefs.edit().remove(QUICK_FILTER_CALENDARS) .putStringSet(QUICK_FILTER_CALENDARS, quickFilterCalendars).apply() + var quickFilterCalendarsOrder: List + get() = prefs.getString(QUICK_FILTER_CALENDARS_ORDER, "")!! + .split(',') + .mapNotNull { it.toLongOrNull() } + set(quickFilterCalendarsOrder) = prefs.edit() + .putString(QUICK_FILTER_CALENDARS_ORDER, quickFilterCalendarsOrder.joinToString(",")) + .apply() + fun addQuickFilterCalendar(type: String) { val currQuickFilterCalendars = HashSet(quickFilterCalendars) currQuickFilterCalendars.add(type) @@ -160,6 +181,10 @@ class Config(context: Context) : BaseConfig(context) { get() = prefs.getBoolean(SHOW_GRID, false) set(showGrid) = prefs.edit().putBoolean(SHOW_GRID, showGrid).apply() + var monthViewFontSize: Int + get() = prefs.getInt(MONTH_VIEW_FONT_SIZE, FONT_SIZE_MEDIUM) + set(monthViewFontSize) = prefs.edit().putInt(MONTH_VIEW_FONT_SIZE, monthViewFontSize).apply() + var loopReminders: Boolean get() = prefs.getBoolean(LOOP_REMINDERS, false) set(loopReminders) = prefs.edit().putBoolean(LOOP_REMINDERS, loopReminders).apply() @@ -294,6 +319,11 @@ class Config(context: Context) : BaseConfig(context) { set(highlightWeekendsColor) = prefs.edit() .putInt(HIGHLIGHT_WEEKENDS_COLOR, highlightWeekendsColor).apply() + // 0 = never picked, means "use the app's primary color" (the old behaviour) + var todayCircleColor: Int + get() = prefs.getInt(TODAY_CIRCLE_COLOR, 0) + set(todayCircleColor) = prefs.edit().putInt(TODAY_CIRCLE_COLOR, todayCircleColor).apply() + var lastUsedEventSpan: Int get() = prefs.getInt(LAST_USED_EVENT_SPAN, YEAR_SECONDS) set(lastUsedEventSpan) = prefs.edit().putInt(LAST_USED_EVENT_SPAN, lastUsedEventSpan) diff --git a/app/src/main/kotlin/org/fossify/calendar/helpers/Constants.kt b/app/src/main/kotlin/org/fossify/calendar/helpers/Constants.kt index 45ed8acab..13f9cd0bf 100644 --- a/app/src/main/kotlin/org/fossify/calendar/helpers/Constants.kt +++ b/app/src/main/kotlin/org/fossify/calendar/helpers/Constants.kt @@ -94,11 +94,18 @@ const val VIBRATE = "vibrate" const val REMINDER_SOUND_URI = "reminder_sound_uri" const val REMINDER_SOUND_TITLE = "reminder_sound_title" const val VIEW = "view" +const val VIEW_CUSTOMIZED = "view_customized" +const val LAST_FOLD_POSTURE = "last_fold_posture" +const val LAST_SWDP = "last_swdp" +const val FOLD_POSTURE_UNSUPPORTED = -1 +const val FOLD_POSTURE_OPEN = 0 +const val FOLD_POSTURE_COVER = 1 const val LAST_EVENT_REMINDER_MINUTES = "reminder_minutes" const val LAST_EVENT_REMINDER_MINUTES_2 = "reminder_minutes_2" const val LAST_EVENT_REMINDER_MINUTES_3 = "reminder_minutes_3" const val DISPLAY_CALENDARS = "display_event_types" const val QUICK_FILTER_CALENDARS = "quick_filter_event_types" +const val QUICK_FILTER_CALENDARS_ORDER = "quick_filter_event_types_order" const val LIST_WIDGET_VIEW_TO_OPEN = "list_widget_view_to_open" const val CALDAV_SYNC = "caldav_sync" const val CALDAV_SYNCED_CALENDAR_IDS = "caldav_synced_calendar_ids" @@ -135,8 +142,10 @@ const val EXPORT_TASKS = "export_tasks" const val EXPORT_PAST_EVENTS = "export_past_events" const val WEEKLY_VIEW_ITEM_HEIGHT_MULTIPLIER = "weekly_view_item_height_multiplier" const val WEEKLY_VIEW_DAYS = "weekly_view_days" +const val MONTH_VIEW_FONT_SIZE = "month_view_font_size" const val HIGHLIGHT_WEEKENDS = "highlight_weekends" const val HIGHLIGHT_WEEKENDS_COLOR = "highlight_weekends_color" +const val TODAY_CIRCLE_COLOR = "today_circle_color" const val LAST_USED_EVENT_SPAN = "last_used_event_span" const val ALLOW_CREATING_TASKS = "allow_creating_tasks" const val WAS_FILTERED_OUT_WARNING_SHOWN = "was_filtered_out_warning_shown" diff --git a/app/src/main/kotlin/org/fossify/calendar/helpers/Formatter.kt b/app/src/main/kotlin/org/fossify/calendar/helpers/Formatter.kt index 3bbcdefe2..08ec2e9ef 100644 --- a/app/src/main/kotlin/org/fossify/calendar/helpers/Formatter.kt +++ b/app/src/main/kotlin/org/fossify/calendar/helpers/Formatter.kt @@ -111,6 +111,12 @@ object Formatter { fun getShortMonthName(context: Context, id: Int) = context.resources.getStringArray(org.fossify.commons.R.array.months_short)[id - 1] + fun getMonthTitle(context: Context, dateTime: DateTime): String { + val month = getMonthName(context, dateTime.monthOfYear) + val targetYear = dateTime.toString(YEAR_PATTERN) + return "$month $targetYear" + } + fun getHourPattern(context: Context) = if (context.config.use24HourFormat) PATTERN_HOURS_24 else PATTERN_HOURS_12 fun getTimePattern(context: Context) = if (context.config.use24HourFormat) PATTERN_TIME_24 else PATTERN_TIME_12 diff --git a/app/src/main/kotlin/org/fossify/calendar/views/MonthView.kt b/app/src/main/kotlin/org/fossify/calendar/views/MonthView.kt index bf41c1d49..7b83b70f8 100644 --- a/app/src/main/kotlin/org/fossify/calendar/views/MonthView.kt +++ b/app/src/main/kotlin/org/fossify/calendar/views/MonthView.kt @@ -5,32 +5,36 @@ import android.graphics.* import android.text.TextPaint import android.text.TextUtils import android.util.AttributeSet -import android.util.SparseIntArray import android.view.View import org.fossify.calendar.R import org.fossify.calendar.extensions.* import org.fossify.calendar.helpers.COLUMN_COUNT -import org.fossify.calendar.helpers.Formatter import org.fossify.calendar.helpers.ROW_COUNT import org.fossify.calendar.models.DayMonthly import org.fossify.calendar.models.Event -import org.fossify.calendar.models.MonthViewEvent import org.fossify.commons.extensions.* +import org.fossify.commons.helpers.FONT_SIZE_EXTRA_LARGE +import org.fossify.commons.helpers.FONT_SIZE_LARGE +import org.fossify.commons.helpers.FONT_SIZE_SMALL import org.fossify.commons.helpers.FontHelper -import org.fossify.commons.helpers.HIGHER_ALPHA import org.fossify.commons.helpers.LOWER_ALPHA import org.fossify.commons.helpers.MEDIUM_ALPHA import org.joda.time.DateTime -import org.joda.time.Days import kotlin.math.max import kotlin.math.min // used in the Monthly view fragment, 1 view per screen class MonthView(context: Context, attrs: AttributeSet, defStyle: Int) : View(context, attrs, defStyle) { + private class RowEvent(val event: Event) { + var firstCol = 0 + var lastCol = 0 + } + companion object { - private const val BG_CORNER_RADIUS = 8f private const val EVENT_DOT_COLUMN_COUNT = 3 private const val EVENT_DOT_ROW_COUNT = 1 + private const val FONT_SCALE_FLOOR = 0.85f + private const val FONT_SCALE_SAFETY = 0.97f } private var textPaint: Paint @@ -39,17 +43,21 @@ class MonthView(context: Context, attrs: AttributeSet, defStyle: Int) : View(con private var circleStrokePaint: Paint private var plusTextPaint: Paint private var eventDotPaint: Paint + private var eventStripPaint: Paint private var config = context.config private var dayWidth = 0f - private var dayHeight = 0f private var primaryColor = 0 private var textColor = 0 private var weekendsTextColor = 0 private var weekDaysLetterHeight = 0 + private var normalTextSize = 0 + private var baseNormalTextSize = 0 + private var defaultNormalTextSize = 0 private var eventTitleHeight = 0 + private var baseEventTitleHeight = 0 + private var defaultEventTitleHeight = 0 private var currDayOfWeek = 0 private var smallPadding = 0 - private var maxEventsPerDay = 0 private var horizontalOffset = 0 private var showWeekNumbers = false private var dimPastEvents = true @@ -57,14 +65,23 @@ class MonthView(context: Context, attrs: AttributeSet, defStyle: Int) : View(con private var highlightWeekends = false private var isPrintVersion = false private var isMonthDayView = false - private var allEvents = ArrayList() - private var bgRectF = RectF() + private var contentHeight = 0 + private var minimumContentHeight = 0 private var dayTextRect = Rect() private var dayLetters = ArrayList() private var days = ArrayList() - private var dayVerticalOffsets = SparseIntArray() + private var rowHeights = IntArray(ROW_COUNT) private var selectedDayCoords = Point(-1, -1) + private val dayNumberHeight: Int + get() = (normalTextSize * 1.4f).toInt() + + private val contentTop: Int + get() = smallPadding + normalTextSize + + private val eventLineStep: Int + get() = eventTitleHeight + smallPadding * 2 + constructor(context: Context, attrs: AttributeSet) : this(context, attrs, 0) init { @@ -77,8 +94,10 @@ class MonthView(context: Context, attrs: AttributeSet, defStyle: Int) : View(con highlightWeekends = config.highlightWeekends smallPadding = resources.displayMetrics.density.toInt() - val normalTextSize = resources.getDimensionPixelSize(org.fossify.commons.R.dimen.normal_text_size) - weekDaysLetterHeight = normalTextSize * 2 + baseNormalTextSize = resources.getDimensionPixelSize(R.dimen.month_view_text_size) + normalTextSize = (baseNormalTextSize * getMonthViewFontSizeScale()).toInt() + defaultNormalTextSize = normalTextSize + weekDaysLetterHeight = normalTextSize textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = textColor @@ -106,8 +125,10 @@ class MonthView(context: Context, attrs: AttributeSet, defStyle: Int) : View(con color = primaryColor } - val smallerTextSize = resources.getDimensionPixelSize(org.fossify.commons.R.dimen.smaller_text_size) - eventTitleHeight = smallerTextSize + val smallerTextSize = resources.getDimensionPixelSize(R.dimen.month_view_event_text_size) + baseEventTitleHeight = smallerTextSize + defaultEventTitleHeight = (baseEventTitleHeight * getMonthViewFontSizeScale()).toInt() + eventTitleHeight = defaultEventTitleHeight eventTitlePaint = TextPaint(Paint.ANTI_ALIAS_FLAG).apply { color = textColor textSize = smallerTextSize.toFloat() @@ -115,69 +136,158 @@ class MonthView(context: Context, attrs: AttributeSet, defStyle: Int) : View(con typeface = FontHelper.getTypeface(context) } + eventStripPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.FILL + } + initWeekDayLetters() setupCurrentDayOfWeekIndex() } + // the wrapper uses this so that even an empty month still fills the whole screen + fun setMinimumContentHeight(value: Int) { + if (minimumContentHeight != value) { + minimumContentHeight = value + requestLayout() + } + } + + // total height of all 6 rows plus the weekday header + fun getContentHeight() = contentHeight + + fun getRowHeight(row: Int): Int = rowHeights[row] + + fun getRowTop(row: Int): Int { + var top = weekDaysLetterHeight + for (i in 0 until row) { + top += rowHeights[i] + } + return top + } + fun updateDays(newDays: ArrayList, isMonthDayView: Boolean) { this.isMonthDayView = isMonthDayView days = newDays showWeekNumbers = config.showWeekNumbers horizontalOffset = context.getWeekNumberWidth() + normalTextSize = (baseNormalTextSize * getMonthViewFontSizeScale()).toInt() + defaultNormalTextSize = normalTextSize + eventTitleHeight = (baseEventTitleHeight * getMonthViewFontSizeScale()).toInt() + defaultEventTitleHeight = eventTitleHeight initWeekDayLetters() setupCurrentDayOfWeekIndex() - groupAllEvents() + computeContentHeight() invalidate() } - private fun groupAllEvents() { - days.forEach { day -> - val dayIndexOnMonthView = day.indexOnMonthView - - day.dayEvents.forEach { event -> - // make sure we properly handle events lasting multiple days and repeating ones - val validDayEvent = isDayValid(event, day.code) - val lastEvent = allEvents.lastOrNull { it.id == event.id } - val notYetAddedOrIsRepeatingEvent = lastEvent == null || lastEvent.endTS <= event.startTS - - // handle overlapping repeating events e.g. an event that lasts 3 days, but repeats every 2 days has a one day overlap - val canOverlap = event.endTS - event.startTS > event.repeatInterval - val shouldAddEvent = notYetAddedOrIsRepeatingEvent || canOverlap && (lastEvent.startTS < event.startTS) - - if (shouldAddEvent && !validDayEvent) { - val daysCnt = getEventLastingDaysCount(event) - - val monthViewEvent = MonthViewEvent( - id = event.id!!, - title = event.title, - startTS = event.startTS, - endTS = event.endTS, - color = event.color, - startDayIndex = dayIndexOnMonthView, - daysCnt = daysCnt, - originalStartDayIndex = dayIndexOnMonthView, - isAllDay = event.getIsAllDay(), - isPastEvent = event.isPastEvent, - isTask = event.isTask(), - isTaskCompleted = event.isTaskCompleted(), - isAttendeeInviteDeclined = event.isAttendeeInviteDeclined(), - isEventCanceled = event.isEventCanceled() - ) - allEvents.add(monthViewEvent) - } + private fun getMonthViewFontSizeScale(): Float = when (config.monthViewFontSize) { + FONT_SIZE_SMALL -> 0.85f + FONT_SIZE_LARGE -> 1.15f + FONT_SIZE_EXTRA_LARGE -> 1.3f + else -> 1f + } + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + val width = MeasureSpec.getSize(widthMeasureSpec) + val height = MeasureSpec.getSize(heightMeasureSpec) + dayWidth = (width - horizontalOffset) / COLUMN_COUNT.toFloat() + + computeContentHeight() + + val desiredHeight = if (MeasureSpec.getMode(heightMeasureSpec) == MeasureSpec.EXACTLY) { + max(contentHeight, height) + } else { + contentHeight + } + setMeasuredDimension(width, desiredHeight) + } + + private fun computeContentHeight() { + if (days.isEmpty()) { + contentHeight = minimumContentHeight + return + } + + if (isMonthDayView) { + normalTextSize = defaultNormalTextSize + eventTitleHeight = defaultEventTitleHeight + syncTextSizes() + val headerless = max(0, minimumContentHeight - weekDaysLetterHeight) + val rawRowHeight = headerless / ROW_COUNT + for (y in 0 until ROW_COUNT) { + rowHeights[y] = rawRowHeight } + contentHeight = minimumContentHeight + return + } + + var rowSlots = IntArray(ROW_COUNT) + for (y in 0 until ROW_COUNT) { + rowSlots[y] = getRowSlots(y) + } + + applyFontScale(rowSlots) + layoutRows() + } + + // shrink the effective text sizes so that the busiest week fits into one uniform row height + private fun applyFontScale(rowSlots: IntArray) { + normalTextSize = defaultNormalTextSize + eventTitleHeight = defaultEventTitleHeight + weekDaysLetterHeight = defaultNormalTextSize + textPaint.textSize = normalTextSize.toFloat() + plusTextPaint.textSize = normalTextSize.toFloat() + eventTitlePaint.textSize = eventTitleHeight.toFloat() + + val available = max(0, minimumContentHeight - weekDaysLetterHeight) + val uniformRowHeight = available / ROW_COUNT + val fullDayNumberHeight = defaultNormalTextSize + smallPadding + val fullLineStep = defaultEventTitleHeight + smallPadding * 2 + var maxRowSlots = 0 + for (y in 0 until ROW_COUNT) { + maxRowSlots = max(maxRowSlots, rowSlots[y]) } - allEvents = allEvents.asSequence().sortedWith( - compareBy({ -it.daysCnt }, { !it.isAllDay }, { it.startTS }, { it.endTS }, { it.startDayIndex }, { it.title }) - ).toMutableList() as ArrayList + val fullRowHeight = fullDayNumberHeight + maxRowSlots * fullLineStep + smallPadding * 2 + if (fullRowHeight <= uniformRowHeight) { + return + } + + val fixedCost = maxRowSlots * smallPadding * 2 + smallPadding * 2 + val variableCost = defaultNormalTextSize + smallPadding + defaultEventTitleHeight * maxRowSlots + val rawScale = (uniformRowHeight - fixedCost) / variableCost + val scale = (rawScale * FONT_SCALE_SAFETY).coerceIn(FONT_SCALE_FLOOR, 1f) + + normalTextSize = (defaultNormalTextSize * scale).toInt() + eventTitleHeight = (defaultEventTitleHeight * scale).toInt() + syncTextSizes() + } + + // every week row gets the same height, filling the whole screen evenly + private fun layoutRows() { + val available = max(0, minimumContentHeight - weekDaysLetterHeight) + var remaining = available + val base = remaining / ROW_COUNT + for (y in 0 until ROW_COUNT) { + rowHeights[y] = base + remaining -= base + } + if (remaining > 0) { + rowHeights[ROW_COUNT - 1] += remaining + } + + contentHeight = minimumContentHeight + } + + private fun syncTextSizes() { + weekDaysLetterHeight = normalTextSize + textPaint.textSize = normalTextSize.toFloat() + plusTextPaint.textSize = normalTextSize.toFloat() + eventTitlePaint.textSize = eventTitleHeight.toFloat() } override fun onDraw(canvas: Canvas) { super.onDraw(canvas) - dayVerticalOffsets.clear() - measureDaySize(canvas) - if (config.showGrid && !isMonthDayView) { drawGrid(canvas) } @@ -189,94 +299,53 @@ class MonthView(context: Context, attrs: AttributeSet, defStyle: Int) : View(con var curId = 0 for (y in 0 until ROW_COUNT) { + val rowTop = getRowTop(y) + if (!isMonthDayView) { + drawMonthEvents(canvas, y, rowTop) + } + for (x in 0 until COLUMN_COUNT) { val day = days.getOrNull(curId) if (day != null) { val dayNumber = day.value.toString() - val textPaint = getTextPaint(day) - textPaint.getTextBounds(dayNumber, 0, dayNumber.length, dayTextRect) - dayVerticalOffsets.put(day.indexOnMonthView, dayVerticalOffsets[day.indexOnMonthView] + weekDaysLetterHeight) - val verticalOffset = dayVerticalOffsets[day.indexOnMonthView] + val numberPaint = getTextPaint(day) + numberPaint.textAlign = Paint.Align.LEFT + numberPaint.getTextBounds(dayNumber, 0, dayNumber.length, dayTextRect) val xPos = x * dayWidth + horizontalOffset - val yPos = y * dayHeight + verticalOffset - val textY = yPos + textPaint.textSize - val xPosCenter = xPos + dayWidth / 2 + val numberX = xPos + smallPadding + val numberBaseline = rowTop + smallPadding + numberPaint.textSize + val circleCenterX = numberX + dayTextRect.width() / 2f + val circleCenterY = numberBaseline - dayTextRect.height() / 2f val isDaySelected = selectedDayCoords.x != -1 && x == selectedDayCoords.x && y == selectedDayCoords.y - if (isDaySelected) { - canvas.drawCircle( - xPosCenter, - textY - dayTextRect.height() / 2, - textPaint.textSize * 0.8f, - circleStrokePaint - ) - if (day.isToday) { - textPaint.color = textColor - } - } else if (day.isToday && !isPrintVersion) { - canvas.drawCircle( - xPosCenter, - textY - dayTextRect.height() / 2, - textPaint.textSize * 0.8f, - getCirclePaint(day) - ) - } - - // mark days with a dot for each event - if (isMonthDayView && !isDaySelected && !day.isToday && day.dayEvents.isNotEmpty()) { - val height = dayTextRect.height() * 1.25f - val eventCount = day.dayEvents.size - val dotRadius = textPaint.textSize * 0.2f - val stepSize = dotRadius * 2.5f - val columnCount = EVENT_DOT_COLUMN_COUNT - - val dayEventsSorted = day.dayEvents - .asSequence() - .sortedWith( - comparator = compareBy({ it.startTS }, { it.endTS }, { it.title }) - ) - .distinctBy { it.color } - - var xDot: Float - var yDot = yPos + height + textPaint.textSize / 2 - var indexInRow: Int - - val dotCount = dayEventsSorted.count() - for ((index, event) in dayEventsSorted.withIndex()) { - indexInRow = index % columnCount - xDot = xPosCenter + stepSize * (indexInRow - (min(dotCount, columnCount)) / 2) - if (dotCount % 2 == 0) { // center even number of dots - xDot += stepSize / 2 - } - - if (index > 0 && indexInRow == 0) { // next row of dots - yDot += stepSize + when { + isDaySelected -> { + val strokePaint = + if (day.isToday && config.todayCircleColor != 0) { + Paint(circleStrokePaint).apply { color = config.todayCircleColor } + } else { + circleStrokePaint + } + canvas.drawCircle(circleCenterX, circleCenterY, numberPaint.textSize * 0.8f, strokePaint) + if (day.isToday) { + numberPaint.color = textColor } + } - // Always show a + sign if the event count exceeds columnCount. - if (eventCount - 1 != index && index >= columnCount * EVENT_DOT_ROW_COUNT - 1) { - plusTextPaint.textSize = stepSize * 1.5f - canvas.drawText("+", xDot, yDot + dotRadius * 1.2f, plusTextPaint) - break - } else { - val paint = eventDotPaint.apply { color = event.color } - canvas.drawCircle(xDot, yDot, dotRadius, paint) - } + day.isToday && !isPrintVersion -> { + canvas.drawCircle(circleCenterX, circleCenterY, numberPaint.textSize * 0.8f, getCirclePaint(day)) } } - canvas.drawText(dayNumber, xPosCenter, textY, textPaint) - dayVerticalOffsets.put(day.indexOnMonthView, (verticalOffset + textPaint.textSize * 2).toInt()) + canvas.drawText(dayNumber, numberX, numberBaseline, numberPaint) + + if (isMonthDayView && !isDaySelected && !day.isToday && day.dayEvents.isNotEmpty()) { + drawEventDots(canvas, day, xPos, rowTop) + } } curId++ } } - - if (!isMonthDayView) { - for (event in allEvents) { - drawEvent(event, canvas) - } - } } private fun drawGrid(canvas: Canvas) { @@ -292,7 +361,7 @@ class MonthView(context: Context, attrs: AttributeSet, defStyle: Int) : View(con // horizontal lines canvas.drawLine(0f, 0f, canvas.width.toFloat(), 0f, gridPaint) for (i in 0 until ROW_COUNT) { - canvas.drawLine(0f, i * dayHeight + weekDaysLetterHeight, canvas.width.toFloat(), i * dayHeight + weekDaysLetterHeight, gridPaint) + canvas.drawLine(0f, getRowTop(i).toFloat(), canvas.width.toFloat(), getRowTop(i).toFloat(), gridPaint) } canvas.drawLine(0f, canvas.height.toFloat(), canvas.width.toFloat(), canvas.height.toFloat(), gridPaint) } @@ -306,7 +375,7 @@ class MonthView(context: Context, attrs: AttributeSet, defStyle: Int) : View(con } else if (highlightWeekends && context.isWeekendIndex(i)) { weekDayLetterPaint = getColoredPaint(weekendsTextColor) } - canvas.drawText(dayLetters[i], xPos, weekDaysLetterHeight * 0.7f, weekDayLetterPaint) + canvas.drawText(dayLetters[i], xPos, weekDaysLetterHeight * 0.8f, weekDayLetterPaint) } } @@ -322,99 +391,204 @@ class MonthView(context: Context, attrs: AttributeSet, defStyle: Int) : View(con val id = "$weekOfYear:" val horizontalMarginFactor = 0.5f val xPos = horizontalOffset * horizontalMarginFactor - val yPos = i * dayHeight + weekDaysLetterHeight - canvas.drawText(id, xPos, yPos + textPaint.textSize, weekNumberPaint) + canvas.drawText(id, xPos, getRowTop(i) + textPaint.textSize, weekNumberPaint) } } - private fun measureDaySize(canvas: Canvas) { - dayWidth = (canvas.width - horizontalOffset) / 7f - dayHeight = (canvas.height - weekDaysLetterHeight) / ROW_COUNT.toFloat() - val availableHeightForEvents = dayHeight.toInt() - weekDaysLetterHeight - maxEventsPerDay = availableHeightForEvents / eventTitleHeight - } - - private fun drawEvent(event: MonthViewEvent, canvas: Canvas) { - var verticalOffset = 0 - for (i in 0 until min(event.daysCnt, 7 - event.startDayIndex % 7)) { - verticalOffset = max(verticalOffset, dayVerticalOffsets[event.startDayIndex + i]) - } - val xPos = event.startDayIndex % 7 * dayWidth + horizontalOffset - val yPos = (event.startDayIndex / 7) * dayHeight + private fun drawEventDots(canvas: Canvas, day: DayMonthly, xPos: Float, rowTop: Int) { val xPosCenter = xPos + dayWidth / 2 + val height = dayTextRect.height() * 1.25f + val eventCount = day.dayEvents.size + val dotRadius = textPaint.textSize * 0.2f + val stepSize = dotRadius * 2.5f + val columnCount = EVENT_DOT_COLUMN_COUNT + + val dayEventsSorted = day.dayEvents + .asSequence() + .sortedWith( + comparator = compareBy({ it.startTS }, { it.endTS }, { it.title }) + ) + .distinctBy { it.color } + + var xDot: Float + var yDot = rowTop + dayNumberHeight + height + textPaint.textSize / 2 + var indexInRow: Int + + val dotCount = dayEventsSorted.count() + for ((index, event) in dayEventsSorted.withIndex()) { + indexInRow = index % columnCount + xDot = xPosCenter + stepSize * (indexInRow - (min(dotCount, columnCount)) / 2) + if (dotCount % 2 == 0) { // center even number of dots + xDot += stepSize / 2 + } - if (verticalOffset - eventTitleHeight * 2 > dayHeight) { - val paint = getTextPaint(days[event.startDayIndex]) - paint.color = textColor - canvas.drawText("...", xPosCenter, yPos + verticalOffset - eventTitleHeight / 2, paint) - return - } + if (index > 0 && indexInRow == 0) { // next row of dots + yDot += stepSize + } - // event background rectangle - val backgroundY = yPos + verticalOffset - val bgLeft = xPos + smallPadding - val bgTop = backgroundY + smallPadding - eventTitleHeight - var bgRight = xPos - smallPadding + dayWidth * event.daysCnt - val bgBottom = backgroundY + smallPadding * 2 - if (bgRight > canvas.width.toFloat()) { - bgRight = canvas.width.toFloat() - smallPadding - val newStartDayIndex = (event.startDayIndex / 7 + 1) * 7 - if (newStartDayIndex < 42) { - val newEvent = event.copy(startDayIndex = newStartDayIndex, daysCnt = event.daysCnt - (newStartDayIndex - event.startDayIndex)) - drawEvent(newEvent, canvas) + // Always show a + sign if the event count exceeds columnCount. + if (eventCount - 1 != index && index >= columnCount * EVENT_DOT_ROW_COUNT - 1) { + plusTextPaint.textSize = stepSize * 1.5f + canvas.drawText("+", xDot, yDot + dotRadius * 1.2f, plusTextPaint) + break + } else { + val paint = eventDotPaint.apply { color = event.color } + canvas.drawCircle(xDot, yDot, dotRadius, paint) } } + } - bgRectF.set(bgLeft, bgTop, bgRight, bgBottom) - canvas.drawRoundRect(bgRectF, BG_CORNER_RADIUS, BG_CORNER_RADIUS, getEventBackgroundColor(event)) - - val specificEventTitlePaint = getEventTitlePaint(event) - var taskIconWidth = 0 - if (event.isTask) { - val taskIcon = resources.getColoredDrawableWithColor(R.drawable.ic_task_vector, specificEventTitlePaint.color).mutate() - val taskIconY = yPos.toInt() + verticalOffset - eventTitleHeight + smallPadding * 2 - taskIcon.setBounds(xPos.toInt() + smallPadding * 2, taskIconY, xPos.toInt() + eventTitleHeight + smallPadding * 2, taskIconY + eventTitleHeight) - taskIcon.draw(canvas) - taskIconWidth += eventTitleHeight + smallPadding + private fun drawMonthEvents(canvas: Canvas, row: Int, rowTop: Int) { + val (bars, singlesPerDay) = getRowEventsStructure(row) + val barBands = packBarBands(bars) + val rowBottom = rowTop + rowHeights[row] - smallPadding + val barsTop = rowTop + contentTop + + val bandCoverage = IntArray(COLUMN_COUNT) + for (i in bars.indices) { + val lineTop = barsTop + barBands[i] * eventLineStep + if (lineTop + eventLineStep > rowBottom) { + continue + } + drawEventBar(canvas, bars[i].event, bars[i].firstCol, bars[i].lastCol, lineTop) + for (col in bars[i].firstCol..bars[i].lastCol) { + bandCoverage[col] = max(bandCoverage[col], barBands[i] + 1) + } } - drawEventTitle(event, canvas, xPos + taskIconWidth, yPos + verticalOffset, bgRight - bgLeft - smallPadding - taskIconWidth, specificEventTitlePaint) + for (col in 0 until COLUMN_COUNT) { + val daySingles = singlesPerDay[col] + var dayTop = barsTop + bandCoverage[col] * eventLineStep + var count = 0 + while (count < daySingles.size) { + if (dayTop + eventLineStep > rowBottom) { + break + } + drawEventLine(canvas, daySingles[count].event, daySingles[count].firstCol, dayTop) + dayTop += eventLineStep + count++ + } + } + } - for (i in 0 until min(event.daysCnt, 7 - event.startDayIndex % 7)) { - dayVerticalOffsets.put(event.startDayIndex + i, verticalOffset + eventTitleHeight + smallPadding * 2) + // how many stacked lines a row needs: the packed bar bands on top plus the busiest single day + private fun getRowSlots(row: Int): Int { + val (bars, singlesPerDay) = getRowEventsStructure(row) + val bandCount = if (bars.isEmpty()) 0 else (packBarBands(bars).maxOrNull()!! + 1) + var maxDayCount = 0 + for (col in 0 until COLUMN_COUNT) { + maxDayCount = max(maxDayCount, singlesPerDay[col].size) } + return bandCount + maxDayCount } - private fun drawEventTitle(event: MonthViewEvent, canvas: Canvas, x: Float, y: Float, availableWidth: Float, paint: Paint) { - val ellipsized = TextUtils.ellipsize(event.title, eventTitlePaint, availableWidth - smallPadding, TextUtils.TruncateAt.END) - canvas.drawText(event.title, 0, ellipsized.length, x + smallPadding * 2, y, paint) + private fun getRowEventsStructure(row: Int): Pair, Array>> { + val bars = ArrayList() + val singlesPerDay = Array(COLUMN_COUNT) { ArrayList() } + for (rowEvent in getRowEvents(row)) { + if (rowEvent.lastCol > rowEvent.firstCol) { + bars.add(rowEvent) + } else { + singlesPerDay[rowEvent.firstCol].add(rowEvent) + } + } + bars.sortWith(compareBy({ it.firstCol }, { it.lastCol })) + return bars to singlesPerDay } - private fun getTextPaint(startDay: DayMonthly): Paint { - var paintColor = when { - !isPrintVersion && startDay.isToday -> primaryColor.getContrastColor() - highlightWeekends && startDay.isWeekend -> weekendsTextColor - else -> textColor + private fun packBarBands(bars: List): IntArray { + val barBands = IntArray(bars.size) + val bands = ArrayList() + for (i in bars.indices) { + val bar = bars[i] + var placed = -1 + for (b in 0 until bands.size) { + val band = bands[b] + var overlaps = false + for (c in bar.firstCol..bar.lastCol) { + if (band[c]) { + overlaps = true + break + } + } + if (!overlaps) { + placed = b + break + } + } + if (placed == -1) { + placed = bands.size + bands.add(BooleanArray(COLUMN_COUNT)) + } + val band = bands[placed] + for (c in bar.firstCol..bar.lastCol) { + band[c] = true + } + barBands[i] = placed } + return barBands + } - if (!startDay.isThisMonth) { - paintColor = paintColor.adjustAlpha(MEDIUM_ALPHA) + private fun getRowEvents(row: Int): ArrayList { + val eventMap = LinkedHashMap() + for (col in 0 until COLUMN_COUNT) { + val day = days.getOrNull(row * COLUMN_COUNT + col) ?: continue + for (event in day.dayEvents) { + val rowEvent = eventMap[event.id] + if (rowEvent == null) { + eventMap[event.id] = RowEvent(event).apply { + firstCol = col + lastCol = col + } + } else { + rowEvent.lastCol = col + } + } } - return getColoredPaint(paintColor) + val rowEvents = ArrayList(eventMap.values) + rowEvents.sortWith(compareBy({ !it.event.getIsAllDay() }, { it.event.startTS }, { it.event.title })) + return rowEvents } - private fun getColoredPaint(color: Int): Paint { - val curPaint = Paint(textPaint) - curPaint.color = color - return curPaint + private fun drawEventBar(canvas: Canvas, event: Event, firstCol: Int, lastCol: Int, lineTop: Int) { + val left = firstCol * dayWidth + horizontalOffset + smallPadding + val right = (lastCol + 1) * dayWidth + horizontalOffset - smallPadding + val top = (lineTop + smallPadding / 2).toFloat() + val bottom = (lineTop + eventLineStep - smallPadding).toFloat() + + val barColor = getEventLineColor(event) + eventStripPaint.color = barColor + val cornerRadius = smallPadding.toFloat() + canvas.drawRoundRect(left, top, right, bottom, cornerRadius, cornerRadius, eventStripPaint) + + val paint = getEventBarTitlePaint(barColor) + val baseline = (lineTop + eventTitleHeight + smallPadding / 2).toFloat() + val textX = firstCol * dayWidth + horizontalOffset + smallPadding * 2 + val availableWidth = ((lastCol - firstCol + 1) * dayWidth - smallPadding * 4).coerceAtLeast(0f) + if (availableWidth > 0) { + val title = event.title.trim() + val ellipsized = TextUtils.ellipsize(title, eventTitlePaint, availableWidth, TextUtils.TruncateAt.END) + canvas.drawText(ellipsized.toString(), 0, ellipsized.length, textX, baseline, paint) + } } - private fun getEventBackgroundColor(event: MonthViewEvent): Paint { - var paintColor = event.color + private fun drawEventLine(canvas: Canvas, event: Event, col: Int, lineTop: Int) { + val paint = getEventLineTitlePaint(event) + val baseline = (lineTop + eventTitleHeight + smallPadding).toFloat() + val textX = col * dayWidth + horizontalOffset + smallPadding + val availableWidth = (dayWidth - smallPadding * 2).coerceAtLeast(0f) + if (availableWidth > 0) { + val title = event.title.trim() + val ellipsized = TextUtils.ellipsize(title, eventTitlePaint, availableWidth, TextUtils.TruncateAt.END) + canvas.drawText(ellipsized.toString(), 0, ellipsized.length, textX, baseline, paint) + } + } + private fun getEventLineColor(event: Event): Int { + var paintColor = event.color val adjustAlpha = when { - event.isTask -> dimCompletedTasks && event.isTaskCompleted + event.isTask() -> dimCompletedTasks && event.isTaskCompleted() else -> dimPastEvents && event.isPastEvent && !isPrintVersion } @@ -422,29 +596,46 @@ class MonthView(context: Context, attrs: AttributeSet, defStyle: Int) : View(con paintColor = paintColor.adjustAlpha(MEDIUM_ALPHA) } - return getColoredPaint(paintColor) + return paintColor } - private fun getEventTitlePaint(event: MonthViewEvent): Paint { - var paintColor = event.color.getContrastColor() - val adjustAlpha = when { - event.isTask -> dimCompletedTasks && event.isTaskCompleted - else -> dimPastEvents && event.isPastEvent && !isPrintVersion + private fun getEventLineTitlePaint(event: Event): Paint { + val curPaint = Paint(eventTitlePaint) + curPaint.color = getEventLineColor(event) + curPaint.isStrikeThruText = event.shouldStrikeThrough() + return curPaint + } + + private fun getEventBarTitlePaint(barColor: Int): Paint { + val curPaint = Paint(eventTitlePaint) + curPaint.color = barColor.getContrastColor() + return curPaint + } + + private fun getTextPaint(startDay: DayMonthly): Paint { + var paintColor = when { + !isPrintVersion && startDay.isToday -> primaryColor.getContrastColor() + highlightWeekends && startDay.isWeekend -> weekendsTextColor + else -> textColor } - if (adjustAlpha) { - paintColor = paintColor.adjustAlpha(HIGHER_ALPHA) + if (!startDay.isThisMonth) { + paintColor = paintColor.adjustAlpha(MEDIUM_ALPHA) } - val curPaint = Paint(eventTitlePaint) - curPaint.color = paintColor - curPaint.isStrikeThruText = event.shouldStrikeThrough() + return getColoredPaint(paintColor) + } + + private fun getColoredPaint(color: Int): Paint { + val curPaint = Paint(textPaint) + curPaint.color = color return curPaint } private fun getCirclePaint(day: DayMonthly): Paint { val curPaint = Paint(textPaint) - var paintColor = primaryColor + val configuredColor = config.todayCircleColor + var paintColor = if (configuredColor != 0) configuredColor else primaryColor if (!day.isThisMonth) { paintColor = paintColor.adjustAlpha(MEDIUM_ALPHA) } @@ -467,30 +658,6 @@ class MonthView(context: Context, attrs: AttributeSet, defStyle: Int) : View(con currDayOfWeek = context.getProperDayIndexInWeek(DateTime()) } - // take into account cases when an event starts on the previous screen, subtract those days - private fun getEventLastingDaysCount(event: Event): Int { - val startDateTime = Formatter.getDateTimeFromTS(event.startTS) - val endDateTime = Formatter.getDateTimeFromTS(event.endTS) - val code = days.first().code - val screenStartDateTime = Formatter.getDateTimeFromCode(code).toLocalDate() - var eventStartDateTime = Formatter.getDateTimeFromTS(startDateTime.seconds()).toLocalDate() - val eventEndDateTime = Formatter.getDateTimeFromTS(endDateTime.seconds()).toLocalDate() - val diff = Days.daysBetween(screenStartDateTime, eventStartDateTime).days - if (diff < 0) { - eventStartDateTime = screenStartDateTime - } - - val isMidnight = Formatter.getDateTimeFromTS(endDateTime.seconds()) == Formatter.getDateTimeFromTS(endDateTime.seconds()).withTimeAtStartOfDay() - val numDays = Days.daysBetween(eventStartDateTime, eventEndDateTime).days - val daysCnt = if (numDays == 1 && isMidnight) 0 else numDays - return daysCnt + 1 - } - - private fun isDayValid(event: Event, code: String): Boolean { - val date = Formatter.getDateTimeFromCode(code) - return event.startTS != event.endTS && Formatter.getDateTimeFromTS(event.endTS) == Formatter.getDateTimeFromTS(date.seconds()).withTimeAtStartOfDay() - } - fun togglePrintMode() { isPrintVersion = !isPrintVersion textColor = if (isPrintVersion) { @@ -509,4 +676,4 @@ class MonthView(context: Context, attrs: AttributeSet, defStyle: Int) : View(con selectedDayCoords = Point(x, y) invalidate() } -} +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/fossify/calendar/views/MonthViewWrapper.kt b/app/src/main/kotlin/org/fossify/calendar/views/MonthViewWrapper.kt index 664844c4b..3fdbcedcb 100644 --- a/app/src/main/kotlin/org/fossify/calendar/views/MonthViewWrapper.kt +++ b/app/src/main/kotlin/org/fossify/calendar/views/MonthViewWrapper.kt @@ -3,7 +3,11 @@ package org.fossify.calendar.views import android.content.Context import android.util.AttributeSet import android.view.LayoutInflater +import android.view.MotionEvent +import android.view.VelocityTracker +import android.view.ViewConfiguration import android.widget.FrameLayout +import android.widget.OverScroller import org.fossify.calendar.R import org.fossify.calendar.databinding.MonthViewBackgroundBinding import org.fossify.calendar.databinding.MonthViewBinding @@ -13,7 +17,6 @@ import org.fossify.calendar.extensions.launchNewEventIntent import org.fossify.calendar.extensions.launchNewTaskIntent import org.fossify.calendar.helpers.COLUMN_COUNT import org.fossify.calendar.helpers.Formatter -import org.fossify.calendar.helpers.ROW_COUNT import org.fossify.calendar.helpers.TYPE_EVENT import org.fossify.calendar.helpers.TYPE_TASK import org.fossify.calendar.models.DayMonthly @@ -21,6 +24,7 @@ import org.fossify.commons.compose.extensions.getActivity import org.fossify.commons.dialogs.RadioGroupDialog import org.fossify.commons.extensions.onGlobalLayout import org.fossify.commons.models.RadioItem +import kotlin.math.abs // used in the Monthly view fragment, 1 view per screen class MonthViewWrapper( @@ -29,9 +33,10 @@ class MonthViewWrapper( defStyle: Int ) : FrameLayout(context, attrs, defStyle) { private var dayWidth = 0f - private var dayHeight = 0f private var weekDaysLetterHeight = 0 private var horizontalOffset = 0 + private var parentHeight = 0 + private var contentHeight = 0 private var wereViewsAdded = false private var isMonthDayView = true private var days = ArrayList() @@ -39,6 +44,13 @@ class MonthViewWrapper( private var binding: MonthViewBinding private var dayClickCallback: ((day: DayMonthly) -> Unit)? = null + private val touchSlop: Int + private val scroller: OverScroller + private var velocityTracker: VelocityTracker? = null + private var downRawY = 0f + private var lastRawY = 0f + private var isDragging = false + constructor(context: Context, attrs: AttributeSet) : this(context, attrs, 0) init { @@ -50,6 +62,11 @@ class MonthViewWrapper( binding = MonthViewBinding.inflate(inflater, this, true) setupHorizontalOffset() + clipChildren = true + clipToPadding = true + touchSlop = ViewConfiguration.get(context).scaledTouchSlop + scroller = OverScroller(context) + onGlobalLayout { if (!wereViewsAdded && days.isNotEmpty()) { measureSizes() @@ -59,51 +76,152 @@ class MonthViewWrapper( } } + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + val width = MeasureSpec.getSize(widthMeasureSpec) + val height = MeasureSpec.getSize(heightMeasureSpec) + parentHeight = height + dayWidth = (width - horizontalOffset) / COLUMN_COUNT.toFloat() + + binding.monthView.setMinimumContentHeight(height) + binding.monthView.measure( + MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY), + MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED) + ) + contentHeight = binding.monthView.measuredHeight + + setMeasuredDimension(width, height) + scrollTo(scrollX, scrollY.coerceIn(0, maxScrollY())) + } + override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) { - super.onLayout(changed, left, top, right, bottom) - measureSizes() - var y = 0 - var x = 0 - var curLeft = dayWidth.toInt() - val end = right + paddingRight + binding.monthView.layout(0, 0, right - left, contentHeight) + var index = 0 for (i in 0 until childCount) { val child = getChildAt(i) - if (child is MonthView) { - //ignore the MonthView layout + if (child === binding.monthView) { continue } + val viewX = index % COLUMN_COUNT + val viewY = index / COLUMN_COUNT + val cellWidth = dayWidth.toInt() + val cellHeight = binding.monthView.getRowHeight(viewY) + val childLeft = (viewX * dayWidth + horizontalOffset).toInt() + val childTop = binding.monthView.getRowTop(viewY) + child.measure( - MeasureSpec.makeMeasureSpec(dayWidth.toInt(), MeasureSpec.EXACTLY), - MeasureSpec.makeMeasureSpec(dayHeight.toInt(), MeasureSpec.EXACTLY) + MeasureSpec.makeMeasureSpec(cellWidth, MeasureSpec.EXACTLY), + MeasureSpec.makeMeasureSpec(cellHeight, MeasureSpec.EXACTLY) ) - val childLeft = x * dayWidth + horizontalOffset - child.translationX - val childTop = y * dayHeight + weekDaysLetterHeight - child.translationY - val childWidth = child.measuredWidth - val childHeight = child.measuredHeight - val childRight = childLeft + childWidth - val childBottom = childTop + childHeight - - child.layout( - childLeft.toInt(), - childTop.toInt(), - childRight.toInt(), - childBottom.toInt() - ) + child.layout(childLeft, childTop, childLeft + child.measuredWidth, childTop + child.measuredHeight) + index++ + } + } + + override fun onInterceptTouchEvent(event: MotionEvent): Boolean { + if (maxScrollY() == 0) { + return super.onInterceptTouchEvent(event) + } + + when (event.actionMasked) { + MotionEvent.ACTION_DOWN -> { + downRawY = event.rawY + lastRawY = event.rawY + isDragging = false + if (!scroller.isFinished) { + scroller.abortAnimation() + } + velocityTracker?.recycle() + velocityTracker = VelocityTracker.obtain().apply { addMovement(event) } + } + + MotionEvent.ACTION_MOVE -> { + velocityTracker?.addMovement(event) + if (!isDragging && abs(event.rawY - downRawY) > touchSlop) { + isDragging = true + } + return isDragging + } + + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { + velocityTracker?.recycle() + velocityTracker = null + isDragging = false + } + } + return isDragging + } + + override fun onTouchEvent(event: MotionEvent): Boolean { + if (maxScrollY() == 0) { + return super.onTouchEvent(event) + } + + when (event.actionMasked) { + MotionEvent.ACTION_DOWN -> { + if (velocityTracker == null) { + velocityTracker = VelocityTracker.obtain() + } + velocityTracker?.addMovement(event) + lastRawY = event.rawY + isDragging = false + if (!scroller.isFinished) { + scroller.abortAnimation() + } + return true + } + + MotionEvent.ACTION_MOVE -> { + velocityTracker?.addMovement(event) + if (!isDragging && abs(event.rawY - downRawY) > touchSlop) { + isDragging = true + } + if (isDragging) { + val dy = (lastRawY - event.rawY).toInt() + lastRawY = event.rawY + if (dy != 0) { + scrollBy(0, dy) + scrollTo(scrollX, scrollY.coerceIn(0, maxScrollY())) + } + return true + } + } + + MotionEvent.ACTION_UP -> { + velocityTracker?.addMovement(event) + velocityTracker?.computeCurrentVelocity(1000) + if (isDragging) { + val velocityY = velocityTracker?.yVelocity ?: 0f + scroller.fling(scrollX, scrollY, 0, -velocityY.toInt(), 0, 0, 0, maxScrollY()) + invalidate() + } + velocityTracker?.recycle() + velocityTracker = null + isDragging = false + return true + } - if (curLeft + childWidth <= end) { - curLeft += childWidth - x++ - } else { - y++ - x = 0 - curLeft = childWidth + MotionEvent.ACTION_CANCEL -> { + velocityTracker?.recycle() + velocityTracker = null + isDragging = false + return true } } + return super.onTouchEvent(event) } + override fun computeScroll() { + if (scroller.computeScrollOffset()) { + scrollTo(scrollX, scroller.currY.coerceIn(0, maxScrollY())) + invalidate() + } + } + + private fun maxScrollY(): Int = (contentHeight - parentHeight).coerceAtLeast(0) + fun updateDays( newDays: ArrayList, addEvents: Boolean, @@ -113,7 +231,7 @@ class MonthViewWrapper( measureSizes() dayClickCallback = callback days = newDays - if (dayWidth != 0f && dayHeight != 0f) { + if (dayWidth != 0f) { addClickableBackgrounds() } @@ -127,7 +245,6 @@ class MonthViewWrapper( private fun measureSizes() { dayWidth = (width - horizontalOffset) / COLUMN_COUNT.toFloat() - dayHeight = (height - weekDaysLetterHeight) / ROW_COUNT.toFloat() } private fun addClickableBackgrounds() { @@ -137,11 +254,9 @@ class MonthViewWrapper( days.forEachIndexed { index, day -> addViewBackground(index % COLUMN_COUNT, index / COLUMN_COUNT, day) } - } private fun addViewBackground(viewX: Int, viewY: Int, day: DayMonthly) { - MonthViewBackgroundBinding.inflate(inflater, this, false).root.apply { if (isMonthDayView) { background = null @@ -189,4 +304,4 @@ class MonthViewWrapper( fun togglePrintMode() { binding.monthView.togglePrintMode() } -} +} \ No newline at end of file diff --git a/app/src/main/res/drawable-nodpi/ic_launcher_foreground.png b/app/src/main/res/drawable-nodpi/ic_launcher_foreground.png new file mode 100644 index 000000000..df4dda042 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/ic_launcher_foreground.png differ diff --git a/app/src/main/res/drawable/ic_drag_handle_vector.xml b/app/src/main/res/drawable/ic_drag_handle_vector.xml new file mode 100644 index 000000000..2d27340ea --- /dev/null +++ b/app/src/main/res/drawable/ic_drag_handle_vector.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml index 23fadeb21..a7aec6f37 100644 --- a/app/src/main/res/drawable/ic_launcher_background.xml +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -1,3 +1,3 @@ - + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml deleted file mode 100644 index bbf5085e3..000000000 --- a/app/src/main/res/drawable/ic_launcher_foreground.xml +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index eac99df0e..95f7d7177 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -10,6 +10,18 @@ android:layout_width="match_parent" android:layout_height="wrap_content" /> + + + + + + + + + + + + + + + + @@ -689,6 +730,29 @@ android:layout_height="wrap_content" android:text="@string/monthly_view" /> + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_month.xml b/app/src/main/res/layout/fragment_month.xml index d89615dbd..ebaab1d70 100644 --- a/app/src/main/res/layout/fragment_month.xml +++ b/app/src/main/res/layout/fragment_month.xml @@ -4,12 +4,10 @@ android:layout_width="match_parent" android:layout_height="match_parent"> - - + android:layout_alignParentTop="true" /> diff --git a/app/src/main/res/layout/item_reorder_calendar.xml b/app/src/main/res/layout/item_reorder_calendar.xml new file mode 100644 index 000000000..ad1d40d92 --- /dev/null +++ b/app/src/main/res/layout/item_reorder_calendar.xml @@ -0,0 +1,44 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/top_navigation.xml b/app/src/main/res/layout/top_navigation.xml index 299e897ef..f9b75e133 100644 --- a/app/src/main/res/layout/top_navigation.xml +++ b/app/src/main/res/layout/top_navigation.xml @@ -23,9 +23,9 @@ android:layout_toStartOf="@+id/top_right_arrow" android:layout_toEndOf="@+id/top_left_arrow" android:gravity="center" - android:paddingTop="@dimen/normal_margin" - android:paddingBottom="@dimen/normal_margin" - android:textSize="@dimen/month_text_size" + android:paddingTop="@dimen/smaller_margin" + android:paddingBottom="@dimen/smaller_margin" + android:textSize="@dimen/month_header_text_size" tools:text="January" /> - - - - - diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_blue.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_blue.xml deleted file mode 100644 index c81738497..000000000 --- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_blue.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_blue_grey.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_blue_grey.xml deleted file mode 100644 index e5ae9a175..000000000 --- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_blue_grey.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_brown.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_brown.xml deleted file mode 100644 index 6c1d26030..000000000 --- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_brown.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_cyan.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_cyan.xml deleted file mode 100644 index cc384dc69..000000000 --- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_cyan.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_deep_orange.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_deep_orange.xml deleted file mode 100644 index 5250a44f1..000000000 --- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_deep_orange.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_deep_purple.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_deep_purple.xml deleted file mode 100644 index 81ba15b52..000000000 --- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_deep_purple.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_grey_black.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_grey_black.xml deleted file mode 100644 index c0477c1e1..000000000 --- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_grey_black.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_indigo.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_indigo.xml deleted file mode 100644 index 20f9f44a9..000000000 --- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_indigo.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_light_blue.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_light_blue.xml deleted file mode 100644 index dd752f852..000000000 --- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_light_blue.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_light_green.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_light_green.xml deleted file mode 100644 index eee16d215..000000000 --- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_light_green.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_lime.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_lime.xml deleted file mode 100644 index cfe22da4d..000000000 --- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_lime.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_orange.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_orange.xml deleted file mode 100644 index af5af0b12..000000000 --- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_orange.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_pink.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_pink.xml deleted file mode 100644 index 00a28a52f..000000000 --- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_pink.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_purple.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_purple.xml deleted file mode 100644 index 8c55541ac..000000000 --- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_purple.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_red.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_red.xml deleted file mode 100644 index f500e3c15..000000000 --- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_red.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_teal.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_teal.xml deleted file mode 100644 index 64fd7f8b0..000000000 --- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_teal.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_yellow.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_yellow.xml deleted file mode 100644 index 74726f57b..000000000 --- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_yellow.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml index 33ced27e1..5dfe03709 100644 --- a/app/src/main/res/values/dimens.xml +++ b/app/src/main/res/values/dimens.xml @@ -24,9 +24,12 @@ 24dp 11sp + 12sp + 12sp 14sp 17sp 22sp + 15sp 8sp @@ -36,7 +39,7 @@ 40dp 16dp - 48dp + 32dp 88dp 6dp 2dp diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 09cedfaee..7ab5b7326 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -5,6 +5,7 @@ Daily view Weekly view Monthly view + Month view font size Monthly and daily view Yearly view Simple event list @@ -227,8 +228,11 @@ Other time Highlight weekends on some views Color of highlighted weekends + Color of today\'s circle in month view Allow changing event time zones Manage quick filter calendars + Reorder quick filter calendars + Drag the handle to change the order Allow creating tasks diff --git a/gradle.properties b/gradle.properties index d5bc32cc9..de27320d5 100644 --- a/gradle.properties +++ b/gradle.properties @@ -3,6 +3,6 @@ android.useAndroidX=true org.gradle.jvmargs=-Xmx8192m # Versioning -VERSION_NAME=1.10.3 -VERSION_CODE=21 +VERSION_NAME=1.10.8 +VERSION_CODE=26 APP_ID=org.fossify.calendar