Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 58 additions & 6 deletions android/app/src/main/kotlin/dev/so1ve/bitree/BuildingColors.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,64 @@ package dev.so1ve.bitree

import android.content.Context
import android.content.res.Configuration
import android.util.Log
import org.json.JSONArray
import org.json.JSONObject

internal object BuildingColors {
private data class Palette(val light: IntArray, val dark: IntArray)
private data class Rule(val name: String, val pattern: Regex, val palette: Palette)
private data class Rule(
val name: String,
val area: String?,
val pattern: Regex,
val palette: Palette,
)
private data class Match(val start: Int, val end: Int, val rule: Rule)
private data class Table(val neutral: Palette, val rules: List<Rule>)

private var table: Table? = null
private val customColors = mutableMapOf<Boolean, Map<String, IntArray>>()
private const val PREFERENCES = "widget_appearance"
private fun colorsKey(dark: Boolean) = if (dark) "darkCourseColors" else "lightCourseColors"
private val otherCampuses = Regex("中关村|西山|珠海|雄安|怀来")
private val spacing = Regex("[\\s\\u00A0\\u3000·._\\-—–()()\\[\\]【】]")

fun savePalette(context: Context, dark: Boolean, colors: Map<String, List<Number>>) {
context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE).edit()
.putString(colorsKey(dark), JSONObject(colors).toString()).apply()
customColors[dark] = colors.mapValues { (_, values) ->
values.map { it.toInt() }.toIntArray()
}
}

private fun readPalette(context: Context, dark: Boolean): Map<String, IntArray> {
return customColors[dark] ?: run {
try {
val saved = context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
.getString(colorsKey(dark), "{}") ?: "{}"
val json = JSONObject(saved)
json.keys().asSequence().associateWith { area ->
val values = json.getJSONArray(area)
require(values.length() == 2)
IntArray(2) { values.getLong(it).toInt() }
}
} catch (error: Exception) {
Log.w("BITree", "无法读取桌面组件的课程配色", error)
emptyMap()
}.also { customColors[dark] = it }
}
}

fun widgetBackground(context: Context): Int {
val fallback = context.getColor(R.color.widget_surface)
if (!isDark(context)) return fallback
return readPalette(context, true)["surface"]?.firstOrNull() ?: fallback
}

private fun isDark(context: Context): Boolean =
context.resources.configuration.uiMode and
Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES

fun forCourse(context: Context, room: String, campus: String): IntArray {
val data = table ?: read(context).also { table = it }
val normalized = room.map { char ->
Expand All @@ -33,10 +78,12 @@ internal object BuildingColors {
other.end - other.start > match.end - match.start
}
}.map { it.rule }.distinctBy { it.name }
val palette = buildings.singleOrNull()?.palette ?: data.neutral
val dark = context.resources.configuration.uiMode and
Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES
return if (dark) palette.dark else palette.light
val building = buildings.singleOrNull()
val palette = building?.palette ?: data.neutral
WidgetColors.course(context, building?.area)?.let { return it }
val dark = isDark(context)
return readPalette(context, dark)[building?.area ?: "neutral"]
?: if (dark) palette.dark else palette.light
}

private fun read(context: Context): Table {
Expand All @@ -52,7 +99,12 @@ internal object BuildingColors {
palette(json.getJSONObject("neutral")),
(0 until rows.length()).map { index ->
val row = rows.getJSONObject(index)
Rule(row.getString("name"), Regex(row.getString("pattern")), palette(row))
Rule(
row.getString("name"),
row.optString("area").takeIf { it.isNotEmpty() },
Regex(row.getString("pattern")),
palette(row),
)
},
)
}
Expand Down
41 changes: 41 additions & 0 deletions android/app/src/main/kotlin/dev/so1ve/bitree/MainActivity.kt
Original file line number Diff line number Diff line change
@@ -1,20 +1,61 @@
package dev.so1ve.bitree

import android.content.res.Configuration
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel

class MainActivity : FlutterActivity() {
private var themeChannel: MethodChannel? = null

override fun onResume() {
super.onResume()
// Wallpaper colours may have changed while the app was in the background.
themeChannel?.invokeMethod("changed", null)
}

override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
TimetableWidget.updateAll(this)
themeChannel?.invokeMethod("changed", null)
}

override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
themeChannel = MethodChannel(
flutterEngine.dartExecutor.binaryMessenger, "dev.so1ve.bitree/theme",
).also { channel ->
channel.setMethodCallHandler { call, result ->
if (call.method == "getSystemPalette") {
result.success(SystemPalette.read(this))
} else {
result.notImplemented()
}
}
}
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "dev.so1ve.bitree/widget")
.setMethodCallHandler { call, result ->
if (call.method == "update") {
call.argument<Map<String, List<Number>>>("lightCourseColors")?.let {
BuildingColors.savePalette(this, false, it)
}
call.argument<Map<String, List<Number>>>("darkCourseColors")?.let {
BuildingColors.savePalette(this, true, it)
}
call.argument<Boolean>("useSystemPalette")?.let {
WidgetColors.saveUseSystemPalette(this, it)
}
TimetableWidget.updateAll(this)
result.success(null)
} else {
result.notImplemented()
}
}
}

override fun cleanUpFlutterEngine(flutterEngine: FlutterEngine) {
themeChannel?.setMethodCallHandler(null)
themeChannel = null
super.cleanUpFlutterEngine(flutterEngine)
}
}
49 changes: 49 additions & 0 deletions android/app/src/main/kotlin/dev/so1ve/bitree/SystemPalette.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package dev.so1ve.bitree

import android.content.Context
import android.os.Build

/** Export Android 12+ tonal resources; older devices retain the app palette. */
internal object SystemPalette {
fun read(context: Context): Map<String, List<Int>>? {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return null
val families = mapOf(
"accent1" to intArrayOf(
android.R.color.system_accent1_0, android.R.color.system_accent1_10, android.R.color.system_accent1_50,
android.R.color.system_accent1_100, android.R.color.system_accent1_200, android.R.color.system_accent1_300,
android.R.color.system_accent1_400, android.R.color.system_accent1_500, android.R.color.system_accent1_600,
android.R.color.system_accent1_700, android.R.color.system_accent1_800, android.R.color.system_accent1_900,
android.R.color.system_accent1_1000,
),
"accent2" to intArrayOf(
android.R.color.system_accent2_0, android.R.color.system_accent2_10, android.R.color.system_accent2_50,
android.R.color.system_accent2_100, android.R.color.system_accent2_200, android.R.color.system_accent2_300,
android.R.color.system_accent2_400, android.R.color.system_accent2_500, android.R.color.system_accent2_600,
android.R.color.system_accent2_700, android.R.color.system_accent2_800, android.R.color.system_accent2_900,
android.R.color.system_accent2_1000,
),
"accent3" to intArrayOf(
android.R.color.system_accent3_0, android.R.color.system_accent3_10, android.R.color.system_accent3_50,
android.R.color.system_accent3_100, android.R.color.system_accent3_200, android.R.color.system_accent3_300,
android.R.color.system_accent3_400, android.R.color.system_accent3_500, android.R.color.system_accent3_600,
android.R.color.system_accent3_700, android.R.color.system_accent3_800, android.R.color.system_accent3_900,
android.R.color.system_accent3_1000,
),
"neutral1" to intArrayOf(
android.R.color.system_neutral1_0, android.R.color.system_neutral1_10, android.R.color.system_neutral1_50,
android.R.color.system_neutral1_100, android.R.color.system_neutral1_200, android.R.color.system_neutral1_300,
android.R.color.system_neutral1_400, android.R.color.system_neutral1_500, android.R.color.system_neutral1_600,
android.R.color.system_neutral1_700, android.R.color.system_neutral1_800, android.R.color.system_neutral1_900,
android.R.color.system_neutral1_1000,
),
"neutral2" to intArrayOf(
android.R.color.system_neutral2_0, android.R.color.system_neutral2_10, android.R.color.system_neutral2_50,
android.R.color.system_neutral2_100, android.R.color.system_neutral2_200, android.R.color.system_neutral2_300,
android.R.color.system_neutral2_400, android.R.color.system_neutral2_500, android.R.color.system_neutral2_600,
android.R.color.system_neutral2_700, android.R.color.system_neutral2_800, android.R.color.system_neutral2_900,
android.R.color.system_neutral2_1000,
),
)
return families.mapValues { (_, colors) -> colors.map { context.getColor(it) } }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,12 @@ open class TimetableWidget : AppWidgetProvider() {
)
widgets.forEach { (id, columns) ->
val views = RemoteViews(context.packageName, R.layout.timetable_widget)
views.setInt(
R.id.widget_content, "setBackgroundColor", WidgetColors.background(context),
)
views.setTextColor(R.id.widget_date, WidgetColors.text(context))
views.setTextColor(R.id.widget_empty, WidgetColors.muted(context))
views.setTextColor(R.id.widget_message, WidgetColors.accent(context))
views.setTextViewText(R.id.widget_date, content.heading)
views.setTextViewText(R.id.widget_empty, content.emptyMessage)
views.setEmptyView(R.id.widget_courses, R.id.widget_empty)
Expand Down
91 changes: 91 additions & 0 deletions android/app/src/main/kotlin/dev/so1ve/bitree/WidgetColors.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
package dev.so1ve.bitree

import android.content.Context
import android.content.res.Configuration
import android.os.Build

/** Widget surfaces follow Android; courses opt into the system palette separately. */
internal object WidgetColors {
private const val PREFERENCES = "widget_appearance"
private const val USE_SYSTEM_PALETTE = "useSystemPalette"

fun saveUseSystemPalette(context: Context, enabled: Boolean) {
context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE).edit()
.putBoolean(USE_SYSTEM_PALETTE, enabled).apply()
}

fun background(context: Context): Int =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
context.getColor(R.color.widget_system_surface)
} else {
BuildingColors.widgetBackground(context)
}

fun text(context: Context): Int = context.getColor(
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) R.color.widget_system_text
else R.color.widget_text,
)

fun muted(context: Context): Int = context.getColor(
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) R.color.widget_system_muted
else R.color.widget_muted,
)

fun accent(context: Context): Int = context.getColor(
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) R.color.widget_system_accent
else R.color.widget_accent,
)

/** Preserve campus grouping with three system accent families and tonal variations. */
fun course(context: Context, area: String?): IntArray? {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return null
if (!context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
.getBoolean(USE_SYSTEM_PALETTE, false)) return null
val dark = context.resources.configuration.uiMode and
Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES
val colors = if (dark) {
when (area) {
"northTeaching" -> intArrayOf(
android.R.color.system_accent1_700, android.R.color.system_accent1_100,
)
"wencui" -> intArrayOf(
android.R.color.system_accent2_700, android.R.color.system_accent2_100,
)
"southScience" -> intArrayOf(
android.R.color.system_accent3_700, android.R.color.system_accent3_100,
)
"eastFacilities" -> intArrayOf(
android.R.color.system_accent1_800, android.R.color.system_accent1_100,
)
"southEngineering" -> intArrayOf(
android.R.color.system_accent3_800, android.R.color.system_accent3_100,
)
else -> intArrayOf(
android.R.color.system_neutral2_700, android.R.color.system_neutral2_100,
)
}
} else {
when (area) {
"northTeaching" -> intArrayOf(
android.R.color.system_accent1_100, android.R.color.system_accent1_900,
)
"wencui" -> intArrayOf(
android.R.color.system_accent2_100, android.R.color.system_accent2_900,
)
"southScience" -> intArrayOf(
android.R.color.system_accent3_100, android.R.color.system_accent3_900,
)
"eastFacilities" -> intArrayOf(
android.R.color.system_accent1_50, android.R.color.system_accent1_900,
)
"southEngineering" -> intArrayOf(
android.R.color.system_accent3_50, android.R.color.system_accent3_900,
)
else -> intArrayOf(
android.R.color.system_neutral2_100, android.R.color.system_neutral2_900,
)
}
}
return colors.map { context.getColor(it) }.toIntArray()
}
}
71 changes: 38 additions & 33 deletions android/app/src/main/res/layout/timetable_widget.xml
Original file line number Diff line number Diff line change
@@ -1,41 +1,46 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/widget_root"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:paddingStart="@dimen/widget_padding"
android:paddingEnd="@dimen/widget_padding"
android:paddingTop="8dp"
android:paddingBottom="8dp"
android:background="@drawable/widget_background"
android:clipToOutline="true">
<include layout="@layout/widget_header" />
<FrameLayout
<LinearLayout
android:id="@+id/widget_content"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:layout_marginTop="6dp">
<ListView
android:id="@+id/widget_courses"
android:layout_height="match_parent"
android:orientation="vertical"
android:paddingStart="@dimen/widget_padding"
android:paddingEnd="@dimen/widget_padding"
android:paddingTop="8dp"
android:paddingBottom="8dp">
<include layout="@layout/widget_header" />
<FrameLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:divider="@null"
android:dividerHeight="0dp"
android:scrollbars="vertical"
android:scrollbarStyle="insideOverlay"
android:scrollbarSize="2dp"
android:clipToPadding="false"
android:requiresFadingEdge="vertical"
android:fadingEdgeLength="12dp"
android:cacheColorHint="@color/widget_surface" />
<TextView
android:id="@+id/widget_empty"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:text="@string/widget_today_empty"
android:textColor="@color/widget_muted"
android:textSize="13sp" />
</FrameLayout>
</LinearLayout>
android:layout_height="0dp"
android:layout_weight="1"
android:layout_marginTop="6dp">
<ListView
android:id="@+id/widget_courses"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:divider="@null"
android:dividerHeight="0dp"
android:scrollbars="vertical"
android:scrollbarStyle="insideOverlay"
android:scrollbarSize="2dp"
android:clipToPadding="false"
android:requiresFadingEdge="vertical"
android:fadingEdgeLength="12dp"
android:cacheColorHint="@android:color/transparent" />
<TextView
android:id="@+id/widget_empty"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:text="@string/widget_today_empty"
android:textColor="@color/widget_muted"
android:textSize="13sp" />
</FrameLayout>
</LinearLayout>
</FrameLayout>
Loading