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
@@ -40,7 +42,18 @@
- center
- 1dp
- @color/text_secondary
- - 11sp
+ - 12sp
+
+
+
+
+
+
+
+
+