From 3fb06eb76b1ca336c0b70c8b8d5c5ad025778538 Mon Sep 17 00:00:00 2001 From: cakeni <211545599+cakeni@users.noreply.github.com> Date: Sun, 30 Aug 2026 20:37:57 +0800 Subject: [PATCH 1/7] feat: refresh schedule UI and add SWPU import --- app/build.gradle | 1 + app/src/main/AndroidManifest.xml | 7 + .../data/backup/BackupModels.kt | 1 + .../com/courseschedule/ui/MainActivity.kt | 252 ++++++++- .../java/com/courseschedule/ui/ViewMotion.kt | 136 +++++ .../com/courseschedule/ui/WeekPagerAdapter.kt | 122 ++++- .../ui/importdata/ImportActivity.kt | 127 ++++- .../ui/importdata/SwpuWebImportActivity.kt | 499 ++++++++++++++++++ .../ui/importdata/WiseduScheduleParser.kt | 215 ++++++++ .../ui/settings/SettingsActivity.kt | 130 ++++- .../utils/SchedulePreferences.kt | 7 + .../courseschedule/view/CourseTableView.kt | 327 ++++++++++-- .../res/drawable/bg_bottom_navigation.xml | 6 + .../res/drawable/bg_course_detail_sheet.xml | 5 + app/src/main/res/drawable/bg_day_selected.xml | 1 + .../res/drawable/bg_import_school_icon.xml | 5 + .../main/res/drawable/bg_schedule_screen.xml | 7 + app/src/main/res/drawable/bg_sheet_handle.xml | 5 + .../main/res/drawable/bg_week_selector.xml | 5 + app/src/main/res/drawable/ic_add.xml | 2 +- app/src/main/res/drawable/ic_edit.xml | 10 + app/src/main/res/drawable/ic_person.xml | 10 + app/src/main/res/drawable/ic_room.xml | 10 + app/src/main/res/drawable/ic_time.xml | 10 + app/src/main/res/layout/activity_import.xml | 140 ++++- app/src/main/res/layout/activity_main.xml | 69 ++- app/src/main/res/layout/activity_settings.xml | 63 ++- .../res/layout/activity_swpu_web_import.xml | 129 +++++ .../main/res/layout/dialog_course_details.xml | 165 +++--- .../main/res/layout/item_week_schedule.xml | 31 +- app/src/main/res/menu/menu_main.xml | 6 + app/src/main/res/values/colors.xml | 79 +-- app/src/main/res/values/strings.xml | 20 + app/src/main/res/values/themes.xml | 49 +- .../ui/importdata/ImportParserTest.kt | 1 + .../ui/importdata/WiseduScheduleParserTest.kt | 213 ++++++++ 36 files changed, 2584 insertions(+), 281 deletions(-) create mode 100644 app/src/main/java/com/courseschedule/ui/ViewMotion.kt create mode 100644 app/src/main/java/com/courseschedule/ui/importdata/SwpuWebImportActivity.kt create mode 100644 app/src/main/java/com/courseschedule/ui/importdata/WiseduScheduleParser.kt create mode 100644 app/src/main/res/drawable/bg_bottom_navigation.xml create mode 100644 app/src/main/res/drawable/bg_course_detail_sheet.xml create mode 100644 app/src/main/res/drawable/bg_import_school_icon.xml create mode 100644 app/src/main/res/drawable/bg_schedule_screen.xml create mode 100644 app/src/main/res/drawable/bg_sheet_handle.xml create mode 100644 app/src/main/res/drawable/bg_week_selector.xml create mode 100644 app/src/main/res/drawable/ic_edit.xml create mode 100644 app/src/main/res/drawable/ic_person.xml create mode 100644 app/src/main/res/drawable/ic_room.xml create mode 100644 app/src/main/res/drawable/ic_time.xml create mode 100644 app/src/main/res/layout/activity_swpu_web_import.xml create mode 100644 app/src/test/java/com/courseschedule/ui/importdata/WiseduScheduleParserTest.kt diff --git a/app/build.gradle b/app/build.gradle index b5b49a3..ef6d95e 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -50,6 +50,7 @@ dependencies { implementation 'androidx.customview:customview:1.1.0' implementation 'androidx.cardview:cardview:1.0.0' implementation 'androidx.viewpager2:viewpager2:1.0.0' + implementation 'androidx.dynamicanimation:dynamicanimation:1.1.0' // Room Database implementation 'androidx.room:room-runtime:2.6.0' diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 2d31172..40d8ec8 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -3,6 +3,7 @@ xmlns:tools="http://schemas.android.com/tools"> + @@ -41,6 +42,12 @@ android:label="@string/import_course" android:theme="@style/Theme.CourseSchedule" /> + + = emptyList() private var semesterWeekStatus: SemesterWeekStatus? = null private var pageSettings = WeekPageSettings() + private var lastPagerPosition = -1 + private var lastAnimatedPagerPosition = -1 + private var pendingPagerMotionPosition = -1 + private var pendingPagerMotionForward = true + private var pagerMotionReady = false + private var semesterDataLoaded = false + private var coursesDataLoaded = false + private var suppressBottomNavigationMotion = false + private var hasResumedOnce = false + private val headerInterpolator = PathInterpolator(0.2f, 0.8f, 0.2f, 1f) private val pageChangeCallback = object : ViewPager2.OnPageChangeCallback() { override fun onPageSelected(position: Int) { + val previousPosition = lastPagerPosition + lastPagerPosition = position val week = position + 1 if (currentWeek != week) { currentWeek = week viewModel.setCurrentWeek(week) } updateWeekDisplay() + if (pagerMotionReady && previousPosition >= 0 && previousPosition != position) { + pendingPagerMotionPosition = position + pendingPagerMotionForward = position > previousPosition + animateWeekHeader(forward = pendingPagerMotionForward) + if (binding.weekPager.scrollState == ViewPager2.SCROLL_STATE_IDLE) { + playSelectedPageMotion(position, pendingPagerMotionForward) + pendingPagerMotionPosition = -1 + } + } + } + + override fun onPageScrollStateChanged(state: Int) { + if (state != ViewPager2.SCROLL_STATE_IDLE || pendingPagerMotionPosition < 0) return + playSelectedPageMotion(pendingPagerMotionPosition, pendingPagerMotionForward) + pendingPagerMotionPosition = -1 } } @@ -88,8 +123,19 @@ class MainActivity : AppCompatActivity() { onAddCourse = { startActivity(Intent(this, AddCourseActivity::class.java)) } ) binding.weekPager.adapter = weekPagerAdapter + binding.weekPager.visibility = View.INVISIBLE binding.weekPager.offscreenPageLimit = 1 binding.weekPager.registerOnPageChangeCallback(pageChangeCallback) + binding.weekPager.setPageTransformer { page, position -> + val distance = abs(position).coerceIn(0f, 1f) + val scale = 1f - (distance * 0.02f) + page.alpha = 1f - (distance * 0.16f) + page.scaleX = scale + page.scaleY = scale + page.translationX = -position * dp(14f) * (1f - distance) + page.rotationY = 0f + page.findViewById(R.id.courseTableView)?.setPagerOffset(position) + } binding.btnPreviousWeek.setOnClickListener { selectWeek(currentWeek - 1, smoothScroll = true) @@ -100,36 +146,88 @@ class MainActivity : AppCompatActivity() { } binding.weekInfo.setOnClickListener { showWeekPicker() } - - binding.fabAddCourse.setOnClickListener { - startActivity(Intent(this, AddCourseActivity::class.java)) - } + binding.weekInfo.installPressScale(0.97f) + binding.btnPreviousWeek.installPressScale(0.97f) + binding.btnNextWeek.installPressScale(0.97f) binding.bottomNavigation.setOnItemSelectedListener { item -> + if (suppressBottomNavigationMotion) return@setOnItemSelectedListener true + val itemView = binding.bottomNavigation.findViewById(item.itemId) when (item.itemId) { - R.id.nav_home -> true + R.id.nav_home -> { + itemView.playNavigationMotion() + true + } R.id.nav_import -> { - startActivity(Intent(this, ImportActivity::class.java)) + openTab(Intent(this, ImportActivity::class.java)) true } R.id.nav_settings -> { - startActivity(Intent(this, SettingsActivity::class.java)) + openTab(Intent(this, SettingsActivity::class.java)) true } else -> false } } + binding.bottomNavigation.setOnItemReselectedListener { item -> + binding.bottomNavigation.findViewById(item.itemId)?.playNavigationMotion() + } + } + + private fun animateWeekHeader(forward: Boolean) { + val offset = dp(if (forward) 18f else -18f) + listOf(binding.tvCurrentWeek, binding.tvWeekContext).forEachIndexed { index, view -> + view.animate().cancel() + view.alpha = 0.25f + view.translationX = offset + view.animate() + .alpha(1f) + .translationX(0f) + .setStartDelay(index * 28L) + .setDuration(290L) + .setInterpolator(headerInterpolator) + .start() + } } + private fun openTab(intent: Intent) { + startActivity(intent) + overridePendingTransition(0, 0) + } + + private fun playSelectedPageMotion(position: Int, forward: Boolean = true) { + if (lastAnimatedPagerPosition == position) return + binding.weekPager.post { + if (binding.weekPager.currentItem != position) return@post + if (weekPagerAdapter.playSelectionMotion(binding.weekPager, position, forward)) { + lastAnimatedPagerPosition = position + } + } + } + + private fun dp(value: Float): Float = value * resources.displayMetrics.density + private fun observeData() { viewModel.currentSemester.observe(this) { semester -> + semesterDataLoaded = true + if (currentSemester?.id != semester?.id) { + lastAnimatedPagerPosition = -1 + pendingPagerMotionPosition = -1 + pagerMotionReady = false + } currentSemester = semester semester?.let { - binding.toolbar.subtitle = it.name refreshWeekPager() syncPagerToCurrentWeek(smoothScroll = false) updateWeekDisplay() + binding.weekPager.post { + if (currentSemester?.id == it.id) { + lastPagerPosition = binding.weekPager.currentItem + pagerMotionReady = true + } + } } + showWeekPagerWhenReady() } viewModel.currentWeek.observe(this) { week -> @@ -145,9 +243,17 @@ class MainActivity : AppCompatActivity() { } viewModel.allCourses.observe(this) { courses -> + coursesDataLoaded = true currentCourses = courses refreshWeekPager() updateWeekDisplay() + showWeekPagerWhenReady() + } + } + + private fun showWeekPagerWhenReady() { + if (semesterDataLoaded && coursesDataLoaded) { + binding.weekPager.visibility = View.VISIBLE } } @@ -185,8 +291,9 @@ class MainActivity : AppCompatActivity() { } } - private fun showCourseDetails(course: Course) { + private fun showCourseDetails(course: Course, sourceView: View, sourceBounds: RectF) { val detailView = layoutInflater.inflate(R.layout.dialog_course_details, null) + detailView.findViewById(R.id.tvDetailTitle).text = course.courseName val teacherMissing = course.teacher.isBlank() detailView.findViewById(R.id.tvDetailTeacher).text = if (teacherMissing) { getString(R.string.teacher_not_provided) @@ -224,12 +331,80 @@ class MainActivity : AppCompatActivity() { noteRow.visibility = View.VISIBLE } - MaterialAlertDialogBuilder(this) - .setTitle(course.courseName) - .setView(detailView) - .setNegativeButton(R.string.close, null) - .setPositiveButton(R.string.edit) { _, _ -> openCourseEditor(course) } - .show() + val dialog = BottomSheetDialog(this) + detailView.findViewById(R.id.btnEditCourse).apply { + installPressScale(0.9f) + setOnClickListener { + dialog.dismiss() + openCourseEditor(course) + } + } + dialog.setContentView(detailView) + dialog.setOnShowListener { + dialog.findViewById(com.google.android.material.R.id.design_bottom_sheet)?.apply { + setBackgroundColor(Color.TRANSPARENT) + playCourseDetailEntrance(detailView, sourceView, sourceBounds) + } + dialog.behavior.state = BottomSheetBehavior.STATE_EXPANDED + dialog.behavior.skipCollapsed = true + } + dialog.show() + } + + private fun playCourseDetailEntrance( + detailView: View, + sourceView: View, + sourceBounds: RectF + ) { + detailView.doOnPreDraw { + val sourceLocation = IntArray(2) + val detailLocation = IntArray(2) + sourceView.getLocationOnScreen(sourceLocation) + detailView.getLocationOnScreen(detailLocation) + + val sourceCenterX = sourceLocation[0] + sourceBounds.centerX() + val sourceCenterY = sourceLocation[1] + sourceBounds.centerY() + val detailCenterX = detailLocation[0] + detailView.width / 2f + val detailCenterY = detailLocation[1] + detailView.height / 2f + + detailView.pivotX = (sourceCenterX - detailLocation[0]) + .coerceIn(0f, detailView.width.toFloat()) + detailView.pivotY = (sourceCenterY - detailLocation[1]) + .coerceIn(0f, detailView.height.toFloat()) + detailView.alpha = 0.72f + detailView.scaleX = 0.9f + detailView.scaleY = 0.92f + detailView.translationX = ((sourceCenterX - detailCenterX) * 0.16f) + .coerceIn(-dp(44f), dp(44f)) + detailView.translationY = ((sourceCenterY - detailCenterY) * 0.18f) + .coerceIn(-dp(72f), dp(104f)) + + (detailView as? ViewGroup)?.let { content -> + repeat(content.childCount) { index -> + content.getChildAt(index).apply { + alpha = 0f + translationY = dp(10f + index.coerceAtMost(3) * 2f) + animate() + .alpha(1f) + .translationY(0f) + .setStartDelay(90L + index * 36L) + .setDuration(330L) + .setInterpolator(headerInterpolator) + .start() + } + } + } + + detailView.animate() + .alpha(1f) + .scaleX(1f) + .scaleY(1f) + .translationX(0f) + .translationY(0f) + .setDuration(480L) + .setInterpolator(headerInterpolator) + .start() + } } private fun openCourseEditor(course: Course) { @@ -246,6 +421,26 @@ class MainActivity : AppCompatActivity() { private fun updateWeekDisplay() { val semester = currentSemester ?: return val status = semesterWeekStatus + val displayDate = Calendar.getInstance().apply { + if (status?.phase != SemesterPhase.ACTIVE || status.week != currentWeek) { + timeInMillis = semester.startDate + add(Calendar.DAY_OF_MONTH, (currentWeek - 1) * 7) + } + } + binding.toolbar.title = SimpleDateFormat("yyyy/M/d", Locale.CHINA).format(displayDate.time) + val weekday = resources.getStringArray(R.array.weekdays).getOrElse( + when (displayDate.get(Calendar.DAY_OF_WEEK)) { + Calendar.MONDAY -> 0 + Calendar.TUESDAY -> 1 + Calendar.WEDNESDAY -> 2 + Calendar.THURSDAY -> 3 + Calendar.FRIDAY -> 4 + Calendar.SATURDAY -> 5 + Calendar.SUNDAY -> 6 + else -> 0 + } + ) { "" } + binding.toolbar.subtitle = getString(R.string.toolbar_week_summary, currentWeek, weekday) binding.tvCurrentWeek.text = getString(R.string.week_format, currentWeek) val isOutsideSemester = status?.phase == SemesterPhase.BEFORE || status?.phase == SemesterPhase.AFTER @@ -263,12 +458,6 @@ class MainActivity : AppCompatActivity() { weekCourses.filter { it.dayOfWeek <= 5 } } binding.tvWeekContext.text = buildWeekContext(status, visibleCourses.size) - binding.fabAddCourse.visibility = if (visibleCourses.isEmpty()) { - android.view.View.GONE - } else { - android.view.View.VISIBLE - } - binding.weekProgress.max = semester.totalWeeks binding.weekProgress.progress = currentWeek binding.weekProgress.setIndicatorColor( @@ -329,6 +518,10 @@ class MainActivity : AppCompatActivity() { override fun onOptionsItemSelected(item: MenuItem): Boolean { return when (item.itemId) { + R.id.action_add_course -> { + startActivity(Intent(this, AddCourseActivity::class.java)) + true + } R.id.action_today -> { viewModel.refreshSemesterStatus() semesterWeekStatus?.week?.let { selectWeek(it, smoothScroll = true) } @@ -341,7 +534,19 @@ class MainActivity : AppCompatActivity() { override fun onResume() { super.onResume() applyDisplaySettings() - binding.bottomNavigation.selectedItemId = R.id.nav_home + val returningToHome = hasResumedOnce && + binding.bottomNavigation.selectedItemId != R.id.nav_home + if (binding.bottomNavigation.selectedItemId != R.id.nav_home) { + suppressBottomNavigationMotion = true + binding.bottomNavigation.selectedItemId = R.id.nav_home + suppressBottomNavigationMotion = false + } + if (returningToHome) { + binding.bottomNavigation.post { + binding.bottomNavigation.findViewById(R.id.nav_home)?.playNavigationMotion() + } + } + hasResumedOnce = true refreshWeekPager() updateWeekDisplay() } @@ -351,6 +556,7 @@ class MainActivity : AppCompatActivity() { pageSettings = WeekPageSettings( showWeekend = prefs.showWeekend, showTimes = prefs.showTime, + showInactiveCourses = prefs.showInactiveCourses, sectionHeightDp = prefs.sectionHeightDp, sectionTimes = prefs.sectionTimes ) diff --git a/app/src/main/java/com/courseschedule/ui/ViewMotion.kt b/app/src/main/java/com/courseschedule/ui/ViewMotion.kt new file mode 100644 index 0000000..1515769 --- /dev/null +++ b/app/src/main/java/com/courseschedule/ui/ViewMotion.kt @@ -0,0 +1,136 @@ +package com.courseschedule.ui + +import android.view.MotionEvent +import android.view.View +import android.view.animation.OvershootInterpolator +import android.view.animation.PathInterpolator +import com.courseschedule.R +import com.google.android.material.R as MaterialR + +private val pressReleaseInterpolator = PathInterpolator(0.22f, 1f, 0.36f, 1f) + +fun View.installPressScale(pressedScale: Float = 0.98f) { + setOnTouchListener { target, event -> + val targetScale = when (event.actionMasked) { + MotionEvent.ACTION_DOWN -> pressedScale + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> 1f + else -> return@setOnTouchListener false + } + target.animate().cancel() + target.animate() + .scaleX(targetScale) + .scaleY(targetScale) + .setDuration(if (targetScale < 1f) 115L else 245L) + .setInterpolator(pressReleaseInterpolator) + .start() + false + } +} + +fun View?.playNavigationMotion() { + val item = this + if (item == null) return + val icon = item.findViewById(MaterialR.id.navigation_bar_item_icon_view) ?: item + val density = item.resources.displayMetrics.density + icon.animate().cancel() + icon.apply { + scaleX = 1f + scaleY = 1f + translationY = 0f + rotation = 0f + rotationY = 0f + } + + when (item.id) { + R.id.nav_home -> icon.apply { + cameraDistance = 8_000f * density + animate() + .rotationY(-38f) + .scaleX(0.91f) + .scaleY(0.91f) + .setDuration(280L) + .setInterpolator(pressReleaseInterpolator) + .withLayer() + .withEndAction { + animate() + .rotationY(0f) + .scaleX(1f) + .scaleY(1f) + .setDuration(520L) + .setInterpolator(OvershootInterpolator(0.7f)) + .withLayer() + .start() + } + .start() + } + + R.id.nav_import -> { + icon.animate() + .translationY(4f * density) + .scaleX(0.9f) + .scaleY(0.9f) + .setDuration(220L) + .setInterpolator(pressReleaseInterpolator) + .withLayer() + .withEndAction { + icon.animate() + .translationY(-8f * density) + .scaleX(1.07f) + .scaleY(1.07f) + .setDuration(300L) + .setInterpolator(pressReleaseInterpolator) + .withLayer() + .withEndAction { + icon.animate() + .translationY(0f) + .scaleX(1f) + .scaleY(1f) + .setDuration(430L) + .setInterpolator(OvershootInterpolator(0.68f)) + .withLayer() + .start() + } + .start() + } + .start() + } + + R.id.nav_settings -> { + icon.animate() + .rotation(88f) + .scaleX(0.93f) + .scaleY(0.93f) + .setDuration(400L) + .setInterpolator(pressReleaseInterpolator) + .withLayer() + .withEndAction { + icon.animate() + .rotation(0f) + .scaleX(1f) + .scaleY(1f) + .setDuration(520L) + .setInterpolator(OvershootInterpolator(0.6f)) + .withLayer() + .start() + } + .start() + } + + else -> { + icon.animate() + .scaleX(0.9f) + .scaleY(0.9f) + .setDuration(260L) + .setInterpolator(pressReleaseInterpolator) + .withEndAction { + icon.animate() + .scaleX(1f) + .scaleY(1f) + .setDuration(520L) + .setInterpolator(OvershootInterpolator(0.7f)) + .start() + } + .start() + } + } +} diff --git a/app/src/main/java/com/courseschedule/ui/WeekPagerAdapter.kt b/app/src/main/java/com/courseschedule/ui/WeekPagerAdapter.kt index c671bb4..f1c41b0 100644 --- a/app/src/main/java/com/courseschedule/ui/WeekPagerAdapter.kt +++ b/app/src/main/java/com/courseschedule/ui/WeekPagerAdapter.kt @@ -1,12 +1,15 @@ package com.courseschedule.ui import android.graphics.Typeface +import android.graphics.RectF import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.view.animation.PathInterpolator import android.widget.TextView import androidx.core.content.ContextCompat import androidx.recyclerview.widget.RecyclerView +import androidx.viewpager2.widget.ViewPager2 import com.courseschedule.R import com.courseschedule.data.entity.Course import com.courseschedule.data.entity.Semester @@ -22,12 +25,13 @@ import java.util.Locale data class WeekPageSettings( val showWeekend: Boolean = true, val showTimes: Boolean = true, + val showInactiveCourses: Boolean = true, val sectionHeightDp: Int = 64, val sectionTimes: List = SchedulePreferences.DEFAULT_SECTION_TIMES ) class WeekPagerAdapter( - private val onCourseClick: (Course) -> Unit, + private val onCourseClick: (Course, View, RectF) -> Unit, private val onAddCourse: () -> Unit ) : RecyclerView.Adapter() { @@ -36,6 +40,7 @@ class WeekPagerAdapter( private var status: SemesterWeekStatus? = null private var settings = WeekPageSettings() private val scrollPositions = mutableMapOf() + private val settleInterpolator = PathInterpolator(0.2f, 0.85f, 0.25f, 1f) init { setHasStableIds(true) @@ -90,9 +95,19 @@ class WeekPagerAdapter( holder.boundWeek?.let { week -> scrollPositions[week] = holder.binding.scheduleScroll.scrollY } + holder.resetSelectionMotion() + holder.binding.courseTableView.resetPagerMotion() super.onViewRecycled(holder) } + fun playSelectionMotion(pager: ViewPager2, position: Int, forward: Boolean): Boolean { + val recyclerView = pager.getChildAt(0) as? RecyclerView ?: return false + val holder = recyclerView.findViewHolderForAdapterPosition(position) as? WeekViewHolder + ?: return false + holder.playSelectionMotion(forward) + return true + } + inner class WeekViewHolder( val binding: ItemWeekScheduleBinding ) : RecyclerView.ViewHolder(binding.root) { @@ -107,26 +122,33 @@ class WeekPagerAdapter( settings: WeekPageSettings ) { val previousWeek = boundWeek + if (previousWeek != week) { + resetSelectionMotion() + binding.courseTableView.resetPagerMotion() + } boundWeek = week - val pageCourses = courses.filter { ScheduleRules.isCourseInWeek(it, week) } + val activeCourses = courses.filter { ScheduleRules.isCourseInWeek(it, week) } + val displayCourses = if (settings.showInactiveCourses) courses else activeCourses val visibleCourses = if (settings.showWeekend) { - pageCourses + displayCourses } else { - pageCourses.filter { it.dayOfWeek <= 5 } + displayCourses.filter { it.dayOfWeek <= 5 } } bindDayHeaders(week, semester, status, settings.showWeekend) binding.courseTableView.applyDisplaySettings( showWeekend = settings.showWeekend, showTimes = settings.showTimes, + showInactiveCourses = settings.showInactiveCourses, sectionHeightDp = settings.sectionHeightDp, sectionTimes = settings.sectionTimes ) binding.courseTableView.setCurrentWeek(week) - binding.courseTableView.setCourses(pageCourses) + binding.courseTableView.setCourses(displayCourses) binding.courseTableView.setOnCourseClickListener(onCourseClick) binding.emptyState.visibility = if (visibleCourses.isEmpty()) View.VISIBLE else View.GONE binding.btnEmptyAdd.setOnClickListener { onAddCourse() } + binding.btnEmptyAdd.installPressScale(0.97f) binding.root.contentDescription = binding.root.context.getString(R.string.week_format, week) if (previousWeek != week) { @@ -136,6 +158,95 @@ class WeekPagerAdapter( } } + fun playSelectionMotion(forward: Boolean) { + resetSelectionMotion() + val density = binding.root.resources.displayMetrics.density + + binding.weekDayHeader.apply { + alpha = 0.55f + translationX = (if (forward) 12f else -12f) * density + animate() + .alpha(1f) + .translationX(0f) + .setDuration(360L) + .setInterpolator(settleInterpolator) + .withLayer() + .start() + } + if (binding.emptyState.visibility != View.VISIBLE) return + + binding.emptyState.apply { + alpha = 0f + translationY = 10f * density + animate() + .alpha(1f) + .translationY(0f) + .setDuration(540L) + .setInterpolator(settleInterpolator) + .withLayer() + .start() + } + binding.emptyIconContainer.apply { + alpha = 0f + scaleX = 0.96f + scaleY = 0.96f + rotation = 0f + translationY = 4f * density + animate() + .alpha(1f) + .scaleX(1f) + .scaleY(1f) + .translationY(0f) + .setStartDelay(45L) + .setDuration(620L) + .setInterpolator(settleInterpolator) + .withLayer() + .start() + } + binding.tvEmptyTitle.apply { + alpha = 0f + translationY = 16f * density + animate() + .alpha(1f) + .translationY(0f) + .setStartDelay(145L) + .setDuration(430L) + .setInterpolator(settleInterpolator) + .start() + } + binding.btnEmptyAdd.apply { + alpha = 0f + translationY = 18f * density + animate() + .alpha(1f) + .translationY(0f) + .setStartDelay(230L) + .setDuration(460L) + .setInterpolator(settleInterpolator) + .withLayer() + .start() + } + } + + fun resetSelectionMotion() { + listOf( + binding.weekDayHeader, + binding.scheduleScroll, + binding.emptyState, + binding.emptyIconContainer, + binding.tvEmptyTitle, + binding.btnEmptyAdd + ).forEach { view -> + view.animate().cancel() + view.alpha = 1f + view.scaleX = 1f + view.scaleY = 1f + view.translationX = 0f + view.translationY = 0f + view.rotation = 0f + } + } + private fun bindDayHeaders( week: Int, semester: Semester, @@ -156,6 +267,7 @@ class WeekPagerAdapter( timeInMillis = semester.startDate add(Calendar.DAY_OF_MONTH, (week - 1) * 7) } + binding.tvMonthLabel.text = "${calendar.get(Calendar.MONTH) + 1}\n月" val today = Calendar.getInstance() val todayIndex = when (today.get(Calendar.DAY_OF_WEEK)) { Calendar.MONDAY -> 0 diff --git a/app/src/main/java/com/courseschedule/ui/importdata/ImportActivity.kt b/app/src/main/java/com/courseschedule/ui/importdata/ImportActivity.kt index a3bce4e..34f6704 100644 --- a/app/src/main/java/com/courseschedule/ui/importdata/ImportActivity.kt +++ b/app/src/main/java/com/courseschedule/ui/importdata/ImportActivity.kt @@ -1,15 +1,19 @@ package com.courseschedule.ui.importdata +import android.app.Activity import android.content.ClipboardManager import android.content.Context +import android.content.Intent import android.net.Uri import android.os.Bundle -import android.view.MenuItem import android.view.View +import android.view.animation.OvershootInterpolator +import android.view.animation.PathInterpolator import android.widget.TextView import android.widget.Toast import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AppCompatActivity +import androidx.core.view.doOnPreDraw import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.lifecycleScope import com.courseschedule.R @@ -17,6 +21,10 @@ import com.courseschedule.data.entity.Course import com.courseschedule.data.entity.Semester import com.courseschedule.databinding.ActivityImportBinding import com.courseschedule.domain.ScheduleRules +import com.courseschedule.ui.MainActivity +import com.courseschedule.ui.installPressScale +import com.courseschedule.ui.playNavigationMotion +import com.courseschedule.ui.settings.SettingsActivity import com.courseschedule.utils.ReminderManager import com.courseschedule.utils.SchedulePreferences import com.courseschedule.viewmodel.CourseViewModel @@ -34,23 +42,49 @@ class ImportActivity : AppCompatActivity() { private lateinit var binding: ActivityImportBinding private lateinit var courseViewModel: CourseViewModel private lateinit var semesterViewModel: SemesterViewModel + private val motionInterpolator = PathInterpolator(0.2f, 0.85f, 0.25f, 1f) private val filePicker = registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> uri?.let(::importFromFile) } + private val schoolImportLauncher = registerForActivityResult( + ActivityResultContracts.StartActivityForResult() + ) { result -> + if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult + val json = result.data?.getStringExtra(SwpuWebImportActivity.EXTRA_SCHEDULE_JSON) + ?.takeIf(String::isNotBlank) + ?: return@registerForActivityResult + importParsed { totalWeeks -> WiseduScheduleParser(totalWeeks).parse(json) } + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) binding = ActivityImportBinding.inflate(layoutInflater) setContentView(binding.root) setSupportActionBar(binding.toolbar) - supportActionBar?.setDisplayHomeAsUpEnabled(true) courseViewModel = ViewModelProvider(this)[CourseViewModel::class.java] semesterViewModel = ViewModelProvider(this)[SemesterViewModel::class.java] + binding.cardImportSchool.setOnClickListener { + val totalWeeks = courseViewModel.currentSemester.value?.totalWeeks + if (totalWeeks == null) { + Toast.makeText(this, R.string.semester_loading, Toast.LENGTH_SHORT).show() + return@setOnClickListener + } + schoolImportLauncher.launch( + Intent(this, SwpuWebImportActivity::class.java) + .putExtra(SwpuWebImportActivity.EXTRA_TOTAL_WEEKS, totalWeeks) + ) + } binding.cardImportJson.setOnClickListener { openFilePicker() } binding.cardImportText.setOnClickListener { showTextImportDialog() } + binding.cardImportSchool.installPressScale() + binding.cardImportJson.installPressScale() + binding.cardImportText.installPressScale() + initBottomNavigation() + animateImportEntrance() courseViewModel.currentSemester.observe(this) { semester -> binding.tvImportTarget.text = getString( R.string.import_target_format, @@ -59,6 +93,87 @@ class ImportActivity : AppCompatActivity() { } } + private fun initBottomNavigation() { + binding.bottomNavigation.selectedItemId = R.id.nav_import + binding.bottomNavigation.setOnItemSelectedListener { item -> + val itemView = binding.bottomNavigation.findViewById(item.itemId) + when (item.itemId) { + R.id.nav_home -> { + startActivity( + Intent(this, MainActivity::class.java).addFlags( + Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP + ) + ) + overridePendingTransition(0, 0) + finish() + overridePendingTransition(0, 0) + true + } + R.id.nav_import -> { + itemView.playNavigationMotion() + true + } + R.id.nav_settings -> { + startActivity(Intent(this, SettingsActivity::class.java)) + overridePendingTransition(0, 0) + finish() + overridePendingTransition(0, 0) + true + } + else -> false + } + } + binding.bottomNavigation.setOnItemReselectedListener { item -> + binding.bottomNavigation.findViewById(item.itemId)?.playNavigationMotion() + } + binding.bottomNavigation.post { + binding.bottomNavigation.findViewById(R.id.nav_import)?.playNavigationMotion() + } + } + + private fun animateImportEntrance() { + val container = binding.importContent + val children = List(container.childCount, container::getChildAt) + children.forEach { child -> + child.alpha = 0.16f + child.translationY = dp(30f) + if (child === binding.cardImportSchool) { + child.scaleX = 0.9f + child.scaleY = 0.9f + } + } + binding.schoolIconContainer.apply { + scaleX = 0.2f + scaleY = 0.2f + rotation = -24f + } + container.doOnPreDraw { + children.forEachIndexed { index, child -> + child.animate() + .alpha(1f) + .translationY(0f) + .scaleX(1f) + .scaleY(1f) + .setStartDelay(index * 50L) + .setDuration(540L) + .setInterpolator(motionInterpolator) + .withLayer() + .start() + } + binding.schoolIconContainer.animate() + .scaleX(1f) + .scaleY(1f) + .rotation(0f) + .setStartDelay(150L) + .setDuration(720L) + .setInterpolator(OvershootInterpolator(1.55f)) + .withLayer() + .start() + } + } + + private fun dp(value: Float): Float = value * resources.displayMetrics.density + private fun openFilePicker() { // Some Android file providers report CSV/HTML as application/octet-stream. // The parser validates the extension and content after selection. @@ -319,15 +434,9 @@ class ImportActivity : AppCompatActivity() { private fun setLoading(loading: Boolean) { binding.progressImport.visibility = if (loading) View.VISIBLE else View.GONE + binding.cardImportSchool.isEnabled = !loading binding.cardImportJson.isEnabled = !loading binding.cardImportText.isEnabled = !loading } - override fun onOptionsItemSelected(item: MenuItem): Boolean { - if (item.itemId == android.R.id.home) { - finish() - return true - } - return super.onOptionsItemSelected(item) - } } diff --git a/app/src/main/java/com/courseschedule/ui/importdata/SwpuWebImportActivity.kt b/app/src/main/java/com/courseschedule/ui/importdata/SwpuWebImportActivity.kt new file mode 100644 index 0000000..45f0d14 --- /dev/null +++ b/app/src/main/java/com/courseschedule/ui/importdata/SwpuWebImportActivity.kt @@ -0,0 +1,499 @@ +package com.courseschedule.ui.importdata + +import android.annotation.SuppressLint +import android.app.Activity +import android.content.Intent +import android.graphics.Bitmap +import android.net.Uri +import android.net.http.SslError +import android.os.Bundle +import android.os.SystemClock +import android.view.View +import android.view.animation.OvershootInterpolator +import android.view.animation.PathInterpolator +import android.webkit.JavascriptInterface +import android.webkit.SslErrorHandler +import android.webkit.WebChromeClient +import android.webkit.WebResourceRequest +import android.webkit.WebView +import android.webkit.WebViewClient +import android.widget.Toast +import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.lifecycleScope +import com.courseschedule.R +import com.courseschedule.databinding.ActivitySwpuWebImportBinding +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.json.JSONObject +import org.json.JSONTokener +import java.util.UUID + +class SwpuWebImportActivity : AppCompatActivity() { + + companion object { + const val EXTRA_SCHEDULE_JSON = "schedule_json" + const val EXTRA_TOTAL_WEEKS = "total_weeks" + private const val LOGIN_URL = + "https://deanservices.swpu.edu.cn/jwapp/sys/jwauthapp/login/index.html" + private const val BRIDGE_NAME = "CourseScheduleBridge" + private const val MAX_SCHEDULE_JSON_CHARS = 400_000 + private const val MIN_IDENTIFYING_DISPLAY_MILLIS = 320L + private const val MIN_PARSING_DISPLAY_MILLIS = 360L + private const val SUCCESS_DISPLAY_MILLIS = 1_100L + private val TRUSTED_HOSTS = setOf("swpu.edu.cn", "deanservices.swpu.edu.cn") + } + + private lateinit var binding: ActivitySwpuWebImportBinding + private var activeRequestToken: String? = null + private val statusInterpolator = PathInterpolator(0.22f, 1f, 0.36f, 1f) + private val totalWeeks by lazy { + intent.getIntExtra(EXTRA_TOTAL_WEEKS, 20).coerceIn(1, 52) + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = ActivitySwpuWebImportBinding.inflate(layoutInflater) + setContentView(binding.root) + + setSupportActionBar(binding.toolbar) + supportActionBar?.setDisplayHomeAsUpEnabled(true) + binding.toolbar.setNavigationOnClickListener { + if (binding.webView.canGoBack()) binding.webView.goBack() else finish() + } + + configureWebView() + binding.btnFetchSchedule.setOnClickListener { fetchCurrentSchedule() } + setImportStatus(false, getString(R.string.academic_waiting_login)) + binding.webView.loadUrl(LOGIN_URL) + } + + @SuppressLint("SetJavaScriptEnabled") + private fun configureWebView() { + binding.webView.settings.apply { + javaScriptEnabled = true + domStorageEnabled = true + allowFileAccess = false + allowContentAccess = false + mixedContentMode = android.webkit.WebSettings.MIXED_CONTENT_NEVER_ALLOW + } + binding.webView.webChromeClient = WebChromeClient() + binding.webView.addJavascriptInterface(ImportBridge(), BRIDGE_NAME) + binding.webView.webViewClient = object : WebViewClient() { + override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean { + val uri = request?.url ?: return true + if (!isTrustedSchoolUri(uri)) { + showBlockedHost() + return true + } + return false + } + + override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) { + super.onPageStarted(view, url, favicon) + val uri = url?.let(Uri::parse) ?: return + if (!isTrustedSchoolUri(uri)) { + view?.stopLoading() + showBlockedHost() + } else if (activeRequestToken != null) { + activeRequestToken = null + setImportStatus(false, getString(R.string.academic_waiting_login)) + } + } + + override fun onPageFinished(view: WebView?, url: String?) { + super.onPageFinished(view, url) + if ( + activeRequestToken == null && + url?.let(Uri::parse)?.let(::isTrustedSchoolUri) == true + ) { + setImportStatus(false, getString(R.string.academic_waiting_login)) + } + } + + override fun onReceivedSslError( + view: WebView?, + handler: SslErrorHandler?, + error: SslError? + ) { + handler?.cancel() + activeRequestToken = null + setImportStatus(false, getString(R.string.academic_ssl_error)) + } + } + } + + private fun fetchCurrentSchedule() { + val currentUri = binding.webView.url?.let(Uri::parse) + if (currentUri == null || !isTrustedSchoolUri(currentUri)) { + showBlockedHost() + return + } + val requestToken = UUID.randomUUID().toString() + activeRequestToken = requestToken + setImportStatus(true, getString(R.string.academic_identifying_term)) + binding.webView.evaluateJavascript(ScriptHolder.termProbeScript()) { encodedPageText -> + if (!isActiveTrustedRequest(requestToken)) return@evaluateJavascript + val pageText = runCatching { + JSONTokener(encodedPageText).nextValue() as? String + }.getOrNull().orEmpty() + val termHint = normalizeWiseduTerm(pageText).orEmpty() + lifecycleScope.launch { + delay(MIN_IDENTIFYING_DISPLAY_MILLIS) + if (!isActiveTrustedRequest(requestToken)) return@launch + binding.webView.evaluateJavascript( + ScriptHolder.fetchScript(requestToken, termHint), + null + ) + } + } + } + + private fun isTrustedSchoolUri(uri: Uri): Boolean { + if (uri.scheme != "https") return false + val host = uri.host?.lowercase().orEmpty() + return host in TRUSTED_HOSTS + } + + private fun isActiveTrustedRequest(token: String?): Boolean { + val uri = binding.webView.url?.let(Uri::parse) + return token != null && token == activeRequestToken && uri != null && isTrustedSchoolUri(uri) + } + + private fun showBlockedHost() { + activeRequestToken = null + setImportStatus(false, getString(R.string.academic_host_blocked)) + Toast.makeText(this, R.string.academic_host_blocked, Toast.LENGTH_SHORT).show() + } + + private fun setImportStatus(loading: Boolean, status: String, success: Boolean = false) { + val statusChanged = binding.tvStatus.text?.toString() != status + binding.tvStatus.animate().cancel() + binding.tvStatus.text = status + if (statusChanged && binding.tvStatus.isLaidOut) { + binding.tvStatus.alpha = 0.3f + binding.tvStatus.translationY = 7f * resources.displayMetrics.density + binding.tvStatus.animate() + .alpha(1f) + .translationY(0f) + .setDuration(320L) + .setInterpolator(statusInterpolator) + .start() + } else { + binding.tvStatus.alpha = 1f + binding.tvStatus.translationY = 0f + } + binding.statusProgress.visibility = if (loading) View.VISIBLE else View.GONE + binding.statusIcon.visibility = if (loading) View.GONE else View.VISIBLE + binding.statusIcon.setImageResource(if (success) R.drawable.ic_check else R.drawable.ic_school) + binding.statusIcon.animate().cancel() + if (success) { + binding.statusIcon.apply { + alpha = 0.35f + scaleX = 0.68f + scaleY = 0.68f + rotation = -9f + animate() + .alpha(1f) + .scaleX(1f) + .scaleY(1f) + .rotation(0f) + .setDuration(480L) + .setInterpolator(OvershootInterpolator(1.45f)) + .start() + } + } else { + binding.statusIcon.alpha = 1f + binding.statusIcon.scaleX = 1f + binding.statusIcon.scaleY = 1f + binding.statusIcon.rotation = 0f + } + binding.btnFetchSchedule.isEnabled = !loading && !success + binding.btnFetchSchedule.alpha = if (binding.btnFetchSchedule.isEnabled) 1f else 0.72f + } + + private fun showFetchError(message: String) { + activeRequestToken = null + setImportStatus(false, getString(R.string.academic_waiting_login)) + MaterialAlertDialogBuilder(this) + .setTitle(R.string.academic_fetch_failed) + .setMessage(message.take(240).ifBlank { getString(R.string.academic_fetch_failed_detail) }) + .setPositiveButton(R.string.ok, null) + .show() + } + + private inner class ImportBridge { + @JavascriptInterface + fun onStage(token: String?, stage: String?) { + binding.webView.post { + if (!isActiveTrustedRequest(token)) return@post + if (stage == "schedule") { + setImportStatus(true, getString(R.string.academic_fetching_schedule)) + } + } + } + + @JavascriptInterface + fun onScheduleJson(token: String?, json: String?) { + binding.webView.post { + if (!isActiveTrustedRequest(token)) return@post + val scheduleJson = json ?: return@post + if (scheduleJson.length > MAX_SCHEDULE_JSON_CHARS) { + showFetchError(getString(R.string.academic_response_too_large)) + return@post + } + setImportStatus(true, getString(R.string.academic_parsing_courses)) + lifecycleScope.launch { + val parsingStartedAt = SystemClock.elapsedRealtime() + val result = runCatching { + withContext(Dispatchers.Default) { + WiseduScheduleParser(totalWeeks).parse(scheduleJson).courses + .map { it.courseName.trim() } + .distinct() + .size + } + } + val remainingDisplayTime = MIN_PARSING_DISPLAY_MILLIS - + (SystemClock.elapsedRealtime() - parsingStartedAt) + if (remainingDisplayTime > 0L) delay(remainingDisplayTime) + if (!isActiveTrustedRequest(token)) return@launch + result.onSuccess { courseCount -> + setImportStatus( + loading = false, + status = getString(R.string.academic_courses_ready, courseCount), + success = true + ) + delay(SUCCESS_DISPLAY_MILLIS) + if (!isActiveTrustedRequest(token)) return@onSuccess + activeRequestToken = null + setResult( + Activity.RESULT_OK, + Intent().putExtra(EXTRA_SCHEDULE_JSON, scheduleJson) + ) + finish() + }.onFailure { error -> + showFetchError( + error.message ?: getString(R.string.academic_fetch_failed_detail) + ) + } + } + } + } + + @JavascriptInterface + fun onImportError(token: String?, message: String?) { + binding.webView.post { + if (!isActiveTrustedRequest(token)) return@post + showFetchError(message.orEmpty()) + } + } + } + + override fun onDestroy() { + activeRequestToken = null + binding.webView.removeJavascriptInterface(BRIDGE_NAME) + binding.webView.stopLoading() + binding.webView.destroy() + super.onDestroy() + } + + private object ScriptHolder { + fun termProbeScript(): String = TERM_PROBE_SCRIPT + + fun fetchScript(requestToken: String, termHint: String): String = FETCH_SCRIPT + .replace("__REQUEST_TOKEN__", JSONObject.quote(requestToken)) + .replace("__TERM_HINT__", JSONObject.quote(termHint)) + + private val TERM_PROBE_SCRIPT = """ + (function () { + const values = []; + function collect(doc, depth) { + if (!doc || depth > 3) return; + doc.querySelectorAll( + 'select[name*="XNXQ"], select[id*="XNXQ"], input[name*="XNXQ"], input[id*="XNXQ"]' + ).forEach(function (element) { + if (element.value) values.push(String(element.value)); + if (element.options && element.selectedIndex >= 0) { + values.push(String(element.options[element.selectedIndex].text || '')); + } + }); + values.push(doc.body ? doc.body.innerText : ''); + doc.querySelectorAll('iframe').forEach(function (frame) { + try { collect(frame.contentDocument, depth + 1); } catch (_) {} + }); + } + collect(document, 0); + return values.join('\n').slice(0, 20000); + })(); + """.trimIndent() + + private val FETCH_SCRIPT = """ + (function () { + const bridge = window.CourseScheduleBridge; + const requestToken = __REQUEST_TOKEN__; + const termHint = __TERM_HINT__; + const formHeaders = { + 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8', + 'X-Requested-With': 'XMLHttpRequest' + }; + + async function readJson(url, options) { + const response = await fetch(url, Object.assign({ credentials: 'include' }, options || {})); + const text = await response.text(); + if (!response.ok) { + if (response.status === 401 || response.status === 403) { + throw new Error('登录状态无效,请先完成学校网页登录后再读取课表'); + } + throw new Error('教务系统请求失败(' + response.status + ')'); + } + try { + return JSON.parse(text); + } catch (_) { + throw new Error('教务系统返回了登录页面,请先完成登录后再读取课表'); + } + } + + function normalizeTerm(value) { + const text = String(value || '').trim(); + let match = text.match(/(20\d{2})\s*[-_/]\s*(20\d{2})\s*[-_/]\s*([1-3])/); + if (match) return match[1] + '-' + match[2] + '-' + match[3]; + match = text.match( + /(20\d{2})\s*[-~~—–-至/]\s*(20\d{2})\s*学年[\s\S]{0,16}?(秋季|春季|夏季|第一|第二|第三|1|2|3)\s*学期/ + ); + if (match) { + const semester = /^(秋季|第一|1)$/.test(match[3]) ? '1' : + (/^(春季|第二|2)$/.test(match[3]) ? '2' : '3'); + return match[1] + '-' + match[2] + '-' + semester; + } + match = text.match(/(?:^|\D)(20\d{2})(20\d{2})([1-3])(?:\D|$)/); + return match ? match[1] + '-' + match[2] + '-' + match[3] : ''; + } + + function selectedTermFromPage() { + const selectors = [ + 'select[name="XNXQDM"]', + '#XNXQDM', + '[data-name="XNXQDM"] select' + ]; + for (const selector of selectors) { + const element = document.querySelector(selector); + if (!element) continue; + const value = element.value || (element.options && element.options[element.selectedIndex] + ? element.options[element.selectedIndex].value + : ''); + const normalized = normalizeTerm(value); + if (normalized) return normalized; + } + return normalizeTerm(document.body ? document.body.innerText : ''); + } + + function termFromResponse(data) { + const candidates = []; + function walk(value, depth) { + if (depth > 6 || value == null) return; + if (Array.isArray(value)) { + value.forEach(function (item) { walk(item, depth + 1); }); + return; + } + if (typeof value !== 'object') { + const normalized = normalizeTerm(value); + if (normalized) candidates.push(normalized); + return; + } + Object.keys(value).forEach(function (key) { + const child = value[key]; + walk(child, depth + 1); + }); + } + walk(data, 0); + return candidates[0] || ''; + } + + function containsCourseRows(value, depth) { + if (depth > 8 || value == null) return false; + if (Array.isArray(value)) { + return value.some(function (item) { + return item && typeof item === 'object' && item.KCM; + }) || value.some(function (item) { return containsCourseRows(item, depth + 1); }); + } + if (typeof value !== 'object') return false; + return Object.keys(value).some(function (key) { + return containsCourseRows(value[key], depth + 1); + }); + } + + async function readSchedule(term) { + const bodies = []; + if (term) { + bodies.push( + 'XNXQDM=' + encodeURIComponent(term) + '&pageSize=200&pageNumber=1' + ); + } + bodies.push('pageSize=200&pageNumber=1'); + let lastResponse = null; + let lastError = null; + for (const body of bodies) { + try { + lastResponse = await readJson( + '/jwapp/sys/wdkb/modules/xskcb/xskcb.do', + { method: 'POST', headers: formHeaders, body: body } + ); + if (containsCourseRows(lastResponse, 0)) return lastResponse; + } catch (error) { + lastError = error; + } + } + if (lastResponse == null && lastError) throw lastError; + return lastResponse; + } + + async function run() { + try { + try { + await fetch('/jwapp/sys/wdkb/*default/index.do', { credentials: 'include' }); + } catch (_) {} + + let term = normalizeTerm(termHint) || selectedTermFromPage(); + if (!term) { + try { + const termData = await readJson( + '/jwapp/sys/wdkb/modules/jshkcb/dqxnxq.do', + { method: 'POST', headers: formHeaders, body: '' } + ); + term = termFromResponse(termData); + } catch (_) {} + } + + bridge.onStage(requestToken, 'schedule'); + await new Promise(function (resolve) { setTimeout(resolve, 360); }); + const schedule = await readSchedule(term); + if (!containsCourseRows(schedule, 0)) { + throw new Error( + term + ? '已识别学期 ' + term + ',但教务接口没有返回课程,请确认学生课表已有数据' + : '无法识别当前学期,且教务接口没有返回默认学期课表' + ); + } + bridge.onScheduleJson( + requestToken, + JSON.stringify({ term: term, payload: schedule }) + ); + } catch (error) { + let message = error && error.message ? error.message : String(error); + if (/Failed to fetch|Load failed|NetworkError/i.test(message)) { + message = '教务请求未完成,请确认已登录并进入课表页面后重试'; + } + bridge.onImportError( + requestToken, + message + ); + } + } + + run(); + })(); + """.trimIndent() + } +} diff --git a/app/src/main/java/com/courseschedule/ui/importdata/WiseduScheduleParser.kt b/app/src/main/java/com/courseschedule/ui/importdata/WiseduScheduleParser.kt new file mode 100644 index 0000000..6c2fe4d --- /dev/null +++ b/app/src/main/java/com/courseschedule/ui/importdata/WiseduScheduleParser.kt @@ -0,0 +1,215 @@ +package com.courseschedule.ui.importdata + +import com.courseschedule.data.entity.Course +import com.google.gson.JsonElement +import com.google.gson.JsonObject +import com.google.gson.JsonParser + +internal fun normalizeWiseduTerm(raw: String): String? { + val direct = Regex("(20\\d{2})\\s*[-_/]\\s*(20\\d{2})\\s*[-_/]\\s*([1-3])") + .find(raw) + if (direct != null) { + return "${direct.groupValues[1]}-${direct.groupValues[2]}-${direct.groupValues[3]}" + } + + val display = Regex( + "(20\\d{2})\\s*[-~~—–-至/]\\s*(20\\d{2})\\s*学年[\\s\\S]{0,16}?" + + "(秋季|春季|夏季|第一|第二|第三|1|2|3)\\s*学期" + ).find(raw) + if (display != null) { + val semester = when (display.groupValues[3]) { + "秋季", "第一", "1" -> 1 + "春季", "第二", "2" -> 2 + "夏季", "第三", "3" -> 3 + else -> return null + } + return "${display.groupValues[1]}-${display.groupValues[2]}-$semester" + } + + val compact = Regex("(? { + val direct = payload.objectOrNull("datas") + ?.objectOrNull("xskcb") + ?.arrayOrNull("rows") + ?.mapNotNull { it.takeIf(JsonElement::isJsonObject)?.asJsonObject } + .orEmpty() + if (direct.isNotEmpty()) return direct + + val fallback = payload.objectOrNull("xskcb") + ?.arrayOrNull("rows") + ?.mapNotNull { it.takeIf(JsonElement::isJsonObject)?.asJsonObject } + .orEmpty() + if (fallback.isNotEmpty()) return fallback + + val rootRows = payload.arrayOrNull("rows") + ?.mapNotNull { it.takeIf(JsonElement::isJsonObject)?.asJsonObject } + .orEmpty() + if (rootRows.isNotEmpty()) return rootRows + + return findCourseArray(payload, depth = 0).orEmpty() + } + + private fun findCourseArray(element: JsonElement, depth: Int): List? { + if (depth > 10 || element.isJsonNull || element.isJsonPrimitive) return null + if (element.isJsonArray) { + val rows = element.asJsonArray + .mapNotNull { it.takeIf(JsonElement::isJsonObject)?.asJsonObject } + .filter { row -> row.has("KCM") && row.has("SKXQ") && row.has("KSJC") } + if (rows.isNotEmpty()) return rows + return element.asJsonArray.firstNotNullOfOrNull { child -> + findCourseArray(child, depth + 1) + } + } + return element.asJsonObject.entrySet().firstNotNullOfOrNull { (_, child) -> + findCourseArray(child, depth + 1) + } + } + + private fun parseRow(row: JsonObject): List { + val name = row.string("KCM").trim() + val day = row.string("SKXQ").firstInteger() + val startSection = row.string("KSJC").firstInteger() + val endSection = row.string("JSJC").firstInteger() ?: startSection + if (name.isBlank() || day == null || day !in 1..7 || startSection == null || endSection == null) { + return emptyList() + } + + val teacher = row.string("SKJS").trim() + val classroom = row.string("JASMC").trim() + val weekPattern = row.string("SKZC").trim() + val weekGroups = compressWeeks(parseWeeks(weekPattern)) + val colorIndex = Math.floorMod(name.hashCode(), 16) + + return weekGroups.map { group -> + Course( + courseName = name, + teacher = teacher, + classroom = classroom, + dayOfWeek = day, + startSection = startSection, + endSection = endSection, + startWeek = group.start, + endWeek = group.end, + weekType = group.weekType, + colorIndex = colorIndex + ) + } + } + + private fun parseWeeks(pattern: String): List { + if (pattern.isBlank()) return (1..defaultTotalWeeks.coerceAtLeast(1)).toList() + + val normalized = pattern.trim() + if (normalized.matches(Regex("[01]+"))) { + return normalized.mapIndexedNotNull { index, value -> + if (value == '1') index + 1 else null + } + } + + val explicit = mutableSetOf() + Regex("(\\d+)\\s*(?:[-~~—–-]|至)\\s*(\\d+)").findAll(normalized).forEach { match -> + val start = match.groupValues[1].toIntOrNull() ?: return@forEach + val end = match.groupValues[2].toIntOrNull() ?: return@forEach + if (start > 0 && end >= start) explicit += start..end + } + Regex("\\d+").findAll(normalized) + .mapNotNull { it.value.toIntOrNull() } + .filter { it > 0 } + .forEach(explicit::add) + + val hasOddMarker = normalized.contains('单') + val hasEvenMarker = normalized.contains('双') + val candidates = explicit.ifEmpty { + (1..defaultTotalWeeks.coerceAtLeast(1)).toMutableSet() + } + return candidates.filter { week -> + when { + hasOddMarker && !hasEvenMarker -> week % 2 == 1 + hasEvenMarker && !hasOddMarker -> week % 2 == 0 + else -> true + } + }.sorted() + } + + private fun compressWeeks(weeks: List): List { + val sorted = weeks.distinct().sorted() + if (sorted.isEmpty()) return emptyList() + if (sorted.size == 1) return listOf(WeekGroup(sorted.first(), sorted.first(), 0)) + + val groups = mutableListOf() + var index = 0 + while (index < sorted.size) { + val start = sorted[index] + val canUseParityRun = index + 1 < sorted.size && sorted[index + 1] - start == 2 + if (canUseParityRun) { + var endIndex = index + 1 + while (endIndex + 1 < sorted.size && sorted[endIndex + 1] - sorted[endIndex] == 2) { + endIndex++ + } + groups += WeekGroup( + start = start, + end = sorted[endIndex], + weekType = if (start % 2 == 0) 2 else 1 + ) + index = endIndex + 1 + continue + } + + var endIndex = index + while (endIndex + 1 < sorted.size && sorted[endIndex + 1] - sorted[endIndex] == 1) { + endIndex++ + } + groups += WeekGroup(start, sorted[endIndex], 0) + index = endIndex + 1 + } + return groups + } + + private data class WeekGroup(val start: Int, val end: Int, val weekType: Int) + + private fun JsonObject.string(key: String): String = get(key) + ?.takeUnless { it.isJsonNull } + ?.let { element -> runCatching { element.asString }.getOrNull() } + .orEmpty() + + private fun JsonObject.objectOrNull(key: String): JsonObject? = get(key) + ?.takeIf(JsonElement::isJsonObject) + ?.asJsonObject + + private fun JsonObject.arrayOrNull(key: String) = get(key) + ?.takeIf(JsonElement::isJsonArray) + ?.asJsonArray + + private fun String.firstInteger(): Int? = Regex("\\d+").find(this)?.value?.toIntOrNull() +} diff --git a/app/src/main/java/com/courseschedule/ui/settings/SettingsActivity.kt b/app/src/main/java/com/courseschedule/ui/settings/SettingsActivity.kt index 21e6906..16d6e3e 100644 --- a/app/src/main/java/com/courseschedule/ui/settings/SettingsActivity.kt +++ b/app/src/main/java/com/courseschedule/ui/settings/SettingsActivity.kt @@ -4,9 +4,9 @@ import android.app.DatePickerDialog import android.content.Intent import android.os.Bundle import android.text.InputType -import android.view.MenuItem import android.view.View import android.view.ViewGroup +import android.view.animation.PathInterpolator import android.widget.AdapterView import android.widget.ArrayAdapter import android.widget.AutoCompleteTextView @@ -14,6 +14,7 @@ import android.widget.LinearLayout import android.widget.Toast import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AppCompatActivity +import androidx.core.view.doOnPreDraw import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.lifecycleScope import com.courseschedule.R @@ -23,6 +24,8 @@ import com.courseschedule.data.entity.Semester import com.courseschedule.databinding.ActivitySettingsBinding import com.courseschedule.domain.ScheduleRules import com.courseschedule.domain.SemesterPhase +import com.courseschedule.ui.installPressScale +import com.courseschedule.ui.playNavigationMotion import com.courseschedule.ui.importdata.ImportActivity import com.courseschedule.utils.ReminderManager import com.courseschedule.utils.SchedulePreferences @@ -47,6 +50,8 @@ class SettingsActivity : AppCompatActivity() { private lateinit var courseViewModel: CourseViewModel private lateinit var preferences: SchedulePreferences private var semesters: List = emptyList() + private var suppressBottomNavigationMotion = false + private val motionInterpolator = PathInterpolator(0.22f, 1f, 0.36f, 1f) private val sectionHeightValues = intArrayOf(56, 64, 72, 84) private val reminderValues = intArrayOf(-1, 5, 10, 15, 30, 60) @@ -61,18 +66,20 @@ class SettingsActivity : AppCompatActivity() { setContentView(binding.root) setSupportActionBar(binding.toolbar) - supportActionBar?.setDisplayHomeAsUpEnabled(true) semesterViewModel = ViewModelProvider(this)[SemesterViewModel::class.java] courseViewModel = ViewModelProvider(this)[CourseViewModel::class.java] preferences = SchedulePreferences(this) initSettingsControls() initActions() + initBottomNavigation() observeData() + animateSettingsEntrance() } private fun initSettingsControls() { binding.switchShowWeekend.isChecked = preferences.showWeekend + binding.switchShowInactiveCourses.isChecked = preferences.showInactiveCourses binding.switchShowTime.isChecked = preferences.showTime binding.switchReminder.isChecked = preferences.reminderEnabled @@ -87,18 +94,34 @@ class SettingsActivity : AppCompatActivity() { binding.spinnerDefaultReminder.setSelection( reminderValues.indexOf(preferences.defaultReminderMinutes).takeIf { it >= 0 } ?: 3 ) - updateReminderControlState(preferences.reminderEnabled) + updateReminderControlState(preferences.reminderEnabled, animate = false) updateSectionTimesSummary() + binding.rowShowWeekend.setOnClickListener { + binding.switchShowWeekend.toggle() + } + binding.rowShowInactiveCourses.setOnClickListener { + binding.switchShowInactiveCourses.toggle() + } + binding.rowShowTime.setOnClickListener { + binding.switchShowTime.toggle() + } + binding.rowReminder.setOnClickListener { + binding.switchReminder.toggle() + } + binding.switchShowWeekend.setOnCheckedChangeListener { _, checked -> preferences.showWeekend = checked } + binding.switchShowInactiveCourses.setOnCheckedChangeListener { _, checked -> + preferences.showInactiveCourses = checked + } binding.switchShowTime.setOnCheckedChangeListener { _, checked -> preferences.showTime = checked } binding.switchReminder.setOnCheckedChangeListener { _, checked -> preferences.reminderEnabled = checked - updateReminderControlState(checked) + updateReminderControlState(checked, animate = true) updateReminderScheduling(checked) } binding.spinnerSectionHeight.onItemSelectedListener = onItemSelected { position -> @@ -124,11 +147,26 @@ class SettingsActivity : AppCompatActivity() { } } - private fun updateReminderControlState(enabled: Boolean) { + private fun updateReminderControlState(enabled: Boolean, animate: Boolean) { binding.spinnerDefaultReminder.isEnabled = enabled - binding.spinnerDefaultReminder.alpha = if (enabled) 1f else 0.45f binding.cardSectionTimes.isEnabled = enabled - binding.cardSectionTimes.alpha = if (enabled) 1f else 0.55f + val spinnerAlpha = if (enabled) 1f else 0.45f + val sectionTimesAlpha = if (enabled) 1f else 0.55f + if (animate) { + binding.spinnerDefaultReminder.animate() + .alpha(spinnerAlpha) + .setDuration(220L) + .setInterpolator(motionInterpolator) + .start() + binding.cardSectionTimes.animate() + .alpha(sectionTimesAlpha) + .setDuration(220L) + .setInterpolator(motionInterpolator) + .start() + } else { + binding.spinnerDefaultReminder.alpha = spinnerAlpha + binding.cardSectionTimes.alpha = sectionTimesAlpha + } } private fun updateReminderScheduling(enabled: Boolean) { @@ -147,8 +185,75 @@ class SettingsActivity : AppCompatActivity() { binding.cardExport.setOnClickListener { exportData() } binding.cardBackup.setOnClickListener { startActivity(Intent(this, ImportActivity::class.java)) + overridePendingTransition(0, 0) } binding.cardAbout.setOnClickListener { showAboutDialog() } + + listOf( + binding.cardSemester, + binding.rowShowWeekend, + binding.rowShowInactiveCourses, + binding.rowShowTime, + binding.rowReminder, + binding.cardSectionTimes, + binding.cardExport, + binding.cardBackup, + binding.cardAbout + ).forEach { it.installPressScale() } + } + + private fun initBottomNavigation() { + binding.bottomNavigation.selectedItemId = R.id.nav_settings + binding.bottomNavigation.setOnItemSelectedListener { item -> + if (suppressBottomNavigationMotion) return@setOnItemSelectedListener true + val itemView = binding.bottomNavigation.findViewById(item.itemId) + when (item.itemId) { + R.id.nav_home -> { + finish() + overridePendingTransition(0, 0) + true + } + R.id.nav_import -> { + startActivity(Intent(this, ImportActivity::class.java)) + overridePendingTransition(0, 0) + finish() + overridePendingTransition(0, 0) + true + } + R.id.nav_settings -> { + itemView.playNavigationMotion() + true + } + else -> false + } + } + binding.bottomNavigation.setOnItemReselectedListener { item -> + binding.bottomNavigation.findViewById(item.itemId)?.playNavigationMotion() + } + binding.bottomNavigation.post { + binding.bottomNavigation.findViewById(R.id.nav_settings)?.playNavigationMotion() + } + } + + private fun animateSettingsEntrance() { + val container = binding.settingsContent + val children = List(container.childCount, container::getChildAt) + children.forEach { child -> + child.alpha = 0.18f + child.translationX = dp(32).toFloat() + } + container.doOnPreDraw { + children.forEachIndexed { index, child -> + child.animate() + .alpha(1f) + .translationX(0f) + .setStartDelay(index * 56L) + .setDuration(620L) + .setInterpolator(motionInterpolator) + .withLayer() + .start() + } + } } private fun observeData() { @@ -437,11 +542,12 @@ class SettingsActivity : AppCompatActivity() { private fun dp(value: Int): Int = (value * resources.displayMetrics.density).toInt() - override fun onOptionsItemSelected(item: MenuItem): Boolean { - if (item.itemId == android.R.id.home) { - finish() - return true + override fun onResume() { + super.onResume() + if (binding.bottomNavigation.selectedItemId != R.id.nav_settings) { + suppressBottomNavigationMotion = true + binding.bottomNavigation.selectedItemId = R.id.nav_settings + suppressBottomNavigationMotion = false } - return super.onOptionsItemSelected(item) } } diff --git a/app/src/main/java/com/courseschedule/utils/SchedulePreferences.kt b/app/src/main/java/com/courseschedule/utils/SchedulePreferences.kt index a2469d2..fb19e7d 100644 --- a/app/src/main/java/com/courseschedule/utils/SchedulePreferences.kt +++ b/app/src/main/java/com/courseschedule/utils/SchedulePreferences.kt @@ -25,6 +25,10 @@ class SchedulePreferences(context: Context) { get() = prefs.getBoolean(KEY_SHOW_TIME, true) set(value) = prefs.edit().putBoolean(KEY_SHOW_TIME, value).apply() + var showInactiveCourses: Boolean + get() = prefs.getBoolean(KEY_SHOW_INACTIVE_COURSES, true) + set(value) = prefs.edit().putBoolean(KEY_SHOW_INACTIVE_COURSES, value).apply() + var sectionHeightDp: Int get() = prefs.getInt(KEY_SECTION_HEIGHT, 64) set(value) = prefs.edit().putInt(KEY_SECTION_HEIGHT, value).apply() @@ -53,6 +57,7 @@ class SchedulePreferences(context: Context) { fun snapshot() = SettingsSnapshot( showWeekend = showWeekend, showTime = showTime, + showInactiveCourses = showInactiveCourses, sectionHeightDp = sectionHeightDp, reminderEnabled = reminderEnabled, defaultReminderMinutes = defaultReminderMinutes, @@ -66,6 +71,7 @@ class SchedulePreferences(context: Context) { prefs.edit() .putBoolean(KEY_SHOW_WEEKEND, snapshot.showWeekend) .putBoolean(KEY_SHOW_TIME, snapshot.showTime) + .putBoolean(KEY_SHOW_INACTIVE_COURSES, snapshot.showInactiveCourses) .putInt(KEY_SECTION_HEIGHT, snapshot.sectionHeightDp.coerceIn(56, 104)) .putBoolean(KEY_REMINDER_ENABLED, snapshot.reminderEnabled) .putInt(KEY_DEFAULT_REMINDER, snapshot.defaultReminderMinutes) @@ -78,6 +84,7 @@ class SchedulePreferences(context: Context) { const val PREFS_NAME = "settings" private const val KEY_SHOW_WEEKEND = "show_weekend" private const val KEY_SHOW_TIME = "show_time" + private const val KEY_SHOW_INACTIVE_COURSES = "show_inactive_courses" private const val KEY_SECTION_HEIGHT = "section_height_dp" private const val KEY_COMPACT_DENSITY_MIGRATED = "compact_density_migrated_v2" private const val KEY_REMINDER_ENABLED = "reminder_enabled" diff --git a/app/src/main/java/com/courseschedule/view/CourseTableView.kt b/app/src/main/java/com/courseschedule/view/CourseTableView.kt index 16c3e4e..dcf8493 100644 --- a/app/src/main/java/com/courseschedule/view/CourseTableView.kt +++ b/app/src/main/java/com/courseschedule/view/CourseTableView.kt @@ -1,7 +1,11 @@ package com.courseschedule.view +import android.animation.Animator +import android.animation.AnimatorListenerAdapter +import android.animation.ValueAnimator import android.content.Context import android.graphics.Canvas +import android.graphics.Color import android.graphics.Paint import android.graphics.Rect import android.graphics.RectF @@ -13,15 +17,23 @@ import android.util.AttributeSet import android.util.TypedValue import android.view.MotionEvent import android.view.View +import android.view.ViewConfiguration +import android.view.animation.DecelerateInterpolator +import android.view.animation.OvershootInterpolator import androidx.core.content.ContextCompat import androidx.core.view.ViewCompat import androidx.core.view.accessibility.AccessibilityNodeInfoCompat import androidx.customview.widget.ExploreByTouchHelper +import androidx.dynamicanimation.animation.FloatValueHolder +import androidx.dynamicanimation.animation.SpringAnimation +import androidx.dynamicanimation.animation.SpringForce import com.courseschedule.R import com.courseschedule.data.entity.Course import com.courseschedule.domain.ScheduleRules import com.courseschedule.utils.SchedulePreferences +import kotlin.math.abs import kotlin.math.roundToInt +import kotlin.math.sin /** * Weekly timetable canvas. Dimensions are density-aware so the grid stays @@ -40,8 +52,8 @@ class CourseTableView @JvmOverloads constructor( private val density = resources.displayMetrics.density private var sectionHeight = dp(64f) private val timeColumnWidth = dp(48f) - private val courseInset = dp(2.5f) - private val courseCornerRadius = dp(6f) + private val courseInset = dp(3f) + private val courseCornerRadius = dp(10f) private val courseColors = intArrayOf( R.color.course_red, @@ -64,6 +76,7 @@ class CourseTableView @JvmOverloads constructor( private val backgroundPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = ContextCompat.getColor(context, R.color.surface_variant) + alpha = 105 style = Paint.Style.FILL } @@ -98,6 +111,13 @@ class CourseTableView @JvmOverloads constructor( style = Paint.Style.FILL } + private val courseStrokePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.WHITE + alpha = 190 + strokeWidth = dp(1.25f) + style = Paint.Style.STROKE + } + private val courseNamePaint = TextPaint(Paint.ANTI_ALIAS_FLAG).apply { color = ContextCompat.getColor(context, R.color.on_primary) textSize = sp(12f) @@ -139,8 +159,19 @@ class CourseTableView @JvmOverloads constructor( private var currentWeek = 1 private var visibleDaysCount = 7 private var showTimes = true + private var showInactiveCourses = true private var highlightedDay: Int? = null - private var onCourseClickListener: ((Course) -> Unit)? = null + private var onCourseClickListener: ((Course, View, RectF) -> Unit)? = null + private val touchSlop = ViewConfiguration.get(context).scaledTouchSlop + private var pressedCourse: Course? = null + private var pressedCourseScale = 1f + private var pressAnimator: ValueAnimator? = null + private var pressAnimationGeneration = 0 + private var pagerOffset = 0f + private val cardMotionStates = mutableMapOf() + private var touchDownX = 0f + private var touchDownY = 0f + private var touchMoved = false private var dayWidth = 0f private var totalWidth = 0f @@ -148,6 +179,49 @@ class CourseTableView @JvmOverloads constructor( private var sectionTimes = SchedulePreferences.DEFAULT_SECTION_TIMES + private inner class CardMotionState(course: Course) { + private val valueHolder = FloatValueHolder(0f) + private val variant = Math.floorMod( + course.dayOfWeek * 7 + course.startSection * 11 + course.endSection * 3, + 5 + ) + val travelDistance = dp( + 32f + course.dayOfWeek * 2.8f + variant * 3.5f + ) + var translationX = 0f + private set + private var targetX = 0f + private val springAnimation = SpringAnimation(valueHolder).apply { + spring = SpringForce(0f).apply { + stiffness = 430f + variant * 95f + dampingRatio = 0.7f + variant * 0.035f + } + setMinimumVisibleChange(0.3f) + addUpdateListener { _, value, _ -> + translationX = value + invalidate() + } + } + + fun moveTo(offset: Float) { + val nextTarget = -offset * travelDistance + if (abs(nextTarget - targetX) < dp(0.15f)) return + targetX = nextTarget + springAnimation.animateToFinalPosition(nextTarget) + } + + fun reset() { + springAnimation.cancel() + valueHolder.value = 0f + translationX = 0f + targetX = 0f + } + + fun cancel() { + springAnimation.cancel() + } + } + private val accessibilityHelper = object : ExploreByTouchHelper(this) { override fun getVirtualViewAt(x: Float, y: Float): Int { return visibleCourses().indexOfFirst { course -> courseBounds(course).contains(x, y) } @@ -181,14 +255,14 @@ class CourseTableView @JvmOverloads constructor( ): Boolean { if (action != AccessibilityNodeInfoCompat.ACTION_CLICK) return false val course = visibleCourses().getOrNull(virtualViewId) ?: return false - onCourseClickListener?.invoke(course) + onCourseClickListener?.invoke(course, this@CourseTableView, RectF(courseBounds(course))) sendEventForVirtualView(virtualViewId, android.view.accessibility.AccessibilityEvent.TYPE_VIEW_CLICKED) return true } } init { - setBackgroundColor(ContextCompat.getColor(context, R.color.surface)) + setBackgroundColor(Color.TRANSPARENT) importantForAccessibility = IMPORTANT_FOR_ACCESSIBILITY_YES ViewCompat.setAccessibilityDelegate(this, accessibilityHelper) } @@ -276,10 +350,58 @@ class CourseTableView @JvmOverloads constructor( } private fun drawCourses(canvas: Canvas) { - visibleCourses().forEach { course -> drawCourse(canvas, course) } + val visibleCourses = visibleCourses() + visibleCourses.forEach { course -> + val bounds = courseBounds(course) + val isCurrentWeek = ScheduleRules.isCourseInWeek(course, currentWeek) + val sectionSpan = (course.endSection - course.startSection + 1).coerceAtLeast(1) + val arcDirection = if ((course.dayOfWeek + course.startSection) % 2 == 0) -1f else 1f + val motion = cardMotionStates.getOrPut(course) { CardMotionState(course) }.also { + it.moveTo(pagerOffset) + } + val motionRatio = (motion.translationX / motion.travelDistance).coerceIn(-1f, 1f) + val motionDistance = abs(motionRatio) + val saveCount = canvas.save() + + canvas.translate( + motion.translationX, + sin(motionDistance * Math.PI).toFloat() * + arcDirection * dp(1.8f + sectionSpan * 0.55f) + ) + + canvas.rotate( + -motionRatio * arcDirection * (0.7f + sectionSpan * 0.14f), + bounds.centerX(), + bounds.centerY() + ) + + val motionScale = 1f - motionDistance * (0.025f + sectionSpan * 0.004f) + canvas.scale( + motionScale, + motionScale, + bounds.centerX(), + bounds.centerY() + ) + + if (course == pressedCourse) { + canvas.scale( + pressedCourseScale, + pressedCourseScale, + bounds.centerX(), + bounds.centerY() + ) + } + + val motionAlpha = (255f * (1f - motionDistance * 0.22f)) + .roundToInt() + .coerceIn(0, 255) + val alpha = if (isCurrentWeek) motionAlpha else (motionAlpha * 0.46f).roundToInt() + drawCourse(canvas, course, alpha, isCurrentWeek) + canvas.restoreToCount(saveCount) + } } - private fun drawCourse(canvas: Canvas, course: Course) { + private fun drawCourse(canvas: Canvas, course: Course, alpha: Int, isCurrentWeek: Boolean) { val bounds = courseBounds(course) val left = bounds.left val top = bounds.top @@ -288,45 +410,61 @@ class CourseTableView @JvmOverloads constructor( if (right <= left || bottom <= top) return coursePaint.color = courseColors[Math.floorMod(course.colorIndex, courseColors.size)] + coursePaint.alpha = alpha + courseStrokePaint.alpha = 190 * alpha / 255 + courseNamePaint.alpha = alpha + courseRoomPaint.alpha = 220 * alpha / 255 + courseTeacherPaint.alpha = 205 * alpha / 255 canvas.drawRoundRect( RectF(left, top, right, bottom), courseCornerRadius, courseCornerRadius, coursePaint ) + canvas.drawRoundRect( + RectF(left, top, right, bottom), + courseCornerRadius, + courseCornerRadius, + courseStrokePaint + ) - val displayName = displayCourseName(course.courseName) + val courseName = displayCourseName(course.courseName) + val displayName = if (isCurrentWeek) { + courseName + } else { + resources.getString(R.string.inactive_course_name_format, courseName) + } val nameLength = displayName.count { !it.isWhitespace() } courseNamePaint.textSize = sp( when { - nameLength >= 18 -> 8.5f - nameLength >= 12 -> 9.5f - else -> 11f + nameLength >= 18 -> 9f + nameLength >= 12 -> 10f + else -> 11.5f } ) - courseRoomPaint.textSize = sp(8.5f) - courseTeacherPaint.textSize = sp(8f) + courseRoomPaint.textSize = sp(9f) + courseTeacherPaint.textSize = sp(8.5f) - val horizontalPadding = dp(3f) + val horizontalPadding = dp(6f) val textWidth = (right - left - horizontalPadding * 2).roundToInt().coerceAtLeast(1) val sectionCount = course.endSection - course.startSection + 1 val showDetails = sectionCount >= 2 - val teacherLayout = if (showDetails && course.teacher.isNotBlank()) { + val roomLayout = if (showDetails && course.classroom.isNotBlank()) { buildTextLayout( - resources.getString(R.string.course_teacher_inline, course.teacher), - courseTeacherPaint, + "@${course.classroom}", + courseRoomPaint, textWidth, - 1 + 2 ) } else { null } - val roomLayout = if (showDetails && course.classroom.isNotBlank()) { - buildTextLayout(course.classroom, courseRoomPaint, textWidth, 2) + val teacherLayout = if (showDetails && course.teacher.isNotBlank()) { + buildTextLayout(course.teacher, courseTeacherPaint, textWidth, 1) } else { null } - val detailLayouts = listOfNotNull(teacherLayout, roomLayout) + val detailLayouts = listOfNotNull(roomLayout, teacherLayout) val verticalPadding = dp(5f) val nameGap = if (detailLayouts.isNotEmpty()) dp(3f) else 0f val detailGap = if (detailLayouts.size > 1) dp(1f) else 0f @@ -365,7 +503,7 @@ class CourseTableView @JvmOverloads constructor( maxLines: Int ): StaticLayout = StaticLayout.Builder .obtain(text, 0, text.length, paint, width) - .setAlignment(Layout.Alignment.ALIGN_CENTER) + .setAlignment(Layout.Alignment.ALIGN_NORMAL) .setEllipsize(TextUtils.TruncateAt.END) .setIncludePad(false) .setLineSpacing(0f, 1f) @@ -373,21 +511,92 @@ class CourseTableView @JvmOverloads constructor( .build() override fun onTouchEvent(event: MotionEvent): Boolean { - if (event.action == MotionEvent.ACTION_UP && event.x > timeColumnWidth) { - val day = ((event.x - timeColumnWidth) / dayWidth).toInt() + 1 - val section = (event.y / sectionHeight).toInt() + 1 - if (day > visibleDaysCount) return true - visibleCourses().firstOrNull { course -> - course.dayOfWeek == day && - section in course.startSection..course.endSection - }?.let { course -> - performClick() - onCourseClickListener?.invoke(course) + when (event.actionMasked) { + MotionEvent.ACTION_DOWN -> { + cancelPressAnimation() + touchDownX = event.x + touchDownY = event.y + touchMoved = false + pressedCourse = courseAt(event.x, event.y) + pressedCourseScale = 1f + animatePressedCourse(0.94f, 130L, clearOnEnd = false) + } + MotionEvent.ACTION_MOVE -> { + if ( + !touchMoved && + (abs(event.x - touchDownX) > touchSlop || abs(event.y - touchDownY) > touchSlop) + ) { + touchMoved = true + animatePressedCourse(1f, 180L, clearOnEnd = true) + } + } + MotionEvent.ACTION_UP -> { + val course = pressedCourse + val isClick = !touchMoved && course != null && courseBounds(course).contains( + event.x, + event.y + ) + animatePressedCourse(1f, 290L, clearOnEnd = true, overshoot = true) + if (isClick && course != null) { + val sourceBounds = RectF(courseBounds(course)) + performClick() + postDelayed( + { onCourseClickListener?.invoke(course, this, sourceBounds) }, + 150L + ) + } + } + MotionEvent.ACTION_CANCEL -> { + if (!touchMoved) { + touchMoved = true + animatePressedCourse(1f, 180L, clearOnEnd = true) + } } } return true } + private fun courseAt(x: Float, y: Float): Course? { + if (x <= timeColumnWidth) return null + return visibleCourses().asReversed().firstOrNull { course -> + courseBounds(course).contains(x, y) + } + } + + private fun cancelPressAnimation() { + pressAnimationGeneration++ + pressAnimator?.cancel() + pressAnimator = null + } + + private fun animatePressedCourse( + target: Float, + duration: Long, + clearOnEnd: Boolean, + overshoot: Boolean = false + ) { + val course = pressedCourse ?: return + val generation = ++pressAnimationGeneration + pressAnimator?.cancel() + pressAnimator = ValueAnimator.ofFloat(pressedCourseScale, target).apply { + this.duration = duration + interpolator = if (overshoot) OvershootInterpolator(1.25f) else DecelerateInterpolator() + addUpdateListener { animator -> + pressedCourseScale = animator.animatedValue as Float + invalidate() + } + addListener(object : AnimatorListenerAdapter() { + override fun onAnimationEnd(animation: Animator) { + if (generation != pressAnimationGeneration) return + pressedCourseScale = target + if (clearOnEnd && pressedCourse == course) pressedCourse = null + invalidate() + } + }) + start() + } + } + override fun performClick(): Boolean { super.performClick() return true @@ -397,12 +606,42 @@ class CourseTableView @JvmOverloads constructor( return accessibilityHelper.dispatchHoverEvent(event) || super.dispatchHoverEvent(event) } + override fun onDetachedFromWindow() { + cancelPressAnimation() + cardMotionStates.values.forEach(CardMotionState::cancel) + super.onDetachedFromWindow() + } + fun setCourses(courses: List) { + cancelPressAnimation() + pressedCourse = null + pressedCourseScale = 1f + val retainedCourses = courses.toSet() + cardMotionStates.entries.removeAll { (course, state) -> + if (course !in retainedCourses) state.cancel() + course !in retainedCourses + } this.courses = courses + cardMotionStates.values.forEach { it.moveTo(pagerOffset) } accessibilityHelper.invalidateRoot() invalidate() } + fun setPagerOffset(position: Float) { + val newOffset = position.coerceIn(-1f, 1f) + if (abs(pagerOffset - newOffset) < 0.001f) return + pagerOffset = newOffset + visibleCourses().forEach { course -> + cardMotionStates.getOrPut(course) { CardMotionState(course) }.moveTo(newOffset) + } + } + + fun resetPagerMotion() { + pagerOffset = 0f + cardMotionStates.values.forEach(CardMotionState::reset) + invalidate() + } + fun setCurrentWeek(week: Int) { currentWeek = week accessibilityHelper.invalidateRoot() @@ -417,11 +656,13 @@ class CourseTableView @JvmOverloads constructor( fun applyDisplaySettings( showWeekend: Boolean, showTimes: Boolean, + showInactiveCourses: Boolean, sectionHeightDp: Int, sectionTimes: List ) { visibleDaysCount = if (showWeekend) 7 else 5 this.showTimes = showTimes + this.showInactiveCourses = showInactiveCourses this.sectionTimes = sectionTimes.takeIf { it.size == TOTAL_SECTIONS } ?: SchedulePreferences.DEFAULT_SECTION_TIMES sectionHeight = dp(sectionHeightDp.coerceIn(56, 104).toFloat()) @@ -430,13 +671,20 @@ class CourseTableView @JvmOverloads constructor( invalidate() } - fun setOnCourseClickListener(listener: (Course) -> Unit) { + fun setOnCourseClickListener(listener: (Course, View, RectF) -> Unit) { onCourseClickListener = listener } - private fun visibleCourses(): List = courses.filter { - it.dayOfWeek <= visibleDaysCount && ScheduleRules.isCourseInWeek(it, currentWeek) - } + private fun visibleCourses(): List = courses + .filter { course -> + course.dayOfWeek <= visibleDaysCount && + (showInactiveCourses || ScheduleRules.isCourseInWeek(course, currentWeek)) + } + .sortedWith( + compareBy { it.startSection } + .thenBy { it.dayOfWeek } + .thenBy { if (ScheduleRules.isCourseInWeek(it, currentWeek)) 1 else 0 } + ) private fun courseBounds(course: Course): RectF = RectF( timeColumnWidth + (course.dayOfWeek - 1) * dayWidth + courseInset, @@ -452,7 +700,7 @@ class CourseTableView @JvmOverloads constructor( 2 -> resources.getString(R.string.even_week) else -> resources.getString(R.string.every_week) } - return resources.getString( + val description = resources.getString( R.string.course_accessibility_description, displayCourseName(course.courseName), days.getOrElse(course.dayOfWeek - 1) { "" }, @@ -464,6 +712,11 @@ class CourseTableView @JvmOverloads constructor( course.teacher.ifBlank { resources.getString(R.string.not_set) }, course.classroom.ifBlank { resources.getString(R.string.not_set) } ) + return if (ScheduleRules.isCourseInWeek(course, currentWeek)) { + description + } else { + "${resources.getString(R.string.inactive_course_label)},$description" + } } private fun dp(value: Float): Float = value * density diff --git a/app/src/main/res/drawable/bg_bottom_navigation.xml b/app/src/main/res/drawable/bg_bottom_navigation.xml new file mode 100644 index 0000000..1e75205 --- /dev/null +++ b/app/src/main/res/drawable/bg_bottom_navigation.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/res/drawable/bg_course_detail_sheet.xml b/app/src/main/res/drawable/bg_course_detail_sheet.xml new file mode 100644 index 0000000..9d8ab74 --- /dev/null +++ b/app/src/main/res/drawable/bg_course_detail_sheet.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/bg_day_selected.xml b/app/src/main/res/drawable/bg_day_selected.xml index 40522ec..7d96b56 100644 --- a/app/src/main/res/drawable/bg_day_selected.xml +++ b/app/src/main/res/drawable/bg_day_selected.xml @@ -7,5 +7,6 @@ + diff --git a/app/src/main/res/drawable/bg_import_school_icon.xml b/app/src/main/res/drawable/bg_import_school_icon.xml new file mode 100644 index 0000000..71afaff --- /dev/null +++ b/app/src/main/res/drawable/bg_import_school_icon.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/bg_schedule_screen.xml b/app/src/main/res/drawable/bg_schedule_screen.xml new file mode 100644 index 0000000..fc20f7e --- /dev/null +++ b/app/src/main/res/drawable/bg_schedule_screen.xml @@ -0,0 +1,7 @@ + + + + diff --git a/app/src/main/res/drawable/bg_sheet_handle.xml b/app/src/main/res/drawable/bg_sheet_handle.xml new file mode 100644 index 0000000..b9c6a05 --- /dev/null +++ b/app/src/main/res/drawable/bg_sheet_handle.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/bg_week_selector.xml b/app/src/main/res/drawable/bg_week_selector.xml new file mode 100644 index 0000000..da4f207 --- /dev/null +++ b/app/src/main/res/drawable/bg_week_selector.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_add.xml b/app/src/main/res/drawable/ic_add.xml index 5fb6255..c700974 100644 --- a/app/src/main/res/drawable/ic_add.xml +++ b/app/src/main/res/drawable/ic_add.xml @@ -5,6 +5,6 @@ android:viewportWidth="24" android:viewportHeight="24"> diff --git a/app/src/main/res/drawable/ic_edit.xml b/app/src/main/res/drawable/ic_edit.xml new file mode 100644 index 0000000..39d4726 --- /dev/null +++ b/app/src/main/res/drawable/ic_edit.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/drawable/ic_person.xml b/app/src/main/res/drawable/ic_person.xml new file mode 100644 index 0000000..433686a --- /dev/null +++ b/app/src/main/res/drawable/ic_person.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/drawable/ic_room.xml b/app/src/main/res/drawable/ic_room.xml new file mode 100644 index 0000000..b043959 --- /dev/null +++ b/app/src/main/res/drawable/ic_room.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/drawable/ic_time.xml b/app/src/main/res/drawable/ic_time.xml new file mode 100644 index 0000000..a466645 --- /dev/null +++ b/app/src/main/res/drawable/ic_time.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/layout/activity_import.xml b/app/src/main/res/layout/activity_import.xml index 880e971..5798d76 100644 --- a/app/src/main/res/layout/activity_import.xml +++ b/app/src/main/res/layout/activity_import.xml @@ -9,67 +9,138 @@ - - + app:layout_constraintTop_toBottomOf="@id/toolbar"> + android:paddingHorizontal="16dp" + android:paddingTop="18dp"> + + + + + + + + + + + + + + + + + + + + + + + android:textSize="12sp" /> + + + app:titleTextAppearance="@style/CourseScheduleToolbarTitle" /> @@ -33,11 +34,12 @@ @@ -46,17 +48,17 @@ android:id="@+id/btnPreviousWeek" android:layout_width="40dp" android:layout_height="40dp" - android:background="@drawable/bg_icon_button" + android:background="?attr/selectableItemBackgroundBorderless" android:contentDescription="@string/previous_week" android:padding="8dp" android:src="@drawable/ic_chevron_left" - app:tint="@color/text_primary" /> + app:tint="@color/text_secondary" /> + app:tint="@color/text_secondary" /> + app:trackColor="#42FFFFFF" + app:trackThickness="2dp" /> - - diff --git a/app/src/main/res/layout/activity_settings.xml b/app/src/main/res/layout/activity_settings.xml index 693881b..5e74db3 100644 --- a/app/src/main/res/layout/activity_settings.xml +++ b/app/src/main/res/layout/activity_settings.xml @@ -10,11 +10,11 @@ - + - + + + + + + + + - + + + diff --git a/app/src/main/res/layout/activity_swpu_web_import.xml b/app/src/main/res/layout/activity_swpu_web_import.xml new file mode 100644 index 0000000..5280246 --- /dev/null +++ b/app/src/main/res/layout/activity_swpu_web_import.xml @@ -0,0 +1,129 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/dialog_course_details.xml b/app/src/main/res/layout/dialog_course_details.xml index 458b8a3..f505fa4 100644 --- a/app/src/main/res/layout/dialog_course_details.xml +++ b/app/src/main/res/layout/dialog_course_details.xml @@ -1,138 +1,181 @@ + android:paddingBottom="32dp"> + + - - + android:textSize="22sp" + android:textStyle="bold" /> + + - - - + + android:textSize="16sp" /> - + + android:textSize="16sp" /> - + + + android:layout_marginStart="22dp" + android:layout_weight="1" + android:orientation="vertical"> + + + + + + + + + + + android:textIsSelectable="true" + android:textSize="16sp" /> - + + android:text="8 月" + android:textColor="@color/text_secondary" + android:textSize="11sp" + android:textStyle="bold" /> @@ -38,7 +40,7 @@ android:layout_width="match_parent" android:layout_height="0dp" android:layout_weight="1" - android:background="@color/surface"> + android:background="@android:color/transparent"> + android:id="@+id/emptyIconContainer" + android:layout_width="72dp" + android:layout_height="72dp" + android:background="@drawable/bg_empty_icon" + android:contentDescription="@string/no_courses_this_week"> + + - #176B5B - #0F4F43 - #DDEFEA - #D29436 - #A66D1F + #3E628C + #294967 + #DCE8F5 + #D69043 + #A96928 - #F4F7F6 - #FFFFFF - #F8FAF9 + #EDF2FA + #FAFCFF + #F3F6FB + #EBF4F7FC #B3261E #FFFFFF #2B1B00 - #1D2422 - #1D2422 - #1D2422 - #66716E - #8C9693 + #1C2733 + #1C2733 + #1C2733 + #667487 + #8A96A5 - #C65D57 - #B7567A - #8062B5 - #6555A3 - #586FB3 - #397EAE - #398CA6 - #368C8A - #397E70 - #4A855E - #718B45 - #8A913E - #B4882F - #B8772F - #B9673E - #8A6859 + #E78387 + #DD86A8 + #A58BE2 + #8F82D3 + #819DDB + #68A6DA + #73B4DE + #70C2C7 + #68B8A8 + #7BB38C + #9CB97B + #B0BE73 + #CDB36C + #DAA56E + #E28C70 + #B39182 @@ -61,14 +62,18 @@ - #E3E9E7 - #E8ECEB - #DDEFEA - #EDF7F4 - #FAFCFB - #F6F9F8 - #176B5B + #DCE4EF + #DCE4EF + #DCE8F5 + #2ADCE8F5 + #1AFFFFFF + #24D9E5F5 + #3E628C + #18A995 + #E19A3D + #4F84C4 + #D65D64 - #176B5B + #3E628C diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6d7ed55..31027c5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -90,6 +90,22 @@ 导入课程 选择导入方式 将导入到 %1$s + 从教务系统导入 + 西南石油大学 · 登录后直接读取当前学期课表 + 其他方式 + 西南石油大学教务系统 + 等待登录 + 账号和密码只提交给学校网页,本应用不会保存 + 正在识别学期 + 正在获取课表 + 正在解析课程 + 已获取 %1$d 门课程 + 读取当前学期课表 + 已阻止打开非学校域名页面 + 学校网站证书校验失败,已停止加载 + 教务系统返回的数据过大,无法安全导入 + 读取课表失败 + 请确认已经登录教务系统,并且当前学期课表可以正常查看。 从文件导入 从文本导入 Excel(.xlsx)、JSON、CSV、HTML 和文本 @@ -160,6 +176,7 @@ 浏览第%d周 %1$s · %2$s-%3$s %1$s · %2$s-%3$s · %4$d门课 + 第%1$d周 · %2$s 学期中 假期 · 浏览第%d周 未开学 · 浏览第%d周 @@ -183,6 +200,9 @@ 课表显示 显示周末 + 显示非本周课程 + 非本周 + [非本周]\n%1$s 显示节次时间 课程块高度 %d dp diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index 11cd9a8..20babc6 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -24,10 +24,12 @@ @color/on_primary - @color/surface - @color/surface + @color/background + @color/navigation_surface true sans + false + @color/background + + + + + + + +