From 3498e4fdfc79924d8bb84b29c5eff949d1bf12a3 Mon Sep 17 00:00:00 2001
From: yueye6811
Date: Sat, 8 Aug 2026 15:56:21 +0800
Subject: [PATCH] Release MaxMath 1.1.0 UI refresh
---
README.md | 7 +-
README.zh-CN.md | 7 +-
RELEASE_NOTES.md | 19 +
app/build.gradle.kts | 9 +-
.../com/paruh/maxmath/ui/CalcViewModel.kt | 21 +-
.../java/com/paruh/maxmath/ui/MatrixCells.kt | 36 ++
.../java/com/paruh/maxmath/ui/MaxMathApp.kt | 57 ++-
.../java/com/paruh/maxmath/ui/ModeIcons.kt | 10 +
.../paruh/maxmath/ui/components/Components.kt | 326 ++++++++++++++----
.../maxmath/ui/components/ComputeSection.kt | 130 +++++++
.../maxmath/ui/components/LatexResult.kt | 4 +-
.../paruh/maxmath/ui/plot/GlGestureMath.kt | 1 -
.../paruh/maxmath/ui/plot/Plot2DPainter.kt | 87 +++--
.../paruh/maxmath/ui/plot/PlotViewModel.kt | 9 +-
.../maxmath/ui/plot/gl/PlotGlRenderer.kt | 49 ++-
.../paruh/maxmath/ui/plot/gl/PlotGlView.kt | 32 +-
.../maxmath/ui/screens/CalculusScreen.kt | 89 +++--
.../paruh/maxmath/ui/screens/HomeScreen.kt | 51 +--
.../paruh/maxmath/ui/screens/MatrixScreen.kt | 71 ++--
.../paruh/maxmath/ui/screens/PlotScreen.kt | 62 ++--
.../maxmath/ui/screens/PolynomialScreen.kt | 76 ++--
.../maxmath/ui/screens/QuadraticFormScreen.kt | 69 ++--
.../paruh/maxmath/ui/screens/SystemScreen.kt | 32 +-
.../maxmath/ui/screens/VectorSpaceScreen.kt | 64 ++--
.../java/com/paruh/maxmath/ui/theme/Color.kt | 109 ++++++
.../java/com/paruh/maxmath/ui/theme/Dimens.kt | 36 ++
.../com/paruh/maxmath/ui/theme/PlotColors.kt | 132 +++++++
.../java/com/paruh/maxmath/ui/theme/Shape.kt | 17 +
.../java/com/paruh/maxmath/ui/theme/Theme.kt | 42 +--
.../java/com/paruh/maxmath/ui/theme/Type.kt | 35 ++
app/src/main/res/values-en/strings.xml | 10 +
app/src/main/res/values-night/colors.xml | 5 +
app/src/main/res/values-night/themes.xml | 10 +
app/src/main/res/values/colors.xml | 5 +
app/src/main/res/values/strings.xml | 10 +
app/src/main/res/values/themes.xml | 10 +-
.../com/paruh/maxmath/ui/CalcViewModelTest.kt | 135 ++++++++
.../paruh/maxmath/ui/MatrixCellsSaverTest.kt | 59 ++++
.../paruh/maxmath/ui/SystemScreenStateTest.kt | 7 +-
.../ui/components/ComputeSectionStateTest.kt | 68 ++++
.../ui/plot/Plot2DPainterPaletteTest.kt | 71 ++++
.../maxmath/ui/plot/PlotScreenStateTest.kt | 4 +-
.../paruh/maxmath/ui/theme/ColorSchemeTest.kt | 91 +++++
43 files changed, 1777 insertions(+), 397 deletions(-)
create mode 100644 app/src/main/java/com/paruh/maxmath/ui/components/ComputeSection.kt
create mode 100644 app/src/main/java/com/paruh/maxmath/ui/theme/Color.kt
create mode 100644 app/src/main/java/com/paruh/maxmath/ui/theme/Dimens.kt
create mode 100644 app/src/main/java/com/paruh/maxmath/ui/theme/PlotColors.kt
create mode 100644 app/src/main/java/com/paruh/maxmath/ui/theme/Shape.kt
create mode 100644 app/src/main/java/com/paruh/maxmath/ui/theme/Type.kt
create mode 100644 app/src/main/res/values-night/colors.xml
create mode 100644 app/src/main/res/values-night/themes.xml
create mode 100644 app/src/test/kotlin/com/paruh/maxmath/ui/MatrixCellsSaverTest.kt
create mode 100644 app/src/test/kotlin/com/paruh/maxmath/ui/components/ComputeSectionStateTest.kt
create mode 100644 app/src/test/kotlin/com/paruh/maxmath/ui/plot/Plot2DPainterPaletteTest.kt
create mode 100644 app/src/test/kotlin/com/paruh/maxmath/ui/theme/ColorSchemeTest.kt
diff --git a/README.md b/README.md
index 9e0c874..e1492ca 100644
--- a/README.md
+++ b/README.md
@@ -9,15 +9,15 @@
简体中文
-[Download MaxMath 1.0.1 APK](https://github.com/yueye6811/MaxMath/releases/download/v1.0.1/MaxMath-v1.0.1-arm64-v8a.apk)
-· [Release notes](https://github.com/yueye6811/MaxMath/releases/tag/v1.0.1)
+[Download MaxMath 1.1.0 APK](https://github.com/yueye6811/MaxMath/releases/download/v1.1.0/MaxMath-v1.1.0-arm64-v8a.apk)
+· [Release notes](https://github.com/yueye6811/MaxMath/releases/tag/v1.1.0)
MaxMath is an offline Android app for higher-algebra computation and interactive
plotting, powered by GNU Maxima. Its interface is built with Kotlin and Jetpack
Compose, mathematical input is handled by a pure Kotlin parser, and complex
symbolic computations run in a separate engine process.
-> Current release: 1.0.1. Minimum supported version: Android 8.0 (API 26).
+> Current release: 1.1.0. Minimum supported version: Android 8.0 (API 26).
> The native computation engine currently provides an `arm64-v8a` build
> workflow only.
@@ -33,6 +33,7 @@ symbolic computations run in a separate engine process.
- Input: implicit multiplication, radicals, fractions, common functions, π/e, and natural equation syntax
- Output: offline LaTeX rendering, copyable text, and PNG saving or sharing
- Localization: Chinese, English, or the system language
+- Appearance: complete light and dark themes, including interactive plots
- Privacy: no network permission, accounts, ads, analytics, or telemetry
## Architecture
diff --git a/README.zh-CN.md b/README.zh-CN.md
index 3273cda..a74552c 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -9,14 +9,14 @@
简体中文
-[下载 MaxMath 1.0.1 APK](https://github.com/yueye6811/MaxMath/releases/download/v1.0.1/MaxMath-v1.0.1-arm64-v8a.apk)
-· [发布说明](https://github.com/yueye6811/MaxMath/releases/tag/v1.0.1)
+[下载 MaxMath 1.1.0 APK](https://github.com/yueye6811/MaxMath/releases/download/v1.1.0/MaxMath-v1.1.0-arm64-v8a.apk)
+· [发布说明](https://github.com/yueye6811/MaxMath/releases/tag/v1.1.0)
基于 GNU Maxima 的离线 Android 高等代数计算与交互式绘图应用。界面使用
Kotlin 与 Jetpack Compose,数学输入由纯 Kotlin 解析器处理,复杂符号计算在独立
引擎进程中执行。
-> 当前版本 1.0.1;最低系统 Android 8.0(API 26);原生计算引擎目前仅提供
+> 当前版本 1.1.0;最低系统 Android 8.0(API 26);原生计算引擎目前仅提供
> `arm64-v8a` 构建流程。
## 功能
@@ -31,6 +31,7 @@ Kotlin 与 Jetpack Compose,数学输入由纯 Kotlin 解析器处理,复杂
- 输入:隐式乘法、根号、分数、常用函数、π/e 与自然写法方程组
- 输出:离线 LaTeX 渲染,可复制文本,并可保存或分享 PNG
- 本地化:中文、English、跟随系统
+- 外观:完整浅色/深色主题,交互式绘图同步适配
- 隐私:不申请网络权限,不包含账号、广告、分析或遥测
## 架构
diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md
index 52d46f6..b609dda 100644
--- a/RELEASE_NOTES.md
+++ b/RELEASE_NOTES.md
@@ -1,3 +1,22 @@
+# MaxMath 1.1.0
+
+界面、深色模式与交互体验升级版(2026-08-08)。
+
+## 本版改进
+
+- 建立完整的 Material 3 浅色/深色主题,统一配色、字体、圆角、间距和尺寸;
+ 2D/3D/等高线绘图同步适配主题,深色模式冷启动不再白闪。
+- 所有计算页面加入运行中取消入口;重算时保留上一结果,取消后恢复原状态,
+ 新结果完成后自动滚动到结果区。
+- 重新设计加载与错误卡片:首次启动引擎时给出提示,错误详情可展开、复制,
+ Maxima 纯文本结果使用等宽排版。
+- 改进数学输入键盘行为,关闭自动纠错和自动大写;极限与积分的符号边界使用
+ 文本键盘;矩阵、向量和二次型的单元格内容可在屏幕旋转后恢复。
+- 加入带方向感的页面切换动画、统一模块卡片和无障碍标签;绘图重算期间保留并
+ 淡化上一预览,导出的 PNG 仍保持适合文档分享的浅色背景。
+- 新增计算取消、状态恢复、矩阵保存器、绘图配色和 WCAG AA 对比度测试;
+ release 变体统一排除依赖 Compose 测试清单的 `*StateTest`。
+
# MaxMath 1.0.1
性能与体积优化版(2026-08-07),详见 `docs/OPTIMIZATION.md`。
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index f5efa54..83d501c 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -23,8 +23,8 @@ android {
applicationId = "com.paruh.maxmath"
minSdk = 26
targetSdk = 36
- versionCode = 15
- versionName = "1.0.1"
+ versionCode = 16
+ versionName = "1.1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
@@ -124,6 +124,9 @@ tasks.withType().configureEach {
// Compose UI 测试依赖 debugImplementation 的 ui-test-manifest(其中声明了
// exported 测试 Activity),release 变体不应打包该测试组件;
// UI 行为与变体无关,debug 变体已覆盖。
- exclude("**/*ScreenStateTest.class")
+ //
+ // 约定:所有用 createComposeRule 的测试都以 StateTest 结尾。原先只排除
+ // *ScreenStateTest,组件级的 *SectionStateTest 会漏网并在 release 变体炸掉。
+ exclude("**/*StateTest.class")
}
}
diff --git a/app/src/main/java/com/paruh/maxmath/ui/CalcViewModel.kt b/app/src/main/java/com/paruh/maxmath/ui/CalcViewModel.kt
index d831801..094da50 100644
--- a/app/src/main/java/com/paruh/maxmath/ui/CalcViewModel.kt
+++ b/app/src/main/java/com/paruh/maxmath/ui/CalcViewModel.kt
@@ -13,9 +13,18 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import java.util.UUID
+/**
+ * @property loading 是否有计算在跑。
+ * @property response 最近一次成功的结果;重算期间**不清空**,界面因此不会塌陷。
+ * @property error 最近一次失败的原因;发起新计算时清掉。
+ *
+ * 「结果已过期」不单独存字段——它就是 `loading && response != null`,
+ * 再加一个标志位只会多一份可能对不上的真相。
+ */
data class CalcUiState(
val loading: Boolean = false,
val response: CalcResponse? = null,
@@ -42,10 +51,15 @@ class CalcViewModel : AndroidViewModel {
fun compute(task: MathTask) {
job?.cancel()
MaximaEngine.cancel()
- _state.value = CalcUiState(loading = true)
+ // 保留上一次结果:重算时不清空,界面不会塌陷再展开。
+ // 错误必须清掉,否则旧报错会和新的进度指示并排显示。
+ _state.update { it.copy(loading = true, error = null) }
job = viewModelScope.launch {
val request = CalcRequest(UUID.randomUUID().toString(), task)
val response = client.compute(request)
+ // 这里整体赋值:一次计算结束后,旧结果就该被新结果或新错误取代。
+ // 若协程已被 cancel(),withContext 恢复时会先抛 CancellationException,
+ // 走不到这一行,因此被取消的计算不会覆盖主线程刚写好的状态。
_state.value = CalcUiState(
loading = false,
response = response.takeIf { it.ok },
@@ -54,14 +68,15 @@ class CalcViewModel : AndroidViewModel {
}
}
+ /** 取消的是这次请求,不是整个界面:结果停留在取消前的样子。 */
fun cancel() {
job?.cancel()
MaximaEngine.cancel()
- _state.value = CalcUiState()
+ _state.update { it.copy(loading = false) }
}
fun showError(message: String?) {
val text = message ?: AppLocale.wrap(getApplication()).getString(R.string.error_input_invalid)
- _state.value = CalcUiState(error = text)
+ _state.update { it.copy(loading = false, error = text) }
}
}
diff --git a/app/src/main/java/com/paruh/maxmath/ui/MatrixCells.kt b/app/src/main/java/com/paruh/maxmath/ui/MatrixCells.kt
index c147161..b79976b 100644
--- a/app/src/main/java/com/paruh/maxmath/ui/MatrixCells.kt
+++ b/app/src/main/java/com/paruh/maxmath/ui/MatrixCells.kt
@@ -1,5 +1,8 @@
package com.paruh.maxmath.ui
+import androidx.compose.runtime.saveable.Saver
+import androidx.compose.runtime.saveable.listSaver
+
/** 不可变更新矩阵中的单个单元格,供矩阵类表单共享。 */
fun updateCell(
cells: List>,
@@ -14,3 +17,36 @@ fun updateCell(
r
}
}
+
+/**
+ * `List>` 的 Saver,用于矩阵/向量/二次型三个表单的单元格。
+ *
+ * 这三处原先用的是普通 `remember`,旋转屏幕后行列数还在、格子里填的东西全没了。
+ * 换成 `rememberSaveable` 时不能依赖默认路径:默认路径能不能work取决于
+ * `List(n){}` 在运行时恰好返回 `ArrayList`(Serializable),换个 List 实现就会在
+ * 旋转时崩,而单元测试根本发现不了。所以显式声明。
+ *
+ * 编码成扁平列表 `[行数, 列数, v00, v01, …, v10, …]`——Int 和 String 都是
+ * Bundle 原生支持的类型。刻意不做分隔符拼接:单元格内容可以是任意表达式,
+ * 里面出现逗号或分号会直接把数据切坏。
+ *
+ * 三个调用点的行长都一致(都用 `List(n) { List(m) { … } }` 构造),所以只带
+ * 行列数就够;restore 里的 `getOrNull`/`as?` 是防御性的,正常路径走不到。
+ */
+val MatrixCellsSaver: Saver>, Any> = listSaver(
+ save = { cells ->
+ buildList {
+ add(cells.size)
+ add(cells.firstOrNull()?.size ?: 0)
+ cells.forEach { row -> addAll(row) }
+ }
+ },
+ restore = { flat ->
+ val rows = flat.getOrNull(0) as? Int ?: 0
+ val cols = flat.getOrNull(1) as? Int ?: 0
+ val values = flat.drop(2)
+ List(rows) { r ->
+ List(cols) { c -> values.getOrNull(r * cols + c) as? String ?: "" }
+ }
+ },
+)
diff --git a/app/src/main/java/com/paruh/maxmath/ui/MaxMathApp.kt b/app/src/main/java/com/paruh/maxmath/ui/MaxMathApp.kt
index b544007..65480e8 100644
--- a/app/src/main/java/com/paruh/maxmath/ui/MaxMathApp.kt
+++ b/app/src/main/java/com/paruh/maxmath/ui/MaxMathApp.kt
@@ -1,5 +1,9 @@
package com.paruh.maxmath.ui
+import androidx.compose.animation.AnimatedContentTransitionScope.SlideDirection
+import androidx.compose.animation.core.tween
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
import androidx.compose.runtime.Composable
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
@@ -13,32 +17,67 @@ import com.paruh.maxmath.ui.screens.QuadraticFormScreen
import com.paruh.maxmath.ui.screens.SystemScreen
import com.paruh.maxmath.ui.screens.VectorSpaceScreen
+/** 路由常量:首页的模块表和这里的 NavHost 都引用它,避免两处字符串写岔。 */
+object Routes {
+ const val HOME = "home"
+ const val MATRIX = "matrix"
+ const val SYSTEM = "system"
+ const val POLYNOMIAL = "polynomial"
+ const val VECTOR = "vector"
+ const val QUADRATIC = "quadratic"
+ const val CALCULUS = "calculus"
+ const val PLOT = "plot"
+}
+
+private const val NAV_DURATION_MS = 280
+
@Composable
fun MaxMathApp() {
val nav = rememberNavController()
- NavHost(navController = nav, startDestination = "home") {
- composable("home") {
+ NavHost(
+ navController = nav,
+ startDestination = Routes.HOME,
+ // 前进向左推、返回向右推:给「首页 → 模块」这层关系一个方向感,
+ // 原先是默认淡入淡出,看不出层级。
+ enterTransition = {
+ slideIntoContainer(SlideDirection.Start, tween(NAV_DURATION_MS)) +
+ fadeIn(tween(NAV_DURATION_MS))
+ },
+ exitTransition = {
+ slideOutOfContainer(SlideDirection.Start, tween(NAV_DURATION_MS)) +
+ fadeOut(tween(NAV_DURATION_MS))
+ },
+ popEnterTransition = {
+ slideIntoContainer(SlideDirection.End, tween(NAV_DURATION_MS)) +
+ fadeIn(tween(NAV_DURATION_MS))
+ },
+ popExitTransition = {
+ slideOutOfContainer(SlideDirection.End, tween(NAV_DURATION_MS)) +
+ fadeOut(tween(NAV_DURATION_MS))
+ },
+ ) {
+ composable(Routes.HOME) {
HomeScreen(onNavigate = { route -> nav.navigate(route) })
}
- composable("matrix") {
+ composable(Routes.MATRIX) {
MatrixScreen(onBack = { nav.popBackStack() })
}
- composable("system") {
+ composable(Routes.SYSTEM) {
SystemScreen(onBack = { nav.popBackStack() })
}
- composable("polynomial") {
+ composable(Routes.POLYNOMIAL) {
PolynomialScreen(onBack = { nav.popBackStack() })
}
- composable("vector") {
+ composable(Routes.VECTOR) {
VectorSpaceScreen(onBack = { nav.popBackStack() })
}
- composable("quadratic") {
+ composable(Routes.QUADRATIC) {
QuadraticFormScreen(onBack = { nav.popBackStack() })
}
- composable("calculus") {
+ composable(Routes.CALCULUS) {
CalculusScreen(onBack = { nav.popBackStack() })
}
- composable("plot") {
+ composable(Routes.PLOT) {
PlotScreen(onBack = { nav.popBackStack() })
}
}
diff --git a/app/src/main/java/com/paruh/maxmath/ui/ModeIcons.kt b/app/src/main/java/com/paruh/maxmath/ui/ModeIcons.kt
index b64b125..dd79c57 100644
--- a/app/src/main/java/com/paruh/maxmath/ui/ModeIcons.kt
+++ b/app/src/main/java/com/paruh/maxmath/ui/ModeIcons.kt
@@ -85,6 +85,16 @@ object ModeIcons {
),
)
+ /**
+ * 步进器的减号。material-icons-core 只有 Add 没有 Remove(Remove 在
+ * material-icons-extended 里,那个包会给 APK 加几 MB),所以自绘一条。
+ * 尺寸刻意对齐 Material 的 Add 字形:横向 5→19,厚度 11→13。
+ *
+ * **不要放进 [all]** —— ModeIconsTest 断言 all.size == 7,那是首页模块图标集,
+ * 减号不属于它。
+ */
+ val Minus: ImageVector = icon("Minus", paths = listOf("M5,11 H19 V13 H5 Z"))
+
val all: List = listOf(Matrix, Equations, Polynomial, Vector, Quadratic, Calculus, Plot)
private fun icon(
diff --git a/app/src/main/java/com/paruh/maxmath/ui/components/Components.kt b/app/src/main/java/com/paruh/maxmath/ui/components/Components.kt
index 98bfac0..9a309fc 100644
--- a/app/src/main/java/com/paruh/maxmath/ui/components/Components.kt
+++ b/app/src/main/java/com/paruh/maxmath/ui/components/Components.kt
@@ -1,28 +1,41 @@
-@file:OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class)
+@file:OptIn(
+ androidx.compose.material3.ExperimentalMaterial3Api::class,
+ androidx.compose.foundation.layout.ExperimentalLayoutApi::class,
+)
package com.paruh.maxmath.ui.components
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
-import androidx.compose.foundation.background
+import android.os.Build
+import android.widget.Toast
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.foundation.ScrollState
+import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ColumnScope
+import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
-import androidx.compose.material3.Button
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material.icons.filled.Add
+import androidx.compose.material.icons.filled.KeyboardArrowDown
+import androidx.compose.material.icons.filled.KeyboardArrowUp
+import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
-import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
@@ -34,55 +47,96 @@ import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
+import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.alpha
+import androidx.compose.ui.focus.FocusDirection
+import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalFocusManager
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.text.input.KeyboardCapitalization
+import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
-import androidx.compose.ui.unit.dp
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.automirrored.filled.ArrowBack
-import androidx.compose.material.icons.filled.Add
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.foundation.text.KeyboardActions
+import androidx.compose.foundation.text.KeyboardOptions
+import kotlinx.coroutines.delay
import com.paruh.maxmath.R
import com.paruh.maxmath.engine.CalcResponse
import com.paruh.maxmath.ui.CalcUiState
-import androidx.compose.ui.res.stringResource
+import com.paruh.maxmath.ui.ModeIcons
+import com.paruh.maxmath.ui.theme.MathMonoStyle
+import com.paruh.maxmath.ui.theme.Sizing
+import com.paruh.maxmath.ui.theme.Spacing
+
+/**
+ * [ModuleScaffold] 内容区的滚动状态。
+ *
+ * 用 CompositionLocal 而不是改 content 的签名:算完之后要把结果卡片滚进视野,
+ * 而这个需求只有 ResultSection 关心,没必要让八个调用点都接一个参数。
+ */
+val LocalModuleScrollState = compositionLocalOf { null }
@Composable
fun ModuleScaffold(
title: String,
onBack: () -> Unit,
- content: @Composable () -> Unit,
+ content: @Composable ColumnScope.() -> Unit,
) {
+ val scrollState = rememberScrollState()
+ // 用 pinned 而不是 enterAlways:内容区是普通的 verticalScroll Column 而非
+ // 惰性列表,会折叠的顶栏在这种容器上手感会抖。pinned 只在内容滚动时
+ // 给顶栏加一层色调容器色,足够表达层次。
+ val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
Scaffold(
+ modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
TopAppBar(
title = { Text(title) },
navigationIcon = {
IconButton(onClick = onBack) {
- Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
+ Icon(
+ Icons.AutoMirrored.Filled.ArrowBack,
+ contentDescription = stringResource(R.string.back),
+ )
}
},
+ scrollBehavior = scrollBehavior,
)
},
) { padding ->
- Column(
- modifier = Modifier
- .fillMaxSize()
- .padding(padding)
- .verticalScroll(rememberScrollState())
- .padding(16.dp),
- verticalArrangement = Arrangement.spacedBy(12.dp),
- ) {
- content()
+ CompositionLocalProvider(LocalModuleScrollState provides scrollState) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(padding)
+ .verticalScroll(scrollState)
+ .padding(Spacing.l),
+ verticalArrangement = Arrangement.spacedBy(Spacing.m),
+ ) {
+ content()
+ }
}
}
}
+/**
+ * 数学表达式输入。关掉自动纠错与自动大写:输入法会把 `x` 开头的式子首字母
+ * 大写、把 `sin` 纠成别的词,而 Maxima 是大小写敏感的。
+ */
@Composable
fun ExpressionField(
label: String,
@@ -100,22 +154,48 @@ fun ExpressionField(
singleLine = false,
minLines = 1,
maxLines = 4,
+ keyboardOptions = MathKeyboardOptions,
)
}
+/**
+ * @param keyboardType 默认 [KeyboardType.Decimal],只适合纯数值字段(绘图区间、
+ * 方位角/仰角)。极限的趋近点可以是 `inf`、方向是 `plus`/`minus`、积分上下限
+ * 可以是符号表达式——那些调用点必须显式传 [KeyboardType.Text],否则数字键盘
+ * 直接把功能弄坏。
+ */
@Composable
fun NumberField(
label: String,
value: String,
modifier: Modifier = Modifier,
+ keyboardType: KeyboardType = KeyboardType.Decimal,
+ imeAction: ImeAction = ImeAction.Next,
onValueChange: (String) -> Unit,
) {
+ val focus = LocalFocusManager.current
OutlinedTextField(
value = value,
- onValueChange = { onValueChange(it.filter { c -> c.isDigit() || c == '.' || c == '-' || c == '+' || c == '/' || c == '^' || c.isLetter() }) },
+ onValueChange = {
+ onValueChange(
+ it.filter { c ->
+ c.isDigit() || c == '.' || c == '-' || c == '+' || c == '/' || c == '^' || c.isLetter()
+ },
+ )
+ },
label = { Text(label) },
- modifier = modifier.width(140.dp),
+ modifier = modifier.width(Sizing.numberField),
singleLine = true,
+ keyboardOptions = KeyboardOptions(
+ keyboardType = keyboardType,
+ imeAction = imeAction,
+ autoCorrectEnabled = false,
+ capitalization = KeyboardCapitalization.None,
+ ),
+ keyboardActions = KeyboardActions(
+ onNext = { focus.moveFocus(FocusDirection.Next) },
+ onDone = { focus.clearFocus() },
+ ),
)
}
@@ -127,14 +207,28 @@ fun Stepper(
max: Int = 8,
onChange: (Int) -> Unit,
) {
- Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(Spacing.s),
+ ) {
Text(label)
- IconButton(onClick = { onChange((value - 1).coerceAtLeast(min)) }) {
- Text("−")
+ IconButton(
+ onClick = { onChange((value - 1).coerceAtLeast(min)) },
+ enabled = value > min,
+ ) {
+ Icon(ModeIcons.Minus, contentDescription = stringResource(R.string.decrease_value, label))
}
- Text(value.toString(), style = MaterialTheme.typography.titleMedium, textAlign = TextAlign.Center, modifier = Modifier.width(32.dp))
- IconButton(onClick = { onChange((value + 1).coerceAtMost(max)) }) {
- Icon(Icons.Default.Add, contentDescription = "+")
+ Text(
+ value.toString(),
+ style = MaterialTheme.typography.titleMedium,
+ textAlign = TextAlign.Center,
+ modifier = Modifier.width(Sizing.stepperValue),
+ )
+ IconButton(
+ onClick = { onChange((value + 1).coerceAtMost(max)) },
+ enabled = value < max,
+ ) {
+ Icon(Icons.Default.Add, contentDescription = stringResource(R.string.increase_value, label))
}
}
}
@@ -146,16 +240,25 @@ fun MatrixInput(
cells: List>,
onChange: (row: Int, col: Int, value: String) -> Unit,
) {
- Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
+ val focus = LocalFocusManager.current
+ Column(verticalArrangement = Arrangement.spacedBy(Spacing.xs)) {
for (r in 0 until rows) {
- Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
+ Row(horizontalArrangement = Arrangement.spacedBy(Spacing.xs)) {
for (c in 0 until cols) {
OutlinedTextField(
value = cells.getOrNull(r)?.getOrNull(c) ?: "",
onValueChange = { onChange(r, c, it) },
- modifier = Modifier.width(76.dp),
+ modifier = Modifier.width(Sizing.matrixCell),
singleLine = true,
textStyle = MaterialTheme.typography.bodyMedium,
+ // 单元格支持符号表达式(InputValidator 会用 MathInputParser
+ // 逐个解析),所以不能上数字键盘;真正的问题是输入法的
+ // 自动大写/纠错,关掉即可。
+ keyboardOptions = MathKeyboardOptions,
+ keyboardActions = KeyboardActions(
+ // 默认焦点顺序就是布局顺序,逐行从左到右走完整个网格。
+ onNext = { focus.moveFocus(FocusDirection.Next) },
+ ),
)
}
}
@@ -163,13 +266,17 @@ fun MatrixInput(
}
}
+/** 运算选择。用 FlowRow:矩阵模块有 5 个选项,窄屏上 Row 会直接截断。 */
@Composable
fun OpChipRow(
options: List>,
selected: T,
onSelect: (T) -> Unit,
) {
- Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ FlowRow(
+ horizontalArrangement = Arrangement.spacedBy(Spacing.s),
+ verticalArrangement = Arrangement.spacedBy(Spacing.s),
+ ) {
options.forEach { (op, label) ->
FilterChip(
selected = selected == op,
@@ -187,11 +294,15 @@ fun AdvancedSwitch(
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(stringResource(R.string.advanced), style = MaterialTheme.typography.bodySmall)
- Spacer(Modifier.width(8.dp))
+ Spacer(Modifier.width(Spacing.s))
Switch(checked = advanced, onCheckedChange = onAdvancedChange)
}
}
+/**
+ * 结果区。三个分支是**叠加**而不是互斥的:重算期间上一次的结果继续显示
+ * (淡出到 45%),下面同时挂一张进度卡片,界面不会塌陷再展开。
+ */
@Composable
fun ResultCard(
state: CalcUiState,
@@ -199,45 +310,123 @@ fun ResultCard(
onCopyPlain: (String) -> Unit,
modifier: Modifier = Modifier,
) {
- when {
- state.loading -> {
- Card(modifier.fillMaxWidth()) {
- Row(
- modifier = Modifier.padding(16.dp),
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(12.dp),
- ) {
- CircularProgressIndicator(modifier = Modifier.width(24.dp).height(24.dp))
- Text(stringResource(R.string.computing))
+ val alpha by animateFloatAsState(
+ targetValue = if (state.loading) 0.45f else 1f,
+ label = "resultAlpha",
+ )
+ Column(verticalArrangement = Arrangement.spacedBy(Spacing.s)) {
+ if (state.loading) {
+ LoadingCard()
+ }
+ state.error?.let { ErrorCard(it) }
+ state.response?.let {
+ ResultContent(it, onCopyTex, onCopyPlain, modifier.alpha(alpha))
+ }
+ }
+}
+
+@Composable
+private fun LoadingCard() {
+ // 空闲超过 60 秒(EngineService.IDLE_TIMEOUT_MS / MaximaEngine.IDLE_STOP_MS)
+ // 之后的第一次计算要重新加载 ECL 镜像,实测 1–3 秒;超过 2.5 秒基本可以
+ // 断定是冷启动。整棵子树在 loading 结束时销毁,计时器每次计算自然重置,
+ // 热计算永远看不到这句提示。
+ var slow by remember { mutableStateOf(false) }
+ LaunchedEffect(Unit) {
+ delay(2_500)
+ slow = true
+ }
+ Card(Modifier.fillMaxWidth()) {
+ Row(
+ modifier = Modifier.padding(Spacing.l),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(Spacing.m),
+ ) {
+ CircularProgressIndicator(modifier = Modifier.size(Sizing.progress))
+ Column {
+ Text(stringResource(R.string.computing))
+ if (slow) {
+ Text(
+ stringResource(R.string.engine_warming_up),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
}
}
}
- state.error != null -> ErrorCard(state.error)
- state.response != null -> ResultContent(state.response!!, onCopyTex, onCopyPlain, modifier)
}
}
+/**
+ * 引擎错误可能是两千字符的原始 Maxima/native 输出,所以默认折叠成一行摘要。
+ *
+ * 摘要和完整正文必须**二选一**:两者同时渲染会让同一段文本匹配到两个节点,
+ * Compose 测试里的 onNodeWithText(...).assertExists() 会因为「找到 2 个」而失败。
+ */
@Composable
private fun ErrorCard(message: String) {
- var expanded by remember { mutableStateOf(false) }
+ // rememberSaveable:旋转屏幕不该把用户刚展开的长错误又收回去。
+ var expanded by rememberSaveable { mutableStateOf(false) }
+ val context = LocalContext.current
Card(
modifier = Modifier.fillMaxWidth(),
- colors = androidx.compose.material3.CardDefaults.cardColors(
+ colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer,
+ contentColor = MaterialTheme.colorScheme.onErrorContainer,
),
) {
- Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
- Text(stringResource(R.string.error_detail), style = MaterialTheme.typography.titleSmall)
- TextButton(onClick = { expanded = !expanded }) {
- Text(if (expanded) "▲" else "▼")
+ Column(
+ Modifier.padding(Spacing.l),
+ verticalArrangement = Arrangement.spacedBy(Spacing.s),
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable { expanded = !expanded },
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Icon(
+ Icons.Default.Warning,
+ contentDescription = null,
+ modifier = Modifier.size(Sizing.iconSmall),
+ )
+ Spacer(Modifier.width(Spacing.s))
+ Text(
+ stringResource(R.string.error_detail),
+ style = MaterialTheme.typography.titleSmall,
+ modifier = Modifier.weight(1f),
+ )
+ IconButton(
+ onClick = { expanded = !expanded },
+ modifier = Modifier.testTag("error_expand_toggle"),
+ ) {
+ Icon(
+ if (expanded) Icons.Default.KeyboardArrowUp else Icons.Default.KeyboardArrowDown,
+ contentDescription = stringResource(
+ if (expanded) R.string.collapse_details else R.string.expand_details,
+ ),
+ )
+ }
}
if (expanded) {
Text(
message,
- style = MaterialTheme.typography.bodySmall,
+ style = MathMonoStyle,
// heightIn 必须在外层:先限定最大高度,再让内部可滚动,
// 否则父级垂直滚动会给它无限高度约束并抛异常。
- modifier = Modifier.heightIn(max = 200.dp).verticalScroll(rememberScrollState()),
+ modifier = Modifier
+ .heightIn(max = Sizing.errorBodyMax)
+ .verticalScroll(rememberScrollState()),
+ )
+ TextButton(onClick = { copyToClipboard(context, "error", message) }) {
+ Text(stringResource(R.string.copy_error))
+ }
+ } else {
+ Text(
+ message.lineSequence().firstOrNull { it.isNotBlank() }.orEmpty(),
+ style = MaterialTheme.typography.bodySmall,
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis,
)
}
}
@@ -251,23 +440,28 @@ private fun ResultContent(
onCopyPlain: (String) -> Unit,
modifier: Modifier = Modifier,
) {
- val context = LocalContext.current
Card(modifier.fillMaxWidth()) {
- Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
+ Column(
+ Modifier.padding(Spacing.l),
+ verticalArrangement = Arrangement.spacedBy(Spacing.m),
+ ) {
Text(stringResource(R.string.result), style = MaterialTheme.typography.titleMedium)
val result = response.result
val imagePath = result?.imagePath
val tex = result?.tex
val plain = result?.plain
if (imagePath != null) {
- Text("PNG: $imagePath", style = MaterialTheme.typography.bodySmall)
+ // 绘图走 PlotViewModel,不经过这里;这个分支目前不可达,
+ // 但绝不能把磁盘路径直接摆给用户看。
+ Text(stringResource(R.string.result_image_saved), style = MaterialTheme.typography.bodySmall)
} else if (tex != null) {
LatexResult(tex, plain)
} else if (plain != null) {
- Text(plain, style = MaterialTheme.typography.bodyLarge)
+ // Maxima 的纯文本是 ASCII 排版的矩阵,必须等宽才对得齐。
+ Text(plain, style = MathMonoStyle)
}
HorizontalDivider()
- Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ Row(horizontalArrangement = Arrangement.spacedBy(Spacing.s)) {
tex?.let {
TextButton(onClick = { onCopyTex(tex) }) { Text(stringResource(R.string.copy_tex)) }
}
@@ -279,7 +473,19 @@ private fun ResultContent(
}
}
+/** 数学输入通用键盘选项:文本键盘,但关掉自动纠错与自动大写。 */
+private val MathKeyboardOptions = KeyboardOptions(
+ keyboardType = KeyboardType.Text,
+ imeAction = ImeAction.Next,
+ autoCorrectEnabled = false,
+ capitalization = KeyboardCapitalization.None,
+)
+
fun copyToClipboard(context: Context, label: String, text: String) {
val cm = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
cm.setPrimaryClip(ClipData.newPlainText(label, text))
+ // Android 13 起系统自己会弹复制确认,应用再弹一次就是重复。
+ if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) {
+ Toast.makeText(context, R.string.copied, Toast.LENGTH_SHORT).show()
+ }
}
diff --git a/app/src/main/java/com/paruh/maxmath/ui/components/ComputeSection.kt b/app/src/main/java/com/paruh/maxmath/ui/components/ComputeSection.kt
new file mode 100644
index 0000000..9fb159e
--- /dev/null
+++ b/app/src/main/java/com/paruh/maxmath/ui/components/ComputeSection.kt
@@ -0,0 +1,130 @@
+package com.paruh.maxmath.ui.components
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Close
+import androidx.compose.material3.Button
+import androidx.compose.material3.Icon
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.runtime.withFrameNanos
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.res.stringResource
+import com.paruh.maxmath.R
+import com.paruh.maxmath.ui.CalcUiState
+import com.paruh.maxmath.ui.theme.Sizing
+import com.paruh.maxmath.ui.theme.Spacing
+
+/**
+ * 计算 / 取消动作行。
+ *
+ * 计算按钮的标签**恒定不变**,运行中只是置灰——不要改成在「计算」和「计算中…」
+ * 之间切换:进度卡片里已经有「计算中…」,按钮再显示一遍会让同一段文本出现两个
+ * 节点,Compose 测试的 onNodeWithText 会因歧义失败。
+ *
+ * 引擎单次求值上限 120 秒(MaximaEngine.EVAL_TIMEOUT_MS),没有取消按钮时用户
+ * 只能干等,这是这轮改动里价值最高的一处。
+ */
+@Composable
+fun ComputeActionRow(
+ loading: Boolean,
+ onCompute: () -> Unit,
+ onCancel: () -> Unit,
+ modifier: Modifier = Modifier,
+ computeLabel: String = stringResource(R.string.compute),
+) {
+ Row(
+ modifier = modifier,
+ horizontalArrangement = Arrangement.spacedBy(Spacing.s),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Button(onClick = onCompute, enabled = !loading) {
+ Text(computeLabel)
+ }
+ AnimatedVisibility(visible = loading) {
+ OutlinedButton(
+ onClick = onCancel,
+ modifier = Modifier.testTag("cancel_button"),
+ ) {
+ Icon(
+ Icons.Default.Close,
+ contentDescription = null,
+ modifier = Modifier.size(Sizing.iconSmall),
+ )
+ Spacer(Modifier.width(Spacing.xs))
+ Text(stringResource(R.string.cancel))
+ }
+ }
+ }
+}
+
+/**
+ * 结果区:接好剪贴板,并在一次计算落地后把结果滚进视野。
+ *
+ * 结果卡片在每个模块页都是最后一个元素,所以滚到 maxValue 就是滚到结果。
+ */
+@Composable
+fun ResultSection(
+ state: CalcUiState,
+ modifier: Modifier = Modifier,
+ scrollIntoView: Boolean = true,
+) {
+ val context = LocalContext.current
+ val scrollState = LocalModuleScrollState.current
+ // 只在「刚从运行中变成结束」时滚动:首次进入页面、或返回一个已有结果的页面
+ // 都不该抢用户的滚动位置。
+ var wasLoading by remember { mutableStateOf(false) }
+ LaunchedEffect(state.loading) {
+ val justFinished = wasLoading && !state.loading
+ wasLoading = state.loading
+ if (justFinished && scrollIntoView && scrollState != null) {
+ // 等一帧:结果卡片这时才参与布局,maxValue 在此之前还是旧值。
+ withFrameNanos { }
+ scrollState.animateScrollTo(scrollState.maxValue)
+ }
+ }
+ ResultCard(
+ state = state,
+ onCopyTex = { copyToClipboard(context, "tex", it) },
+ onCopyPlain = { copyToClipboard(context, "plain", it) },
+ modifier = modifier,
+ )
+}
+
+/** 六个简单模块页共用的收尾:动作行 + 结果区。 */
+@Composable
+fun ComputeSection(
+ state: CalcUiState,
+ onCompute: () -> Unit,
+ onCancel: () -> Unit,
+ modifier: Modifier = Modifier,
+ computeLabel: String = stringResource(R.string.compute),
+) {
+ Column(
+ modifier = modifier,
+ verticalArrangement = Arrangement.spacedBy(Spacing.m),
+ ) {
+ ComputeActionRow(
+ loading = state.loading,
+ onCompute = onCompute,
+ onCancel = onCancel,
+ computeLabel = computeLabel,
+ )
+ ResultSection(state)
+ }
+}
diff --git a/app/src/main/java/com/paruh/maxmath/ui/components/LatexResult.kt b/app/src/main/java/com/paruh/maxmath/ui/components/LatexResult.kt
index b470edc..83eb3e4 100644
--- a/app/src/main/java/com/paruh/maxmath/ui/components/LatexResult.kt
+++ b/app/src/main/java/com/paruh/maxmath/ui/components/LatexResult.kt
@@ -20,6 +20,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView
import com.paruh.maxmath.R
+import com.paruh.maxmath.ui.theme.MathMonoStyle
import ru.noties.jlatexmath.JLatexMathDrawable
import ru.noties.jlatexmath.JLatexMathView
@@ -84,7 +85,8 @@ fun LatexResult(tex: String, plain: String?, modifier: Modifier = Modifier) {
},
onFailure = { e ->
Log.w("LatexResult", "latex render failed: ${e.message}", e)
- Text(plain ?: tex, style = MaterialTheme.typography.bodyLarge)
+ // 回退的是 Maxima 纯文本(ASCII 排版的矩阵),必须等宽才对得齐。
+ Text(plain ?: tex, style = MathMonoStyle)
Text(
stringResource(R.string.error_latex_render_failed, e.message ?: ""),
style = MaterialTheme.typography.bodySmall,
diff --git a/app/src/main/java/com/paruh/maxmath/ui/plot/GlGestureMath.kt b/app/src/main/java/com/paruh/maxmath/ui/plot/GlGestureMath.kt
index 9311508..6cbd400 100644
--- a/app/src/main/java/com/paruh/maxmath/ui/plot/GlGestureMath.kt
+++ b/app/src/main/java/com/paruh/maxmath/ui/plot/GlGestureMath.kt
@@ -1,7 +1,6 @@
package com.paruh.maxmath.ui.plot
import androidx.compose.ui.geometry.Offset
-import com.paruh.maxmath.ui.plot.gl.GlPlotKind
import com.paruh.maxmath.ui.plot.gl.GlViewState
/**
diff --git a/app/src/main/java/com/paruh/maxmath/ui/plot/Plot2DPainter.kt b/app/src/main/java/com/paruh/maxmath/ui/plot/Plot2DPainter.kt
index 1d63a31..76299c9 100644
--- a/app/src/main/java/com/paruh/maxmath/ui/plot/Plot2DPainter.kt
+++ b/app/src/main/java/com/paruh/maxmath/ui/plot/Plot2DPainter.kt
@@ -14,6 +14,7 @@ import androidx.compose.ui.graphics.nativeCanvas
import com.paruh.maxmath.engine.PlotAnnotations
import com.paruh.maxmath.parser.Evaluator
import com.paruh.maxmath.parser.Expr
+import com.paruh.maxmath.ui.theme.PlotPalette
import kotlin.math.ceil
import kotlin.math.floor
import kotlin.math.log10
@@ -25,6 +26,7 @@ import kotlin.math.pow
*
* 拖动/缩放期间 [draw] 每帧都会被调用,因此画笔、路径、采样缓冲与刻度
* 标签全部复用:这些对象原先都是每帧新建,是手势掉帧的主要来源。
+ * 配色也走同一套复用逻辑(见 [applyPalette]),跟随主题不会带来每帧分配。
*
* 复用状态非线程安全——[draw] 与 [renderToBitmap] 都只在主线程调用。
*/
@@ -32,22 +34,6 @@ object Plot2DPainter {
private const val SAMPLE_COUNT = 800
- private val CURVE_COLORS = listOf(
- Color(0xFF1F77B4),
- Color(0xFFFF7F0E),
- Color(0xFF2CA02C),
- Color(0xFFD62728),
- Color(0xFF9467BD),
- Color(0xFF8C564B),
- Color(0xFFE377C2),
- Color(0xFF7F7F7F),
- Color(0xFFBCBD22),
- Color(0xFF17BECF),
- )
-
- private val ZERO_COLOR = Color(0xFFD62728)
- private val EXTREMA_COLOR = Color(0xFF2CA02C)
-
private val bgPaint = Paint().apply { color = Color.White }
private val gridPaint = Paint().apply { color = Color(0xFFE0E0E0) }
private val axisPaint = Paint().apply { color = Color(0xFF666666) }
@@ -69,6 +55,24 @@ object Plot2DPainter {
private val xTicks = TickCache()
private val yTicks = TickCache()
+ /**
+ * 上一次应用过的调色板。用引用相等做门禁:调色板只在主题切换或导出时变,
+ * 稳态下每帧一次 setter 都不会执行。[PlotPalette.Light]/[PlotPalette.Dark]
+ * 都是单例,引用比较成立。
+ *
+ * 与画笔、路径同属主线程独占的复用状态。
+ */
+ private var appliedPalette: PlotPalette? = null
+
+ private fun applyPalette(palette: PlotPalette) {
+ if (appliedPalette === palette) return
+ bgPaint.color = palette.background
+ gridPaint.color = palette.grid
+ axisPaint.color = palette.axis
+ borderPaint.color = palette.border
+ appliedPalette = palette
+ }
+
fun draw(
canvas: Canvas,
size: Size,
@@ -76,8 +80,10 @@ object Plot2DPainter {
expressions: List,
annotations: PlotAnnotations?,
textSizePx: Float,
+ palette: PlotPalette = PlotPalette.Light,
) {
if (size.width <= 0f || size.height <= 0f) return
+ applyPalette(palette)
val w = range.width
val h = range.height
if (w <= 0.0 || h <= 0.0) return
@@ -108,7 +114,7 @@ object Plot2DPainter {
val maxJump = h * 50.0
val vars = HashMap()
expressions.forEachIndexed { index, expr ->
- curvePaint.color = CURVE_COLORS[index % CURVE_COLORS.size]
+ curvePaint.color = palette.curve(index)
CurveSampler.sampleInto(
samples,
{ x ->
@@ -134,14 +140,14 @@ object Plot2DPainter {
}
annotations?.let { ann ->
- markerPaint.color = ZERO_COLOR
+ markerPaint.color = palette.zero
ann.zeros.forEach { x ->
if (x in range.xMin..range.xMax) {
canvas.drawCircle(Offset(sx(x), sy(0.0)), 4f, markerPaint)
- drawLabel(canvas, "(${fmt(x)}, 0)", sx(x), sy(0.0) - 6f, textSizePx, ZERO_COLOR)
+ drawLabel(canvas, "(${fmt(x)}, 0)", sx(x), sy(0.0) - 6f, textSizePx, palette.zeroArgb)
}
}
- markerPaint.color = EXTREMA_COLOR
+ markerPaint.color = palette.extrema
ann.extrema.forEach { (x, y) ->
if (x in range.xMin..range.xMax && y in range.yMin..range.yMax) {
val cx = sx(x)
@@ -159,16 +165,29 @@ object Plot2DPainter {
cx,
cy - 10f,
textSizePx,
- EXTREMA_COLOR,
+ palette.extremaArgb,
)
}
}
}
- drawTickLabels(canvas, size, range, textSizePx)
+ drawTickLabels(canvas, size, range, textSizePx, palette)
canvas.drawRect(Rect(0f, 0f, size.width, size.height), borderPaint)
}
+ /**
+ * 离屏渲染高清 PNG(保存/分享用)。
+ *
+ * [palette] 默认**恒为浅色**,即使界面处于深色模式:
+ * 1. 导出的文件会进相册、会通过 ACTION_SEND 发到聊天和文档里,深色底板在
+ * 那些场景里看起来像渲染出错;
+ * 2. 另一条 2D 保存路径直接复制引擎用 matplotlib 渲染的白底 PNG,实时路径
+ * 若跟随主题,同一个「保存 PNG」会因为用户有没有拖动过而产出两种观感
+ * 完全不同的文件;
+ * 3. 不透明浅色底打印出来才是对的。
+ *
+ * 3D/等高线的截图路径(PlotGlController.capture)出于同样理由临时切浅色。
+ */
fun renderToBitmap(
width: Int,
height: Int,
@@ -176,10 +195,19 @@ object Plot2DPainter {
expressions: List,
annotations: PlotAnnotations?,
textSizePx: Float = 12f,
+ palette: PlotPalette = PlotPalette.Light,
): Bitmap {
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
val canvas = Canvas(android.graphics.Canvas(bitmap))
- draw(canvas, Size(width.toFloat(), height.toFloat()), range, expressions, annotations, textSizePx)
+ draw(
+ canvas,
+ Size(width.toFloat(), height.toFloat()),
+ range,
+ expressions,
+ annotations,
+ textSizePx,
+ palette,
+ )
return bitmap
}
@@ -235,8 +263,9 @@ object Plot2DPainter {
size: Size,
range: PlotRange,
textSizePx: Float,
+ palette: PlotPalette,
) {
- textPaint.color = android.graphics.Color.rgb(90, 90, 90)
+ textPaint.color = palette.tickLabelArgb
textPaint.textSize = textSizePx
val native = canvas.nativeCanvas
for (i in 0 until xTicks.count) {
@@ -250,20 +279,16 @@ object Plot2DPainter {
}
}
+ /** @param colorArgb 预先算好的 ARGB int:Paint 只吃 int,别在这里现算。 */
private fun drawLabel(
canvas: Canvas,
text: String,
x: Float,
y: Float,
textSizePx: Float,
- color: Color,
+ colorArgb: Int,
) {
- textPaint.color = android.graphics.Color.argb(
- 255,
- (color.red * 255).toInt(),
- (color.green * 255).toInt(),
- (color.blue * 255).toInt(),
- )
+ textPaint.color = colorArgb
textPaint.textSize = textSizePx
canvas.nativeCanvas.drawText(text, x - textPaint.measureText(text) / 2f, y, textPaint)
}
diff --git a/app/src/main/java/com/paruh/maxmath/ui/plot/PlotViewModel.kt b/app/src/main/java/com/paruh/maxmath/ui/plot/PlotViewModel.kt
index 6e35b84..95390a6 100644
--- a/app/src/main/java/com/paruh/maxmath/ui/plot/PlotViewModel.kt
+++ b/app/src/main/java/com/paruh/maxmath/ui/plot/PlotViewModel.kt
@@ -20,6 +20,7 @@ import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import com.paruh.maxmath.R
@@ -67,7 +68,10 @@ class PlotViewModel(
if (task.kind == PlotKind.PLOT_2D) {
MaximaEngine.cancel()
}
- _state.value = PlotUiState(loading = true)
+ // 只改入口状态,不清空图像:重绘期间上一张图继续留在屏幕上。
+ // 完成时 regenerate2d/regenerateGl 仍整体赋值一个新的 PlotUiState,
+ // 这正是 2D↔3D 切换能丢掉另一种模式残留产物的原因,不要改成 copy。
+ _state.update { it.copy(loading = true, error = null) }
job = viewModelScope.launch {
when (task.kind) {
PlotKind.PLOT_2D -> regenerate2d(task)
@@ -76,10 +80,11 @@ class PlotViewModel(
}
}
+ /** 与 CalcViewModel.cancel 一致:撤销请求,不清屏。 */
fun cancel() {
job?.cancel()
MaximaEngine.cancel()
- _state.value = PlotUiState()
+ _state.update { it.copy(loading = false) }
}
private suspend fun regenerate2d(task: PlotTask) {
diff --git a/app/src/main/java/com/paruh/maxmath/ui/plot/gl/PlotGlRenderer.kt b/app/src/main/java/com/paruh/maxmath/ui/plot/gl/PlotGlRenderer.kt
index 9387f90..d2aaee6 100644
--- a/app/src/main/java/com/paruh/maxmath/ui/plot/gl/PlotGlRenderer.kt
+++ b/app/src/main/java/com/paruh/maxmath/ui/plot/gl/PlotGlRenderer.kt
@@ -5,6 +5,7 @@ import android.opengl.GLES20
import android.opengl.GLSurfaceView
import android.util.Log
import com.paruh.maxmath.ui.plot.PlotRange
+import com.paruh.maxmath.ui.theme.GlPalette
import java.nio.ByteBuffer
import java.nio.ByteOrder
import kotlin.math.sqrt
@@ -30,6 +31,23 @@ internal class PlotGlRenderer : GLSurfaceView.Renderer {
@Volatile
var state: GlViewState = GlViewState()
+ /**
+ * 跟随主题的配色。与 [state] 同样是 UI 线程写、GL 线程每帧读:
+ * FloatArray 的**内容**本身不是天然安全发布的,但 volatile 写
+ * [GlPalette] 引用建立了 happens-before 边,而这些数组构造后绝不修改。
+ */
+ @Volatile
+ var palette: GlPalette = GlPalette.Light
+
+ /**
+ * 截图期间的临时覆盖。导出的 PNG 恒为浅色,理由同
+ * [com.paruh.maxmath.ui.plot.Plot2DPainter.renderToBitmap]:否则深色模式下
+ * readPixels 会抓到深色帧缓冲,而 2D 那条路径仍然产出白底,同一个
+ * 「保存 PNG」会给出两种观感完全不同的文件。
+ */
+ @Volatile
+ var paletteOverride: GlPalette? = null
+
private var program = 0
private var axesProgram = 0
private var mesh: GlMesh? = null
@@ -68,7 +86,8 @@ internal class PlotGlRenderer : GLSurfaceView.Renderer {
unused: javax.microedition.khronos.opengles.GL10?,
config: javax.microedition.khronos.egl.EGLConfig?,
) {
- GLES20.glClearColor(1f, 1f, 1f, 1f)
+ // glClearColor 挪到 onDrawFrame:底色要跟随主题,而 onSurfaceCreated
+ // 只在上下文创建时跑一次。
GLES20.glEnable(GLES20.GL_DEPTH_TEST)
GLES20.glEnable(GLES20.GL_BLEND)
GLES20.glBlendFunc(GLES20.GL_SRC_ALPHA, GLES20.GL_ONE_MINUS_SRC_ALPHA)
@@ -106,9 +125,13 @@ internal class PlotGlRenderer : GLSurfaceView.Renderer {
}
override fun onDrawFrame(unused: javax.microedition.khronos.opengles.GL10?) {
+ // 每帧读一次 volatile 并设一次状态,不分配。
+ val p = paletteOverride ?: palette
+ val bg = p.background
+ GLES20.glClearColor(bg[0], bg[1], bg[2], bg[3])
GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT or GLES20.GL_DEPTH_BUFFER_BIT)
drawMesh()
- drawOverlays()
+ drawOverlays(p)
captureCallback?.let { cb ->
captureCallback = null
cb(readPixels())
@@ -209,7 +232,7 @@ internal class PlotGlRenderer : GLSurfaceView.Renderer {
GLES20.glBindBuffer(GLES20.GL_ELEMENT_ARRAY_BUFFER, 0)
}
- private fun drawOverlays() {
+ private fun drawOverlays(palette: GlPalette) {
if (axesProgram == 0) return
val kind = state.kind
val rotY = Mat4.rotationY(Math.toRadians(state.azimuthDeg.toDouble()).toFloat())
@@ -247,9 +270,9 @@ internal class PlotGlRenderer : GLSurfaceView.Renderer {
mvpLoc, 1, false,
Mat4.identity().set(proj).multiply(view).multiply(model).m, 0,
)
- drawLines(posLoc, colorUniform[0], axisXBuf, 2, AXIS_X_COLOR)
- drawLines(posLoc, colorUniform[0], axisYBuf, 2, AXIS_Y_COLOR)
- drawLines(posLoc, colorUniform[0], axisZBuf, 2, AXIS_Z_COLOR)
+ drawLines(posLoc, colorUniform[0], axisXBuf, 2, palette.axisX)
+ drawLines(posLoc, colorUniform[0], axisYBuf, 2, palette.axisY)
+ drawLines(posLoc, colorUniform[0], axisZBuf, 2, palette.axisZ)
} else {
val currentRange = range ?: return
// 热力图与等值线/边框都在 z=0:开启深度测试时后画的线会被
@@ -261,14 +284,14 @@ internal class PlotGlRenderer : GLSurfaceView.Renderer {
mvpLoc, 1, false,
Mat4.identity().set(proj).multiply(view).multiply(contourFrameModel(state)).m, 0,
)
- drawLines(posLoc, colorUniform[0], frameBuf, 8, FRAME_COLOR)
+ drawLines(posLoc, colorUniform[0], frameBuf, 8, palette.frame)
if (contourBuf != 0 && contourSize > 0) {
GLES20.glUniformMatrix4fv(
mvpLoc, 1, false,
Mat4.identity().set(proj).multiply(view)
.multiply(contourLineModel(state, currentRange)).m, 0,
)
- drawLines(posLoc, colorUniform[0], contourBuf, contourSize, CONTOUR_COLOR)
+ drawLines(posLoc, colorUniform[0], contourBuf, contourSize, palette.contour)
}
GLES20.glEnable(GLES20.GL_DEPTH_TEST)
}
@@ -380,14 +403,12 @@ internal class PlotGlRenderer : GLSurfaceView.Renderer {
companion object {
- /** 光照方向与各种线条颜色都是常量,不必每帧重新构造数组。 */
+ /**
+ * 光照方向是常量,不必每帧重新构造数组。
+ * 线条颜色已挪进 [GlPalette](同样是构造一次的常量数组)。
+ */
private val LIGHT_DIR = normalize(floatArrayOf(0.4f, 0.7f, 0.8f))
private val IDENTITY_MAT3 = floatArrayOf(1f, 0f, 0f, 0f, 1f, 0f, 0f, 0f, 1f)
- private val AXIS_X_COLOR = floatArrayOf(0.75f, 0.25f, 0.25f, 1f)
- private val AXIS_Y_COLOR = floatArrayOf(0.25f, 0.65f, 0.25f, 1f)
- private val AXIS_Z_COLOR = floatArrayOf(0.25f, 0.35f, 0.85f, 1f)
- private val FRAME_COLOR = floatArrayOf(0.35f, 0.35f, 0.35f, 1f)
- private val CONTOUR_COLOR = floatArrayOf(0.12f, 0.12f, 0.12f, 1f)
private fun normalize(v: FloatArray): FloatArray {
val len = sqrt(v[0] * v[0] + v[1] * v[1] + v[2] * v[2])
diff --git a/app/src/main/java/com/paruh/maxmath/ui/plot/gl/PlotGlView.kt b/app/src/main/java/com/paruh/maxmath/ui/plot/gl/PlotGlView.kt
index f997c59..604d12e 100644
--- a/app/src/main/java/com/paruh/maxmath/ui/plot/gl/PlotGlView.kt
+++ b/app/src/main/java/com/paruh/maxmath/ui/plot/gl/PlotGlView.kt
@@ -1,7 +1,6 @@
package com.paruh.maxmath.ui.plot.gl
import android.graphics.Bitmap
-import android.opengl.GLES20
import android.opengl.GLSurfaceView
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
@@ -12,6 +11,7 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.viewinterop.AndroidView
import com.paruh.maxmath.R
import com.paruh.maxmath.ui.plot.PlotRange
+import com.paruh.maxmath.ui.theme.GlPalette
import javax.microedition.khronos.egl.EGL10
import javax.microedition.khronos.egl.EGLConfig
import javax.microedition.khronos.egl.EGLDisplay
@@ -62,11 +62,26 @@ class PlotGlController {
requestRender()
}
- /** 异步截取当前 GL 帧(回调在 GL 线程)。 */
+ fun setPalette(palette: GlPalette) {
+ renderer.palette = palette
+ requestRender()
+ }
+
+ /**
+ * 异步截取当前 GL 帧(回调在 GL 线程)。
+ *
+ * 截图前临时切成浅色配色,回调后立即还原并重绘:导出的 PNG 与 2D 那条
+ * 路径保持一致,都是浅色底;屏幕上的深色视图在下一帧就恢复。
+ */
fun capture(onBitmap: (Bitmap) -> Unit) {
val glView = view ?: return
glView.queueEvent {
- renderer.requestCapture(onBitmap)
+ renderer.paletteOverride = GlPalette.Light
+ renderer.requestCapture { bitmap ->
+ renderer.paletteOverride = null
+ onBitmap(bitmap)
+ glView.requestRender()
+ }
glView.requestRender()
}
}
@@ -77,13 +92,22 @@ class PlotGlController {
}
@Composable
-fun PlotGlSurface(controller: PlotGlController, modifier: Modifier = Modifier) {
+fun PlotGlSurface(
+ controller: PlotGlController,
+ modifier: Modifier = Modifier,
+ palette: GlPalette = GlPalette.Light,
+) {
val context = LocalContext.current
val view = remember { GLSurfaceView(context).apply { controller.attach(this) } }
LaunchedEffect(view) {
view.onResume()
controller.refresh()
}
+ // 首次组合时也会触发:这个 Surface 只在 glMesh 就绪后才进入组合树,
+ // 所以配色必须在这里推一次,不能只靠后续的主题切换。
+ LaunchedEffect(palette) {
+ controller.setPalette(palette)
+ }
DisposableEffect(view) {
onDispose { view.onPause() }
}
diff --git a/app/src/main/java/com/paruh/maxmath/ui/screens/CalculusScreen.kt b/app/src/main/java/com/paruh/maxmath/ui/screens/CalculusScreen.kt
index 1cad9e3..10cda06 100644
--- a/app/src/main/java/com/paruh/maxmath/ui/screens/CalculusScreen.kt
+++ b/app/src/main/java/com/paruh/maxmath/ui/screens/CalculusScreen.kt
@@ -2,9 +2,7 @@ package com.paruh.maxmath.ui.screens
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.width
-import androidx.compose.material3.Button
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -14,26 +12,25 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
-import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.unit.dp
+import androidx.compose.ui.text.input.KeyboardType
import androidx.lifecycle.viewmodel.compose.viewModel
import com.paruh.maxmath.R
import com.paruh.maxmath.ui.CalcViewModel
import com.paruh.maxmath.ui.components.AdvancedSwitch
+import com.paruh.maxmath.ui.components.ComputeSection
import com.paruh.maxmath.ui.components.ExpressionField
import com.paruh.maxmath.ui.components.ModuleScaffold
import com.paruh.maxmath.ui.components.NumberField
import com.paruh.maxmath.ui.components.OpChipRow
-import com.paruh.maxmath.ui.components.ResultCard
-import com.paruh.maxmath.ui.components.copyToClipboard
+import com.paruh.maxmath.ui.theme.Sizing
+import com.paruh.maxmath.ui.theme.Spacing
import com.paruh.maxmath.engine.CalculusKind
import com.paruh.maxmath.engine.CalculusTask
@Composable
fun CalculusScreen(onBack: () -> Unit) {
val vm: CalcViewModel = viewModel()
- val context = LocalContext.current
val state by vm.state.collectAsState()
var expression by rememberSaveable { mutableStateOf("sin(x)/x") }
var variable by rememberSaveable { mutableStateOf("x") }
@@ -56,22 +53,41 @@ fun CalculusScreen(onBack: () -> Unit) {
value = variable,
onValueChange = { variable = it },
label = { Text(stringResource(R.string.variable)) },
- modifier = Modifier.width(140.dp),
+ modifier = Modifier.width(Sizing.numberField),
singleLine = true,
)
if (op == CalculusKind.LIMIT) {
- Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
- NumberField(stringResource(R.string.point), point) { point = it }
- NumberField(stringResource(R.string.direction), direction) { direction = it }
+ // 趋近点可以是 inf / minf,方向是 plus / minus——都不是数字,
+ // 上数字键盘会直接把这两个字段变成废的。
+ Row(horizontalArrangement = Arrangement.spacedBy(Spacing.s)) {
+ NumberField(
+ stringResource(R.string.point),
+ point,
+ keyboardType = KeyboardType.Text,
+ ) { point = it }
+ NumberField(
+ stringResource(R.string.direction),
+ direction,
+ keyboardType = KeyboardType.Text,
+ ) { direction = it }
}
}
if (op == CalculusKind.DIFF) {
NumberField(stringResource(R.string.order), order) { order = it }
}
if (op == CalculusKind.INTEGRATE) {
- Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
- NumberField(stringResource(R.string.lower), lower) { lower = it }
- NumberField(stringResource(R.string.upper), upper) { upper = it }
+ // 积分上下限允许符号表达式(%pi、inf 等),同理走文本键盘。
+ Row(horizontalArrangement = Arrangement.spacedBy(Spacing.s)) {
+ NumberField(
+ stringResource(R.string.lower),
+ lower,
+ keyboardType = KeyboardType.Text,
+ ) { lower = it }
+ NumberField(
+ stringResource(R.string.upper),
+ upper,
+ keyboardType = KeyboardType.Text,
+ ) { upper = it }
}
}
AdvancedSwitch(advanced) { advanced = it }
@@ -84,31 +100,28 @@ fun CalculusScreen(onBack: () -> Unit) {
),
selected = op,
) { op = it }
- Button(onClick = {
- try {
- vm.compute(
- CalculusTask(
- kind = op,
- expression = expression,
- variable = variable.trim(),
- point = point.trim(),
- direction = direction.trim().ifBlank { null },
- order = order.toIntOrNull() ?: 1,
- lower = lower.trim().ifBlank { null },
- upper = upper.trim().ifBlank { null },
- raw = advanced,
- )
- )
- } catch (e: Exception) {
- vm.showError(e.message)
- }
- }) {
- Text(stringResource(R.string.compute))
- }
- ResultCard(
+ ComputeSection(
state = state,
- onCopyTex = { copyToClipboard(context, "tex", it) },
- onCopyPlain = { copyToClipboard(context, "plain", it) },
+ onCompute = {
+ try {
+ vm.compute(
+ CalculusTask(
+ kind = op,
+ expression = expression,
+ variable = variable.trim(),
+ point = point.trim(),
+ direction = direction.trim().ifBlank { null },
+ order = order.toIntOrNull() ?: 1,
+ lower = lower.trim().ifBlank { null },
+ upper = upper.trim().ifBlank { null },
+ raw = advanced,
+ )
+ )
+ } catch (e: Exception) {
+ vm.showError(e.message)
+ }
+ },
+ onCancel = vm::cancel,
)
}
}
diff --git a/app/src/main/java/com/paruh/maxmath/ui/screens/HomeScreen.kt b/app/src/main/java/com/paruh/maxmath/ui/screens/HomeScreen.kt
index 99dbfcb..67628c8 100644
--- a/app/src/main/java/com/paruh/maxmath/ui/screens/HomeScreen.kt
+++ b/app/src/main/java/com/paruh/maxmath/ui/screens/HomeScreen.kt
@@ -6,12 +6,14 @@ import android.app.Activity
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
@@ -34,11 +36,13 @@ import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.unit.dp
import com.paruh.maxmath.R
import com.paruh.maxmath.ui.AppLanguage
import com.paruh.maxmath.ui.AppLocale
import com.paruh.maxmath.ui.ModeIcons
+import com.paruh.maxmath.ui.Routes
+import com.paruh.maxmath.ui.theme.Sizing
+import com.paruh.maxmath.ui.theme.Spacing
private data class ModuleEntry(
val route: String,
@@ -48,30 +52,35 @@ private data class ModuleEntry(
)
private val modules = listOf(
- ModuleEntry("matrix", ModeIcons.Matrix, R.string.module_matrix, R.string.module_matrix_desc),
- ModuleEntry("system", ModeIcons.Equations, R.string.module_system, R.string.module_system_desc),
- ModuleEntry("polynomial", ModeIcons.Polynomial, R.string.module_polynomial, R.string.module_polynomial_desc),
- ModuleEntry("vector", ModeIcons.Vector, R.string.module_vector, R.string.module_vector_desc),
- ModuleEntry("quadratic", ModeIcons.Quadratic, R.string.module_quadratic, R.string.module_quadratic_desc),
- ModuleEntry("calculus", ModeIcons.Calculus, R.string.module_calculus, R.string.module_calculus_desc),
- ModuleEntry("plot", ModeIcons.Plot, R.string.module_plot, R.string.module_plot_desc),
+ ModuleEntry(Routes.MATRIX, ModeIcons.Matrix, R.string.module_matrix, R.string.module_matrix_desc),
+ ModuleEntry(Routes.SYSTEM, ModeIcons.Equations, R.string.module_system, R.string.module_system_desc),
+ ModuleEntry(Routes.POLYNOMIAL, ModeIcons.Polynomial, R.string.module_polynomial, R.string.module_polynomial_desc),
+ ModuleEntry(Routes.VECTOR, ModeIcons.Vector, R.string.module_vector, R.string.module_vector_desc),
+ ModuleEntry(Routes.QUADRATIC, ModeIcons.Quadratic, R.string.module_quadratic, R.string.module_quadratic_desc),
+ ModuleEntry(Routes.CALCULUS, ModeIcons.Calculus, R.string.module_calculus, R.string.module_calculus_desc),
+ ModuleEntry(Routes.PLOT, ModeIcons.Plot, R.string.module_plot, R.string.module_plot_desc),
)
@Composable
fun HomeScreen(onNavigate: (String) -> Unit) {
Scaffold { padding ->
LazyColumn(
- modifier = Modifier
- .fillMaxSize()
- .padding(padding)
- .padding(16.dp),
- verticalArrangement = Arrangement.spacedBy(12.dp),
+ modifier = Modifier.fillMaxSize(),
+ // contentPadding 而不是 Modifier.padding:卡片这样能滚到系统栏
+ // 下面去,而不是提前一截停住。
+ contentPadding = PaddingValues(
+ start = Spacing.l,
+ end = Spacing.l,
+ top = padding.calculateTopPadding() + Spacing.l,
+ bottom = padding.calculateBottomPadding() + Spacing.l,
+ ),
+ verticalArrangement = Arrangement.spacedBy(Spacing.m),
) {
item {
Row(
modifier = Modifier
.fillMaxWidth()
- .padding(bottom = 8.dp),
+ .padding(bottom = Spacing.s),
verticalAlignment = Alignment.CenterVertically,
) {
Column(Modifier.weight(1f)) {
@@ -88,16 +97,18 @@ fun HomeScreen(onNavigate: (String) -> Unit) {
items(modules) { module ->
Card(onClick = { onNavigate(module.route) }, modifier = Modifier.fillMaxWidth()) {
Row(
- modifier = Modifier.padding(16.dp),
+ modifier = Modifier.padding(Spacing.l),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
module.icon,
- contentDescription = stringResource(module.title),
- modifier = Modifier.width(40.dp).height(40.dp),
- tint = MaterialTheme.colorScheme.onSurface,
+ // 装饰性:旁边的标题文本已经念过一遍模块名,
+ // 这里再给 description 会让读屏重复播报。
+ contentDescription = null,
+ modifier = Modifier.size(Sizing.moduleIcon),
+ tint = MaterialTheme.colorScheme.primary,
)
- Spacer(Modifier.width(16.dp))
+ Spacer(Modifier.width(Spacing.l))
Column {
Text(stringResource(module.title), style = MaterialTheme.typography.titleMedium)
Text(
@@ -109,7 +120,7 @@ fun HomeScreen(onNavigate: (String) -> Unit) {
}
}
}
- item { Spacer(Modifier.height(24.dp)) }
+ item { Spacer(Modifier.height(Spacing.xl)) }
}
}
}
diff --git a/app/src/main/java/com/paruh/maxmath/ui/screens/MatrixScreen.kt b/app/src/main/java/com/paruh/maxmath/ui/screens/MatrixScreen.kt
index 40b38c6..2136fae 100644
--- a/app/src/main/java/com/paruh/maxmath/ui/screens/MatrixScreen.kt
+++ b/app/src/main/java/com/paruh/maxmath/ui/screens/MatrixScreen.kt
@@ -1,37 +1,34 @@
package com.paruh.maxmath.ui.screens
import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.width
-import androidx.compose.material3.Button
-import androidx.compose.material3.OutlinedTextField
-import androidx.compose.material3.Text
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.paruh.maxmath.R
import com.paruh.maxmath.ui.CalcViewModel
import com.paruh.maxmath.ui.InputValidator
+import com.paruh.maxmath.ui.MatrixCellsSaver
import com.paruh.maxmath.ui.updateCell
import com.paruh.maxmath.ui.components.AdvancedSwitch
+import com.paruh.maxmath.ui.components.ComputeSection
import com.paruh.maxmath.ui.components.MatrixInput
import com.paruh.maxmath.ui.components.ModuleScaffold
import com.paruh.maxmath.ui.components.OpChipRow
-import com.paruh.maxmath.ui.components.ResultCard
import com.paruh.maxmath.ui.components.Stepper
-import com.paruh.maxmath.ui.components.copyToClipboard
+import com.paruh.maxmath.ui.theme.Spacing
import com.paruh.maxmath.engine.MatrixInput
import com.paruh.maxmath.engine.MatrixKind
import com.paruh.maxmath.engine.MatrixTask
@@ -46,16 +43,21 @@ fun MatrixScreen(onBack: () -> Unit) {
var op by rememberSaveable { mutableStateOf(MatrixKind.DET) }
var advanced by rememberSaveable { mutableStateOf(false) }
var rawText by rememberSaveable { mutableStateOf("") }
- var cells by remember { mutableStateOf(List(2) { List(2) { "" } }) }
+ var cells by rememberSaveable(stateSaver = MatrixCellsSaver) {
+ mutableStateOf(List(2) { List(2) { "" } })
+ }
LaunchedEffect(rows, cols) {
- cells = List(rows) { r ->
- List(cols) { c -> cells.getOrNull(r)?.getOrNull(c) ?: "" }
+ // 只在行列数真的变了时重建,避免旋转后的重组把恢复出来的数据又洗一遍。
+ if (cells.size != rows || cells.firstOrNull()?.size != cols) {
+ cells = List(rows) { r ->
+ List(cols) { c -> cells.getOrNull(r)?.getOrNull(c) ?: "" }
+ }
}
}
ModuleScaffold(title = stringResource(R.string.module_matrix), onBack = onBack) {
- Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
+ Row(horizontalArrangement = Arrangement.spacedBy(Spacing.l)) {
Stepper(stringResource(R.string.rows), rows) { rows = it }
Stepper(stringResource(R.string.cols), cols) { cols = it }
}
@@ -70,7 +72,10 @@ fun MatrixScreen(onBack: () -> Unit) {
minLines = 2,
)
} else {
- Text(stringResource(R.string.matrix_payload), style = androidx.compose.material3.MaterialTheme.typography.bodySmall)
+ Text(
+ stringResource(R.string.matrix_payload),
+ style = MaterialTheme.typography.bodySmall,
+ )
MatrixInput(rows, cols, cells) { r, c, v ->
cells = updateCell(cells, r, c, v)
}
@@ -89,26 +94,22 @@ fun MatrixScreen(onBack: () -> Unit) {
selected = op,
) { op = it }
- Button(onClick = {
- try {
- InputValidator.requireParsable(context, cells, advanced)
- val matrix = if (advanced && rawText.isNotBlank()) {
- MatrixInput(rawText = rawText)
- } else {
- MatrixInput(cells = cells)
- }
- vm.compute(MatrixTask(kind = op, matrix = matrix, raw = advanced))
- } catch (e: Exception) {
- vm.showError(e.message)
- }
- }, enabled = !state.loading) {
- Text(if (state.loading) stringResource(R.string.computing) else stringResource(R.string.compute))
- }
-
- ResultCard(
+ ComputeSection(
state = state,
- onCopyTex = { copyToClipboard(context, "tex", it) },
- onCopyPlain = { copyToClipboard(context, "plain", it) },
+ onCompute = {
+ try {
+ InputValidator.requireParsable(context, cells, advanced)
+ val matrix = if (advanced && rawText.isNotBlank()) {
+ MatrixInput(rawText = rawText)
+ } else {
+ MatrixInput(cells = cells)
+ }
+ vm.compute(MatrixTask(kind = op, matrix = matrix, raw = advanced))
+ } catch (e: Exception) {
+ vm.showError(e.message)
+ }
+ },
+ onCancel = vm::cancel,
)
}
}
diff --git a/app/src/main/java/com/paruh/maxmath/ui/screens/PlotScreen.kt b/app/src/main/java/com/paruh/maxmath/ui/screens/PlotScreen.kt
index bb767fd..fd53f14 100644
--- a/app/src/main/java/com/paruh/maxmath/ui/screens/PlotScreen.kt
+++ b/app/src/main/java/com/paruh/maxmath/ui/screens/PlotScreen.kt
@@ -4,7 +4,6 @@ import android.content.ContentValues
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.BitmapFactory
-import android.net.Uri
import android.os.Build
import android.os.Handler
import android.os.Looper
@@ -19,13 +18,11 @@ import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.gestures.detectTransformGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
-import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch
@@ -44,16 +41,15 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
-import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.FileProvider
import androidx.lifecycle.viewmodel.compose.viewModel
@@ -63,10 +59,14 @@ import com.paruh.maxmath.R
import com.paruh.maxmath.engine.PlotKind
import com.paruh.maxmath.engine.PlotTask
import com.paruh.maxmath.ui.CalcUiState
+import com.paruh.maxmath.ui.components.ComputeActionRow
import com.paruh.maxmath.ui.components.ModuleScaffold
import com.paruh.maxmath.ui.components.NumberField
import com.paruh.maxmath.ui.components.OpChipRow
import com.paruh.maxmath.ui.components.ResultCard
+import com.paruh.maxmath.ui.theme.LocalPlotPalette
+import com.paruh.maxmath.ui.theme.Sizing
+import com.paruh.maxmath.ui.theme.Spacing
import com.paruh.maxmath.ui.plot.EnginePlotEngine
import com.paruh.maxmath.ui.plot.GlGestureMath
import com.paruh.maxmath.ui.plot.Plot2DPainter
@@ -95,6 +95,7 @@ fun PlotScreen(onBack: () -> Unit, viewModelOverride: PlotViewModel? = null) {
},
)
val state by vm.state.collectAsState()
+ val plotPalette = LocalPlotPalette.current
var mode by rememberSaveable { mutableStateOf(PlotKind.PLOT_2D) }
var functions by rememberSaveable { mutableStateOf("x^2\nsin(x)") }
@@ -247,16 +248,18 @@ fun PlotScreen(onBack: () -> Unit, viewModelOverride: PlotViewModel? = null) {
)
}
- Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ // 这六个字段是全应用仅有的纯数值输入(parseRange 用 toDoubleOrNull),
+ // 因此保留 NumberField 默认的 Decimal 键盘。
+ Row(horizontalArrangement = Arrangement.spacedBy(Spacing.s)) {
NumberField(stringResource(R.string.xmin), xMin) { xMin = it }
NumberField(stringResource(R.string.xmax), xMax) { xMax = it }
}
- Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ Row(horizontalArrangement = Arrangement.spacedBy(Spacing.s)) {
NumberField(stringResource(R.string.ymin), yMin) { yMin = it }
NumberField(stringResource(R.string.ymax), yMax) { yMax = it }
}
if (mode == PlotKind.PLOT_3D) {
- Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ Row(horizontalArrangement = Arrangement.spacedBy(Spacing.s)) {
NumberField(stringResource(R.string.azimuth), azimuth) { azimuth = it }
NumberField(stringResource(R.string.elevation), elevation) { elevation = it }
}
@@ -268,9 +271,14 @@ fun PlotScreen(onBack: () -> Unit, viewModelOverride: PlotViewModel? = null) {
}
}
- Button(onClick = { regenerate() }, enabled = !state.loading) {
- Text(stringResource(R.string.regenerate))
- }
+ ComputeActionRow(
+ loading = state.loading,
+ onCompute = { regenerate() },
+ onCancel = vm::cancel,
+ computeLabel = stringResource(R.string.regenerate),
+ )
+ // 刻意保持原样传 CalcUiState(loading, error):response 恒为 null,
+ // 绘图页复用的就是这张标准进度/错误卡片,PlotScreenStateTest 盯着这点。
ResultCard(
state = CalcUiState(loading = state.loading, error = state.error),
onCopyTex = {},
@@ -292,7 +300,7 @@ fun PlotScreen(onBack: () -> Unit, viewModelOverride: PlotViewModel? = null) {
Box(
modifier = Modifier
.fillMaxWidth()
- .aspectRatio(3f / 2f)
+ .aspectRatio(Sizing.PLOT_ASPECT)
.clip(MaterialTheme.shapes.medium)
.background(MaterialTheme.colorScheme.surfaceVariant)
.onSizeChanged { size ->
@@ -360,7 +368,14 @@ fun PlotScreen(onBack: () -> Unit, viewModelOverride: PlotViewModel? = null) {
null
}
if (vp != null) {
- Canvas(modifier = Modifier.fillMaxSize()) {
+ Canvas(
+ modifier = Modifier
+ .fillMaxSize()
+ // 重绘期间把旧图淡下去。只对 2D 分支这么做:
+ // 给承载 GLSurfaceView 的 AndroidView 加 alpha 会强制
+ // 离屏图层,而那块 surface 是在窗口上挖洞的,会出现瑕疵。
+ .alpha(if (state.loading) 0.45f else 1f),
+ ) {
Plot2DPainter.draw(
canvas = this.drawContext.canvas,
size = size,
@@ -368,6 +383,7 @@ fun PlotScreen(onBack: () -> Unit, viewModelOverride: PlotViewModel? = null) {
expressions = exprs,
annotations = ann,
textSizePx = with(density) { 10.sp.toPx() },
+ palette = plotPalette,
)
}
}
@@ -375,9 +391,10 @@ fun PlotScreen(onBack: () -> Unit, viewModelOverride: PlotViewModel? = null) {
mode == PlotKind.PLOT_2D && bitmap != null -> {
Image(
bitmap = bitmap.asImageBitmap(),
- contentDescription = "plot",
+ contentDescription = stringResource(R.string.plot_preview),
modifier = Modifier
.fillMaxSize()
+ .alpha(if (state.loading) 0.45f else 1f)
.graphicsLayer {
scaleX = scale
scaleY = scale
@@ -387,7 +404,11 @@ fun PlotScreen(onBack: () -> Unit, viewModelOverride: PlotViewModel? = null) {
)
}
mode != PlotKind.PLOT_2D && state.glMesh != null -> {
- PlotGlSurface(controller = glController, modifier = Modifier.fillMaxSize())
+ PlotGlSurface(
+ controller = glController,
+ modifier = Modifier.fillMaxSize(),
+ palette = plotPalette.gl,
+ )
}
else -> {
Text(
@@ -400,17 +421,18 @@ fun PlotScreen(onBack: () -> Unit, viewModelOverride: PlotViewModel? = null) {
touchCoord?.let { coord ->
Text(
coord,
- color = Color.White,
+ color = MaterialTheme.colorScheme.inverseOnSurface,
fontSize = 14.sp,
modifier = Modifier
.align(Alignment.TopStart)
- .padding(8.dp)
- .background(Color.Black.copy(alpha = 0.6f))
- .padding(4.dp),
+ .padding(Spacing.s)
+ .clip(MaterialTheme.shapes.extraSmall)
+ .background(MaterialTheme.colorScheme.inverseSurface.copy(alpha = 0.85f))
+ .padding(Spacing.xs),
)
}
}
- Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ Row(horizontalArrangement = Arrangement.spacedBy(Spacing.s)) {
TextButton(onClick = { saveCurrent() }) {
Text(stringResource(R.string.save_png))
}
diff --git a/app/src/main/java/com/paruh/maxmath/ui/screens/PolynomialScreen.kt b/app/src/main/java/com/paruh/maxmath/ui/screens/PolynomialScreen.kt
index da1ffb4..f3a0a39 100644
--- a/app/src/main/java/com/paruh/maxmath/ui/screens/PolynomialScreen.kt
+++ b/app/src/main/java/com/paruh/maxmath/ui/screens/PolynomialScreen.kt
@@ -1,10 +1,6 @@
package com.paruh.maxmath.ui.screens
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.width
-import androidx.compose.material3.Button
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -14,18 +10,16 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
-import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.paruh.maxmath.R
import com.paruh.maxmath.ui.CalcViewModel
import com.paruh.maxmath.ui.components.AdvancedSwitch
+import com.paruh.maxmath.ui.components.ComputeSection
import com.paruh.maxmath.ui.components.ExpressionField
import com.paruh.maxmath.ui.components.ModuleScaffold
import com.paruh.maxmath.ui.components.OpChipRow
-import com.paruh.maxmath.ui.components.ResultCard
-import com.paruh.maxmath.ui.components.copyToClipboard
+import com.paruh.maxmath.ui.theme.Sizing
import com.paruh.maxmath.engine.ExpandTask
import com.paruh.maxmath.engine.PolynomialKind
import com.paruh.maxmath.engine.PolynomialTask
@@ -42,7 +36,6 @@ private enum class PolynomialOp {
@Composable
fun PolynomialScreen(onBack: () -> Unit) {
val vm: CalcViewModel = viewModel()
- val context = LocalContext.current
val state by vm.state.collectAsState()
var expr1 by rememberSaveable { mutableStateOf("x^4-1") }
var expr2 by rememberSaveable { mutableStateOf("x^2-1") }
@@ -69,7 +62,7 @@ fun PolynomialScreen(onBack: () -> Unit) {
value = variable,
onValueChange = { variable = it },
label = { Text(stringResource(R.string.variable)) },
- modifier = Modifier.width(140.dp),
+ modifier = Modifier.width(Sizing.numberField),
singleLine = true,
)
}
@@ -84,40 +77,37 @@ fun PolynomialScreen(onBack: () -> Unit) {
),
selected = op,
) { op = it }
- Button(onClick = {
- try {
- val task = when (op) {
- PolynomialOp.FACTOR -> PolynomialTask(
- kind = PolynomialKind.FACTOR,
- expression = expr1,
- raw = advanced,
- )
- PolynomialOp.GCD -> PolynomialTask(
- kind = PolynomialKind.GCD,
- expression = expr1,
- expr2 = expr2,
- raw = advanced,
- )
- PolynomialOp.ROOTS -> PolynomialTask(
- kind = PolynomialKind.ROOTS,
- expression = expr1,
- variable = variable.trim(),
- raw = advanced,
- )
- PolynomialOp.EXPAND -> ExpandTask(expression = expr1, raw = advanced)
- PolynomialOp.SIMPLIFY -> SimplifyTask(expression = expr1, raw = advanced)
- }
- vm.compute(task)
- } catch (e: Exception) {
- vm.showError(e.message)
- }
- }) {
- Text(stringResource(R.string.compute))
- }
- ResultCard(
+ ComputeSection(
state = state,
- onCopyTex = { copyToClipboard(context, "tex", it) },
- onCopyPlain = { copyToClipboard(context, "plain", it) },
+ onCompute = {
+ try {
+ val task = when (op) {
+ PolynomialOp.FACTOR -> PolynomialTask(
+ kind = PolynomialKind.FACTOR,
+ expression = expr1,
+ raw = advanced,
+ )
+ PolynomialOp.GCD -> PolynomialTask(
+ kind = PolynomialKind.GCD,
+ expression = expr1,
+ expr2 = expr2,
+ raw = advanced,
+ )
+ PolynomialOp.ROOTS -> PolynomialTask(
+ kind = PolynomialKind.ROOTS,
+ expression = expr1,
+ variable = variable.trim(),
+ raw = advanced,
+ )
+ PolynomialOp.EXPAND -> ExpandTask(expression = expr1, raw = advanced)
+ PolynomialOp.SIMPLIFY -> SimplifyTask(expression = expr1, raw = advanced)
+ }
+ vm.compute(task)
+ } catch (e: Exception) {
+ vm.showError(e.message)
+ }
+ },
+ onCancel = vm::cancel,
)
}
}
diff --git a/app/src/main/java/com/paruh/maxmath/ui/screens/QuadraticFormScreen.kt b/app/src/main/java/com/paruh/maxmath/ui/screens/QuadraticFormScreen.kt
index 48b05b9..602d57f 100644
--- a/app/src/main/java/com/paruh/maxmath/ui/screens/QuadraticFormScreen.kt
+++ b/app/src/main/java/com/paruh/maxmath/ui/screens/QuadraticFormScreen.kt
@@ -1,9 +1,7 @@
package com.paruh.maxmath.ui.screens
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.material3.Button
+import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -11,25 +9,23 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.paruh.maxmath.R
import com.paruh.maxmath.ui.CalcViewModel
import com.paruh.maxmath.ui.InputValidator
+import com.paruh.maxmath.ui.MatrixCellsSaver
import com.paruh.maxmath.ui.updateCell
import com.paruh.maxmath.ui.components.AdvancedSwitch
+import com.paruh.maxmath.ui.components.ComputeSection
import com.paruh.maxmath.ui.components.MatrixInput
import com.paruh.maxmath.ui.components.ModuleScaffold
import com.paruh.maxmath.ui.components.OpChipRow
-import com.paruh.maxmath.ui.components.ResultCard
import com.paruh.maxmath.ui.components.Stepper
-import com.paruh.maxmath.ui.components.copyToClipboard
import com.paruh.maxmath.engine.MatrixInput
import com.paruh.maxmath.engine.MatrixKind
import com.paruh.maxmath.engine.MatrixTask
@@ -43,19 +39,27 @@ fun QuadraticFormScreen(onBack: () -> Unit) {
var op by rememberSaveable { mutableStateOf(MatrixKind.QUAD_EXPAND) }
var advanced by rememberSaveable { mutableStateOf(false) }
var variables by rememberSaveable { mutableStateOf("x1,x2") }
- var cells by remember { mutableStateOf(List(2) { List(2) { "" } }) }
+ var cells by rememberSaveable(stateSaver = MatrixCellsSaver) {
+ mutableStateOf(List(2) { r -> List(2) { c -> if (r == c) "1" else "" } })
+ }
LaunchedEffect(n) {
- cells = List(n) { r ->
- List(n) { c ->
- if (r == c) "1" else cells.getOrNull(r)?.getOrNull(c) ?: ""
+ // 只在维度真的变了时重建,否则旋转后的重组会白白跑一遍。
+ if (cells.size != n || cells.firstOrNull()?.size != n) {
+ // 已有的值优先于对角线默认的 "1":否则用户把对角元改成别的数、
+ // 一旋转屏幕就被打回 1。
+ cells = List(n) { r ->
+ List(n) { c -> cells.getOrNull(r)?.getOrNull(c) ?: if (r == c) "1" else "" }
}
}
}
ModuleScaffold(title = stringResource(R.string.module_quadratic), onBack = onBack) {
Stepper(stringResource(R.string.dims), n, min = 2, max = 4) { n = it }
- Text("A = (aᵢⱼ)", style = androidx.compose.material3.MaterialTheme.typography.bodySmall)
+ Text(
+ stringResource(R.string.quad_matrix_label),
+ style = MaterialTheme.typography.bodySmall,
+ )
MatrixInput(n, n, cells) { r, c, v ->
cells = updateCell(cells, r, c, v)
}
@@ -74,29 +78,26 @@ fun QuadraticFormScreen(onBack: () -> Unit) {
),
selected = op,
) { op = it }
- Button(onClick = {
- try {
- InputValidator.requireParsable(context, cells, advanced)
- val vars = variables.split(',').map { it.trim() }.filter { it.isNotBlank() }
- .ifEmpty { (1..n).map { "x$it" } }
- vm.compute(
- MatrixTask(
- kind = op,
- matrix = MatrixInput(cells = cells),
- variables = vars,
- raw = advanced,
- )
- )
- } catch (e: Exception) {
- vm.showError(e.message)
- }
- }) {
- Text(stringResource(R.string.compute))
- }
- ResultCard(
+ ComputeSection(
state = state,
- onCopyTex = { copyToClipboard(context, "tex", it) },
- onCopyPlain = { copyToClipboard(context, "plain", it) },
+ onCompute = {
+ try {
+ InputValidator.requireParsable(context, cells, advanced)
+ val vars = variables.split(',').map { it.trim() }.filter { it.isNotBlank() }
+ .ifEmpty { (1..n).map { "x$it" } }
+ vm.compute(
+ MatrixTask(
+ kind = op,
+ matrix = MatrixInput(cells = cells),
+ variables = vars,
+ raw = advanced,
+ )
+ )
+ } catch (e: Exception) {
+ vm.showError(e.message)
+ }
+ },
+ onCancel = vm::cancel,
)
}
}
diff --git a/app/src/main/java/com/paruh/maxmath/ui/screens/SystemScreen.kt b/app/src/main/java/com/paruh/maxmath/ui/screens/SystemScreen.kt
index 598b55b..158ce64 100644
--- a/app/src/main/java/com/paruh/maxmath/ui/screens/SystemScreen.kt
+++ b/app/src/main/java/com/paruh/maxmath/ui/screens/SystemScreen.kt
@@ -1,8 +1,6 @@
package com.paruh.maxmath.ui.screens
-import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.material3.Button
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -12,22 +10,19 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
-import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.lifecycle.viewmodel.compose.viewModel
import com.paruh.maxmath.R
import com.paruh.maxmath.ui.CalcViewModel
import com.paruh.maxmath.ui.components.AdvancedSwitch
+import com.paruh.maxmath.ui.components.ComputeSection
import com.paruh.maxmath.ui.components.ExpressionField
import com.paruh.maxmath.ui.components.ModuleScaffold
-import com.paruh.maxmath.ui.components.ResultCard
-import com.paruh.maxmath.ui.components.copyToClipboard
import com.paruh.maxmath.engine.SystemTask
@Composable
fun SystemScreen(onBack: () -> Unit) {
val vm: CalcViewModel = viewModel()
- val context = LocalContext.current
val state by vm.state.collectAsState()
var equations by rememberSaveable { mutableStateOf("x+y=1; 2x-y=3") }
var variables by rememberSaveable { mutableStateOf("") }
@@ -48,21 +43,18 @@ fun SystemScreen(onBack: () -> Unit) {
singleLine = true,
)
AdvancedSwitch(advanced) { advanced = it }
- Button(onClick = {
- vm.compute(
- SystemTask(
- equations = equations.trim(),
- variables = variables.split(',').map { it.trim() }.filter { it.isNotBlank() },
- raw = advanced,
- )
- )
- }) {
- Text(stringResource(R.string.compute))
- }
- ResultCard(
+ ComputeSection(
state = state,
- onCopyTex = { copyToClipboard(context, "tex", it) },
- onCopyPlain = { copyToClipboard(context, "plain", it) },
+ onCompute = {
+ vm.compute(
+ SystemTask(
+ equations = equations.trim(),
+ variables = variables.split(',').map { it.trim() }.filter { it.isNotBlank() },
+ raw = advanced,
+ )
+ )
+ },
+ onCancel = vm::cancel,
)
}
}
diff --git a/app/src/main/java/com/paruh/maxmath/ui/screens/VectorSpaceScreen.kt b/app/src/main/java/com/paruh/maxmath/ui/screens/VectorSpaceScreen.kt
index a60de4c..6cd7656 100644
--- a/app/src/main/java/com/paruh/maxmath/ui/screens/VectorSpaceScreen.kt
+++ b/app/src/main/java/com/paruh/maxmath/ui/screens/VectorSpaceScreen.kt
@@ -2,31 +2,30 @@ package com.paruh.maxmath.ui.screens
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
-import androidx.compose.material3.Button
+import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.paruh.maxmath.R
import com.paruh.maxmath.ui.CalcViewModel
import com.paruh.maxmath.ui.InputValidator
+import com.paruh.maxmath.ui.MatrixCellsSaver
import com.paruh.maxmath.ui.updateCell
import com.paruh.maxmath.ui.components.AdvancedSwitch
+import com.paruh.maxmath.ui.components.ComputeSection
import com.paruh.maxmath.ui.components.MatrixInput
import com.paruh.maxmath.ui.components.ModuleScaffold
import com.paruh.maxmath.ui.components.OpChipRow
-import com.paruh.maxmath.ui.components.ResultCard
import com.paruh.maxmath.ui.components.Stepper
-import com.paruh.maxmath.ui.components.copyToClipboard
+import com.paruh.maxmath.ui.theme.Spacing
import com.paruh.maxmath.engine.VectorKind
import com.paruh.maxmath.engine.VectorTask
@@ -39,20 +38,28 @@ fun VectorSpaceScreen(onBack: () -> Unit) {
var count by rememberSaveable { mutableStateOf(2) }
var op by rememberSaveable { mutableStateOf(VectorKind.INNER) }
var advanced by rememberSaveable { mutableStateOf(false) }
- var cells by remember { mutableStateOf(List(2) { List(2) { "" } }) }
+ var cells by rememberSaveable(stateSaver = MatrixCellsSaver) {
+ mutableStateOf(List(2) { List(2) { "" } })
+ }
LaunchedEffect(dims, count) {
- cells = List(count) { r ->
- List(dims) { c -> cells.getOrNull(r)?.getOrNull(c) ?: "" }
+ // 只在维度/个数真的变了时重建,旋转恢复出来的数据不会被覆盖。
+ if (cells.size != count || cells.firstOrNull()?.size != dims) {
+ cells = List(count) { r ->
+ List(dims) { c -> cells.getOrNull(r)?.getOrNull(c) ?: "" }
+ }
}
}
ModuleScaffold(title = stringResource(R.string.module_vector), onBack = onBack) {
- Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
+ Row(horizontalArrangement = Arrangement.spacedBy(Spacing.l)) {
Stepper(stringResource(R.string.dims), dims) { dims = it }
Stepper(stringResource(R.string.vector_count), count) { count = it }
}
- Text("u₁ … uₙ", style = androidx.compose.material3.MaterialTheme.typography.bodySmall)
+ Text(
+ stringResource(R.string.vectors_label),
+ style = MaterialTheme.typography.bodySmall,
+ )
MatrixInput(count, dims, cells) { r, c, v ->
cells = updateCell(cells, r, c, v)
}
@@ -65,28 +72,25 @@ fun VectorSpaceScreen(onBack: () -> Unit) {
),
selected = op,
) { op = it }
- Button(onClick = {
- try {
- InputValidator.requireParsable(context, cells, advanced)
- val task = when (op) {
- VectorKind.INNER -> {
- require(cells.size >= 2) { context.getString(R.string.error_vector_need_two) }
- VectorTask(kind = op, vectors = cells, raw = advanced)
+ ComputeSection(
+ state = state,
+ onCompute = {
+ try {
+ InputValidator.requireParsable(context, cells, advanced)
+ val task = when (op) {
+ VectorKind.INNER -> {
+ require(cells.size >= 2) { context.getString(R.string.error_vector_need_two) }
+ VectorTask(kind = op, vectors = cells, raw = advanced)
+ }
+ VectorKind.NORM -> VectorTask(kind = op, vectors = listOf(cells[0]), raw = advanced)
+ VectorKind.GRAMSCHMIDT -> VectorTask(kind = op, vectors = cells, raw = advanced)
}
- VectorKind.NORM -> VectorTask(kind = op, vectors = listOf(cells[0]), raw = advanced)
- VectorKind.GRAMSCHMIDT -> VectorTask(kind = op, vectors = cells, raw = advanced)
+ vm.compute(task)
+ } catch (e: Exception) {
+ vm.showError(e.message)
}
- vm.compute(task)
- } catch (e: Exception) {
- vm.showError(e.message)
- }
- }) {
- Text(stringResource(R.string.compute))
- }
- ResultCard(
- state = state,
- onCopyTex = { copyToClipboard(context, "tex", it) },
- onCopyPlain = { copyToClipboard(context, "plain", it) },
+ },
+ onCancel = vm::cancel,
)
}
}
diff --git a/app/src/main/java/com/paruh/maxmath/ui/theme/Color.kt b/app/src/main/java/com/paruh/maxmath/ui/theme/Color.kt
new file mode 100644
index 0000000..c99e650
--- /dev/null
+++ b/app/src/main/java/com/paruh/maxmath/ui/theme/Color.kt
@@ -0,0 +1,109 @@
+package com.paruh.maxmath.ui.theme
+
+import androidx.compose.material3.darkColorScheme
+import androidx.compose.material3.lightColorScheme
+import androidx.compose.ui.graphics.Color
+
+/**
+ * 完整的 Material 3 配色。
+ *
+ * 三个种子色(primary/secondary/tertiary)沿用原来的取值:#1B5E8A 已经落在
+ * 明度 40 附近、#8FCBFF 落在明度 80 附近,本就符合 M3 浅色 40 / 深色 80 的惯例。
+ * 容器色与中性色围绕它们推导,中性色阶朝蓝色 primary 略微偏冷。
+ *
+ * 所有文字配对都通过了 WCAG AA 4.5:1:浅色下最紧的是 onSecondary/secondary
+ * (4.86),onSurface/surface 为 16.27(浅)/ 14.40(深)。outline/surface
+ * 为 4.25 / 5.88,满足非文字元素 3.0 的要求。
+ *
+ * 改这里的任何一个值都要重新算对比度——ErrorCard 直接用 errorContainer 打底,
+ * 而引擎错误正是本应用最常出现的文本。
+ */
+internal val MaxMathLightColors = lightColorScheme(
+ primary = Color(0xFF1B5E8A),
+ onPrimary = Color(0xFFFFFFFF),
+ primaryContainer = Color(0xFFC9E6FF),
+ onPrimaryContainer = Color(0xFF001E31),
+ inversePrimary = Color(0xFF8FCBFF),
+
+ secondary = Color(0xFF4A7C59),
+ onSecondary = Color(0xFFFFFFFF),
+ secondaryContainer = Color(0xFFCCEBD4),
+ onSecondaryContainer = Color(0xFF072015),
+
+ tertiary = Color(0xFF8A5A1B),
+ onTertiary = Color(0xFFFFFFFF),
+ tertiaryContainer = Color(0xFFFFDDB6),
+ onTertiaryContainer = Color(0xFF2D1600),
+
+ error = Color(0xFFBA1A1A),
+ onError = Color(0xFFFFFFFF),
+ errorContainer = Color(0xFFFFDAD6),
+ onErrorContainer = Color(0xFF410002),
+
+ background = Color(0xFFF8F9FC),
+ onBackground = Color(0xFF191C1E),
+ surface = Color(0xFFF8F9FC),
+ onSurface = Color(0xFF191C1E),
+ surfaceVariant = Color(0xFFDDE3EA),
+ onSurfaceVariant = Color(0xFF41484D),
+ surfaceTint = Color(0xFF1B5E8A),
+ inverseSurface = Color(0xFF2E3133),
+ inverseOnSurface = Color(0xFFEFF1F3),
+
+ outline = Color(0xFF71787E),
+ outlineVariant = Color(0xFFC1C7CE),
+ scrim = Color(0xFF000000),
+
+ surfaceBright = Color(0xFFF8F9FC),
+ surfaceDim = Color(0xFFD8DBDF),
+ surfaceContainerLowest = Color(0xFFFFFFFF),
+ surfaceContainerLow = Color(0xFFF2F4F7),
+ surfaceContainer = Color(0xFFECEFF3),
+ surfaceContainerHigh = Color(0xFFE6E9ED),
+ surfaceContainerHighest = Color(0xFFE0E4E8),
+)
+
+internal val MaxMathDarkColors = darkColorScheme(
+ primary = Color(0xFF8FCBFF),
+ onPrimary = Color(0xFF003353),
+ primaryContainer = Color(0xFF054A76),
+ onPrimaryContainer = Color(0xFFC9E6FF),
+ inversePrimary = Color(0xFF1B5E8A),
+
+ secondary = Color(0xFFA5D6A7),
+ onSecondary = Color(0xFF0D3A21),
+ secondaryContainer = Color(0xFF275235),
+ onSecondaryContainer = Color(0xFFCCEBD4),
+
+ tertiary = Color(0xFFFFCC80),
+ onTertiary = Color(0xFF4A2800),
+ tertiaryContainer = Color(0xFF6A3D00),
+ onTertiaryContainer = Color(0xFFFFDDB6),
+
+ error = Color(0xFFFFB4AB),
+ onError = Color(0xFF690005),
+ errorContainer = Color(0xFF93000A),
+ onErrorContainer = Color(0xFFFFDAD6),
+
+ background = Color(0xFF101416),
+ onBackground = Color(0xFFE1E3E5),
+ surface = Color(0xFF101416),
+ onSurface = Color(0xFFE1E3E5),
+ surfaceVariant = Color(0xFF41484D),
+ onSurfaceVariant = Color(0xFFC1C7CE),
+ surfaceTint = Color(0xFF8FCBFF),
+ inverseSurface = Color(0xFFE1E3E5),
+ inverseOnSurface = Color(0xFF2E3133),
+
+ outline = Color(0xFF8B9298),
+ outlineVariant = Color(0xFF41484D),
+ scrim = Color(0xFF000000),
+
+ surfaceBright = Color(0xFF363A3C),
+ surfaceDim = Color(0xFF101416),
+ surfaceContainerLowest = Color(0xFF0B0F11),
+ surfaceContainerLow = Color(0xFF191C1E),
+ surfaceContainer = Color(0xFF1D2022),
+ surfaceContainerHigh = Color(0xFF272A2D),
+ surfaceContainerHighest = Color(0xFF323538),
+)
diff --git a/app/src/main/java/com/paruh/maxmath/ui/theme/Dimens.kt b/app/src/main/java/com/paruh/maxmath/ui/theme/Dimens.kt
new file mode 100644
index 0000000..cad2f8c
--- /dev/null
+++ b/app/src/main/java/com/paruh/maxmath/ui/theme/Dimens.kt
@@ -0,0 +1,36 @@
+package com.paruh.maxmath.ui.theme
+
+import androidx.compose.ui.unit.dp
+
+/**
+ * 间距与尺寸标度。
+ *
+ * 刻意用普通对象而不是 CompositionLocal:全应用只有一套标度,而且普通常量
+ * 在非 Composable 代码里也能取(Plot2DPainter 之类)。将来真要做紧凑变体,
+ * 再提升成 CompositionLocal 是机械改动。
+ */
+object Spacing {
+ val xxs = 2.dp
+ val xs = 4.dp
+ val s = 8.dp
+ val m = 12.dp
+ val l = 16.dp
+ val xl = 24.dp
+ val xxl = 32.dp
+}
+
+object Sizing {
+ val iconSmall = 18.dp
+ val icon = 24.dp
+ val progress = 24.dp
+ val moduleIcon = 40.dp
+ val matrixCell = 76.dp
+ val numberField = 140.dp
+ val stepperValue = 32.dp
+
+ /** 错误详情展开后的最大高度,超出部分内部滚动。 */
+ val errorBodyMax = 200.dp
+
+ /** 绘图区宽高比,与引擎输出的 720x480 一致。 */
+ const val PLOT_ASPECT = 3f / 2f
+}
diff --git a/app/src/main/java/com/paruh/maxmath/ui/theme/PlotColors.kt b/app/src/main/java/com/paruh/maxmath/ui/theme/PlotColors.kt
new file mode 100644
index 0000000..6a7d3b2
--- /dev/null
+++ b/app/src/main/java/com/paruh/maxmath/ui/theme/PlotColors.kt
@@ -0,0 +1,132 @@
+package com.paruh.maxmath.ui.theme
+
+import androidx.compose.runtime.Immutable
+import androidx.compose.runtime.staticCompositionLocalOf
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.toArgb
+
+/**
+ * 绘图配色的唯一来源:Compose Canvas([com.paruh.maxmath.ui.plot.Plot2DPainter])
+ * 与 OpenGL 渲染器([com.paruh.maxmath.ui.plot.gl.PlotGlRenderer])共用。
+ *
+ * 曲线色板刻意沿用 matplotlib 的 tab10 原值:实时画布画的是引擎用 matplotlib
+ * 渲染成 PNG 的同一批函数,两边配色一旦分叉,同一个函数在实时视图和导出图里
+ * 就是两张不同的图。深色下的对比度让位于这个一致性。
+ */
+@Immutable
+class PlotPalette(
+ val background: Color,
+ val grid: Color,
+ val axis: Color,
+ val border: Color,
+ val tickLabel: Color,
+ val zero: Color,
+ val extrema: Color,
+ val curves: List,
+ val gl: GlPalette,
+) {
+ // 预先转成 ARGB int:android.graphics.Paint 只吃 int,
+ // 原实现每画一个标注标签都要现算一次 Color.argb(...)。
+ val tickLabelArgb: Int = tickLabel.toArgb()
+ val zeroArgb: Int = zero.toArgb()
+ val extremaArgb: Int = extrema.toArgb()
+
+ fun curve(index: Int): Color = curves[index % curves.size]
+
+ companion object {
+ val Light = PlotPalette(
+ background = Color.White,
+ grid = Color(0xFFE0E0E0),
+ axis = Color(0xFF666666),
+ border = Color(0xFF999999),
+ tickLabel = Color(0xFF5A5A5A),
+ zero = Color(0xFFD62728),
+ extrema = Color(0xFF2CA02C),
+ curves = TAB10,
+ gl = GlPalette.Light,
+ )
+
+ val Dark = PlotPalette(
+ background = Color(0xFF131719),
+ grid = Color(0xFF2E3438),
+ axis = Color(0xFF8B9298),
+ border = Color(0xFF4E565B),
+ tickLabel = Color(0xFFB7BDC3),
+ zero = Color(0xFFFF7A7A),
+ extrema = Color(0xFF6FD16F),
+ curves = TAB10_DARK,
+ gl = GlPalette.Dark,
+ )
+ }
+}
+
+/** matplotlib tab10,与引擎导出的 PNG 完全一致。 */
+private val TAB10 = listOf(
+ Color(0xFF1F77B4),
+ Color(0xFFFF7F0E),
+ Color(0xFF2CA02C),
+ Color(0xFFD62728),
+ Color(0xFF9467BD),
+ Color(0xFF8C564B),
+ Color(0xFFE377C2),
+ Color(0xFF7F7F7F),
+ Color(0xFFBCBD22),
+ Color(0xFF17BECF),
+)
+
+/** tab10 提亮版,在 #131719 底色上每一条都不低于 6.3:1。 */
+private val TAB10_DARK = listOf(
+ Color(0xFF6FB8E8),
+ Color(0xFFFFA85C),
+ Color(0xFF6FD16F),
+ Color(0xFFFF7A7A),
+ Color(0xFFC39BE0),
+ Color(0xFFC48C80),
+ Color(0xFFF2A8DA),
+ Color(0xFFB0B0B0),
+ Color(0xFFDCDD5E),
+ Color(0xFF5FDCEA),
+)
+
+/**
+ * GL 侧配色:rgba FloatArray,可直接喂给 glUniform4fv / glClearColor。
+ *
+ * 构造后绝不修改。渲染器通过 @Volatile 引用发布,volatile 写建立的
+ * happens-before 边覆盖了数组内容——与 GlViewState 的发布方式同理。
+ */
+@Immutable
+class GlPalette(
+ val background: FloatArray,
+ val axisX: FloatArray,
+ val axisY: FloatArray,
+ val axisZ: FloatArray,
+ val frame: FloatArray,
+ val contour: FloatArray,
+) {
+ companion object {
+ val Light = GlPalette(
+ background = floatArrayOf(1f, 1f, 1f, 1f),
+ axisX = floatArrayOf(0.75f, 0.25f, 0.25f, 1f),
+ axisY = floatArrayOf(0.25f, 0.65f, 0.25f, 1f),
+ axisZ = floatArrayOf(0.25f, 0.35f, 0.85f, 1f),
+ frame = floatArrayOf(0.35f, 0.35f, 0.35f, 1f),
+ contour = floatArrayOf(0.12f, 0.12f, 0.12f, 1f),
+ )
+
+ val Dark = GlPalette(
+ // #131719,与 PlotPalette.Dark.background 一致。
+ background = floatArrayOf(0.075f, 0.090f, 0.098f, 1f),
+ axisX = floatArrayOf(1.0f, 0.48f, 0.48f, 1f),
+ axisY = floatArrayOf(0.44f, 0.82f, 0.44f, 1f),
+ axisZ = floatArrayOf(0.50f, 0.62f, 1.0f, 1f),
+ frame = floatArrayOf(0.55f, 0.58f, 0.60f, 1f),
+ contour = floatArrayOf(0.88f, 0.90f, 0.92f, 1f),
+ )
+ }
+}
+
+/**
+ * 默认浅色而不是跟随系统:非 Composable 的导出路径(renderToBitmap)也读这个
+ * 默认值,导出的 PNG 恒为浅色。见 Plot2DPainter.renderToBitmap 的说明。
+ */
+val LocalPlotPalette = staticCompositionLocalOf { PlotPalette.Light }
diff --git a/app/src/main/java/com/paruh/maxmath/ui/theme/Shape.kt b/app/src/main/java/com/paruh/maxmath/ui/theme/Shape.kt
new file mode 100644
index 0000000..c7532a9
--- /dev/null
+++ b/app/src/main/java/com/paruh/maxmath/ui/theme/Shape.kt
@@ -0,0 +1,17 @@
+package com.paruh.maxmath.ui.theme
+
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Shapes
+import androidx.compose.ui.unit.dp
+
+/**
+ * medium 同时承担两处:M3 Card 的默认圆角,以及 PlotScreen 里绘图框的
+ * `.clip(MaterialTheme.shapes.medium)`。12→16dp 把卡片和图框一起调柔。
+ */
+val MaxMathShapes = Shapes(
+ extraSmall = RoundedCornerShape(4.dp),
+ small = RoundedCornerShape(8.dp),
+ medium = RoundedCornerShape(16.dp),
+ large = RoundedCornerShape(20.dp),
+ extraLarge = RoundedCornerShape(28.dp),
+)
diff --git a/app/src/main/java/com/paruh/maxmath/ui/theme/Theme.kt b/app/src/main/java/com/paruh/maxmath/ui/theme/Theme.kt
index 3e4437f..eae02ea 100644
--- a/app/src/main/java/com/paruh/maxmath/ui/theme/Theme.kt
+++ b/app/src/main/java/com/paruh/maxmath/ui/theme/Theme.kt
@@ -2,27 +2,29 @@ package com.paruh.maxmath.ui.theme
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.darkColorScheme
-import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
-import androidx.compose.ui.graphics.Color
-
-private val LightColors = lightColorScheme(
- primary = Color(0xFF1B5E8A),
- secondary = Color(0xFF4A7C59),
- tertiary = Color(0xFF8A5A1B),
-)
-
-private val DarkColors = darkColorScheme(
- primary = Color(0xFF8FCBFF),
- secondary = Color(0xFFA5D6A7),
- tertiary = Color(0xFFFFCC80),
-)
+import androidx.compose.runtime.CompositionLocalProvider
+/**
+ * @param darkTheme 显式传入可以让 @Preview 和测试强制某一套配色,
+ * 不必伪造系统配置。默认跟随系统。
+ *
+ * 不接 dynamicColor(Material You):绘图曲线与 LaTeX 公式的可读性依赖
+ * 可预测的对比度,跟随壁纸取色没法保证。
+ */
@Composable
-fun MaxMathTheme(content: @Composable () -> Unit) {
- MaterialTheme(
- colorScheme = if (isSystemInDarkTheme()) DarkColors else LightColors,
- content = content,
- )
+fun MaxMathTheme(
+ darkTheme: Boolean = isSystemInDarkTheme(),
+ content: @Composable () -> Unit,
+) {
+ CompositionLocalProvider(
+ LocalPlotPalette provides if (darkTheme) PlotPalette.Dark else PlotPalette.Light,
+ ) {
+ MaterialTheme(
+ colorScheme = if (darkTheme) MaxMathDarkColors else MaxMathLightColors,
+ typography = MaxMathTypography,
+ shapes = MaxMathShapes,
+ content = content,
+ )
+ }
}
diff --git a/app/src/main/java/com/paruh/maxmath/ui/theme/Type.kt b/app/src/main/java/com/paruh/maxmath/ui/theme/Type.kt
new file mode 100644
index 0000000..d47dd5f
--- /dev/null
+++ b/app/src/main/java/com/paruh/maxmath/ui/theme/Type.kt
@@ -0,0 +1,35 @@
+package com.paruh.maxmath.ui.theme
+
+import androidx.compose.material3.Typography
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.sp
+
+private val default = Typography()
+
+/**
+ * 保留 M3 默认字号阶梯,只加重四个角色的字重。
+ * 不引入自定义字体:应用要离线可用,塞字体会白白增大 APK。
+ */
+val MaxMathTypography = Typography(
+ headlineLarge = default.headlineLarge.copy(
+ fontWeight = FontWeight.SemiBold,
+ letterSpacing = (-0.25).sp,
+ ),
+ titleMedium = default.titleMedium.copy(fontWeight = FontWeight.SemiBold),
+ titleSmall = default.titleSmall.copy(fontWeight = FontWeight.SemiBold),
+ labelLarge = default.labelLarge.copy(fontWeight = FontWeight.Medium),
+)
+
+/**
+ * Maxima 纯文本输出与原始引擎错误专用。
+ *
+ * Maxima 的 string() 输出是 ASCII 排版的矩阵,比例字体下列对不齐、根本没法读;
+ * 等宽字体是让这些内容可读的唯一办法。LaTeX 渲染失败时的回退文本同理。
+ */
+val MathMonoStyle = TextStyle(
+ fontFamily = FontFamily.Monospace,
+ fontSize = 14.sp,
+ lineHeight = 20.sp,
+)
diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml
index 15486d3..0cc9f57 100644
--- a/app/src/main/res/values-en/strings.xml
+++ b/app/src/main/res/values-en/strings.xml
@@ -28,6 +28,13 @@
Copy text
Copied
Error details
+ Show details
+ Hide details
+ Copy error
+ Starting the engine, this may take a few seconds
+ Image generated
+ Decrease %1$s
+ Increase %1$s
Advanced mode (raw Maxima syntax)
Rows
@@ -72,6 +79,8 @@
Vector v
Vectors
Variables (default x1,x2,…)
+ u₁ … uₙ
+ A = (aᵢⱼ)
2D
3D
@@ -89,6 +98,7 @@
Save PNG
Saved
Tap image for coordinates
+ Function plot
Engine not ready: %1$s
diff --git a/app/src/main/res/values-night/colors.xml b/app/src/main/res/values-night/colors.xml
new file mode 100644
index 0000000..6522551
--- /dev/null
+++ b/app/src/main/res/values-night/colors.xml
@@ -0,0 +1,5 @@
+
+
+
+ #101416
+
diff --git a/app/src/main/res/values-night/themes.xml b/app/src/main/res/values-night/themes.xml
new file mode 100644
index 0000000..94cea67
--- /dev/null
+++ b/app/src/main/res/values-night/themes.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml
index cdde88f..078e982 100644
--- a/app/src/main/res/values/colors.xml
+++ b/app/src/main/res/values/colors.xml
@@ -1,4 +1,9 @@
#000000
+
+ #F8F9FC
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index e35a73e..b2f7588 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -29,6 +29,13 @@
复制文本
已复制
错误详情
+ 展开详情
+ 收起详情
+ 复制错误信息
+ 首次计算需启动引擎,请稍候
+ 图像已生成
+ 减少%1$s
+ 增加%1$s
高级模式(直接输入 Maxima 语法)
行
@@ -73,6 +80,8 @@
向量 v
向量组
变量(默认 x1,x2,…)
+ u₁ … uₙ
+ A = (aᵢⱼ)
2D
3D
@@ -90,6 +99,7 @@
保存 PNG
已保存
点击图像查看坐标
+ 函数图像
引擎未就绪:%1$s
diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml
index 1dc887a..7732496 100644
--- a/app/src/main/res/values/themes.xml
+++ b/app/src/main/res/values/themes.xml
@@ -1,6 +1,14 @@
+
diff --git a/app/src/test/kotlin/com/paruh/maxmath/ui/CalcViewModelTest.kt b/app/src/test/kotlin/com/paruh/maxmath/ui/CalcViewModelTest.kt
index ccfe2ad..3014ef9 100644
--- a/app/src/test/kotlin/com/paruh/maxmath/ui/CalcViewModelTest.kt
+++ b/app/src/test/kotlin/com/paruh/maxmath/ui/CalcViewModelTest.kt
@@ -2,17 +2,52 @@ package com.paruh.maxmath.ui
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.paruh.maxmath.engine.CalcRequest
+import com.paruh.maxmath.engine.CalcResponse
+import com.paruh.maxmath.engine.CalcResult
+import com.paruh.maxmath.engine.EngineClient
+import com.paruh.maxmath.engine.SimplifyTask
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.StandardTestDispatcher
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.advanceUntilIdle
+import kotlinx.coroutines.test.resetMain
+import kotlinx.coroutines.test.runTest
+import kotlinx.coroutines.test.setMain
+import org.junit.After
import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue
+import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
+@OptIn(ExperimentalCoroutinesApi::class)
@RunWith(AndroidJUnit4::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class CalcViewModelTest {
+ // viewModelScope 跑在 Dispatchers.Main 上,必须换成测试调度器,
+ // 否则 advanceUntilIdle 推不动它。
+ private val scheduler = StandardTestDispatcher()
+
+ @Before
+ fun setUp() {
+ Dispatchers.setMain(scheduler)
+ }
+
+ @After
+ fun tearDown() {
+ Dispatchers.resetMain()
+ }
+
@Test
@Config(sdk = [34], qualifiers = "zh-rCN")
fun nullMessageFallsBackToLocalizedChinese() {
@@ -28,4 +63,104 @@ class CalcViewModelTest {
vm.showError(null)
assertEquals("Invalid input", vm.state.value.error)
}
+
+ /**
+ * 重算不清空上一次结果——旧实现直接赋值 CalcUiState(loading = true),
+ * 每点一次计算界面都要塌陷再展开。
+ */
+ @Test
+ @Config(sdk = [34])
+ fun computeKeepsPreviousResultWhileLoading() = runTest(scheduler) {
+ val client = GatedEngineClient()
+ val vm = CalcViewModel(ApplicationProvider.getApplicationContext(), client)
+ val first = firstResultOf(vm, client)
+
+ client.reopen()
+ vm.compute(SimplifyTask(expression = "y+y"))
+ advanceUntilIdle()
+
+ assertTrue("应处于计算中", vm.state.value.loading)
+ assertSame("重算期间旧结果必须保留", first, vm.state.value.response)
+ assertNull("发起新计算时旧错误必须清掉", vm.state.value.error)
+ }
+
+ /** 取消撤销的是请求,不是界面:结果停留在取消前的样子。 */
+ @Test
+ @Config(sdk = [34])
+ fun cancelRestoresPreviousResult() = runTest(scheduler) {
+ val client = GatedEngineClient()
+ val vm = CalcViewModel(ApplicationProvider.getApplicationContext(), client)
+ val first = firstResultOf(vm, client)
+
+ client.reopen()
+ vm.compute(SimplifyTask(expression = "y+y"))
+ advanceUntilIdle()
+ vm.cancel()
+ advanceUntilIdle()
+
+ assertFalse(vm.state.value.loading)
+ assertSame("取消后应回到取消前的结果", first, vm.state.value.response)
+ }
+
+ /**
+ * 被取消的协程不能在稍后覆盖主线程刚写好的状态。
+ * compute() 的完成赋值依赖这一点:协程恢复时会先抛 CancellationException。
+ */
+ @Test
+ @Config(sdk = [34])
+ fun cancelledRunDoesNotClobberState() = runTest(scheduler) {
+ val client = GatedEngineClient()
+ val vm = CalcViewModel(ApplicationProvider.getApplicationContext(), client)
+ val first = firstResultOf(vm, client)
+
+ client.reopen()
+ vm.compute(SimplifyTask(expression = "y+y"))
+ advanceUntilIdle()
+ vm.cancel()
+ // 取消之后才放行第二次计算:它的返回值必须被丢弃。
+ client.release()
+ advanceUntilIdle()
+
+ assertFalse(vm.state.value.loading)
+ assertSame("被取消的计算不得覆盖状态", first, vm.state.value.response)
+ }
+
+ /** 跑完一次计算并返回它的结果对象,供后续断言做引用比较。 */
+ private fun TestScope.firstResultOf(
+ vm: CalcViewModel,
+ client: GatedEngineClient,
+ ): CalcResponse {
+ vm.compute(SimplifyTask(expression = "x+x"))
+ advanceUntilIdle()
+ client.release()
+ advanceUntilIdle()
+ return requireNotNull(vm.state.value.response) { "首次计算应产出结果" }
+ .also { assertNotNull(it.result) }
+ }
+
+ /** 由测试决定何时返回的 EngineClient。 */
+ private class GatedEngineClient : EngineClient(
+ ApplicationProvider.getApplicationContext()
+ ) {
+ @Volatile
+ private var gate = CompletableDeferred()
+
+ override suspend fun compute(request: CalcRequest): CalcResponse {
+ gate.await()
+ return CalcResponse(
+ id = request.id,
+ ok = true,
+ result = CalcResult(tex = "2x", plain = "2*x"),
+ )
+ }
+
+ /** 开一道新门闩,让下一次 compute 重新挂起。 */
+ fun reopen() {
+ gate = CompletableDeferred()
+ }
+
+ fun release() {
+ gate.complete(Unit)
+ }
+ }
}
diff --git a/app/src/test/kotlin/com/paruh/maxmath/ui/MatrixCellsSaverTest.kt b/app/src/test/kotlin/com/paruh/maxmath/ui/MatrixCellsSaverTest.kt
new file mode 100644
index 0000000..0a73776
--- /dev/null
+++ b/app/src/test/kotlin/com/paruh/maxmath/ui/MatrixCellsSaverTest.kt
@@ -0,0 +1,59 @@
+package com.paruh.maxmath.ui
+
+import androidx.compose.runtime.saveable.SaverScope
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotNull
+import org.junit.Test
+
+/**
+ * 纯 JVM 测试,不需要 Robolectric。
+ *
+ * 这些用例存在的理由:矩阵/向量/二次型三个表单原先用普通 remember,旋转屏幕
+ * 会丢掉所有已填内容。换成 rememberSaveable 之后必须保证编码是无损的——
+ * 尤其是含逗号/分号的表达式,那正是分隔符式编码会切坏的输入。
+ */
+class MatrixCellsSaverTest {
+
+ private fun roundTrip(cells: List>): List> {
+ val saved = with(MatrixCellsSaver) { SaverScope { true }.save(cells) }
+ assertNotNull("save 不应返回 null", saved)
+ return MatrixCellsSaver.restore(saved!!)!!
+ }
+
+ @Test
+ fun `plain cells round trip`() {
+ val cells = listOf(listOf("1", "-2.5"), listOf("x^2", "0"))
+ assertEquals(cells, roundTrip(cells))
+ }
+
+ @Test
+ fun `empty cells survive`() {
+ // 空格子按 0 处理,是受支持的输入,不能在恢复时被吃掉或错位。
+ val cells = listOf(listOf("", "3"), listOf("", ""))
+ assertEquals(cells, roundTrip(cells))
+ }
+
+ @Test
+ fun `cells containing separators are not corrupted`() {
+ val cells = listOf(
+ listOf("f(a,b)", "1;2"),
+ listOf("[1,2]", "sin(x),cos(x)"),
+ )
+ assertEquals(cells, roundTrip(cells))
+ }
+
+ @Test
+ fun `non square matrices keep their shape`() {
+ val cells = listOf(listOf("1", "2", "3"), listOf("4", "5", "6"))
+ val restored = roundTrip(cells)
+ assertEquals(2, restored.size)
+ assertEquals(3, restored[0].size)
+ assertEquals(cells, restored)
+ }
+
+ @Test
+ fun `single cell round trips`() {
+ val cells = listOf(listOf("42"))
+ assertEquals(cells, roundTrip(cells))
+ }
+}
diff --git a/app/src/test/kotlin/com/paruh/maxmath/ui/SystemScreenStateTest.kt b/app/src/test/kotlin/com/paruh/maxmath/ui/SystemScreenStateTest.kt
index 36d54cd..f7d1e2a 100644
--- a/app/src/test/kotlin/com/paruh/maxmath/ui/SystemScreenStateTest.kt
+++ b/app/src/test/kotlin/com/paruh/maxmath/ui/SystemScreenStateTest.kt
@@ -1,6 +1,7 @@
package com.paruh.maxmath.ui
import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.runtime.CompositionLocalProvider
@@ -85,9 +86,13 @@ class SystemScreenStateTest {
vm!!.showError("计算超时(120 秒)")
compose.waitForIdle()
- compose.onNodeWithText("▼").performClick()
+ // 展开控件从裸三角字形换成了带 testTag 的图标按钮:
+ // testTag 与语言无关,和 latex_view / language_menu_button 一致。
+ compose.onNodeWithTag("error_expand_toggle").performClick()
compose.waitForIdle()
+ // 折叠时显示首行摘要、展开时显示完整正文,两者互斥,
+ // 所以这段文本无论何时都只对应一个节点。
compose.onNodeWithText("计算超时(120 秒)").assertExists()
}
diff --git a/app/src/test/kotlin/com/paruh/maxmath/ui/components/ComputeSectionStateTest.kt b/app/src/test/kotlin/com/paruh/maxmath/ui/components/ComputeSectionStateTest.kt
new file mode 100644
index 0000000..7495d38
--- /dev/null
+++ b/app/src/test/kotlin/com/paruh/maxmath/ui/components/ComputeSectionStateTest.kt
@@ -0,0 +1,68 @@
+package com.paruh.maxmath.ui.components
+
+import androidx.compose.ui.test.assertIsEnabled
+import androidx.compose.ui.test.assertIsNotEnabled
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.test.performClick
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import org.junit.Assert.assertEquals
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.annotation.Config
+import org.robolectric.annotation.GraphicsMode
+
+/**
+ * P0 回归:取消按钮。
+ *
+ * 引擎单次求值上限 120 秒,改动之前 ViewModel 的 cancel() 明明是好的、
+ * R.string.cancel 也早就翻译好了,却没有任何界面调用它——用户只能干等。
+ * 这几条用例把「运行中一定有取消入口」钉住。
+ */
+@RunWith(AndroidJUnit4::class)
+@GraphicsMode(GraphicsMode.Mode.NATIVE)
+@Config(sdk = [34], qualifiers = "zh-rCN")
+class ComputeSectionStateTest {
+
+ @get:Rule
+ val compose = createComposeRule()
+
+ @Test
+ fun cancelIsHiddenWhenIdle() {
+ compose.setContent {
+ ComputeActionRow(loading = false, onCompute = {}, onCancel = {})
+ }
+ compose.waitForIdle()
+
+ compose.onNodeWithTag("cancel_button").assertDoesNotExist()
+ compose.onNodeWithText("计算").assertIsEnabled()
+ }
+
+ @Test
+ fun cancelAppearsWhileLoadingAndComputeIsDisabled() {
+ compose.setContent {
+ ComputeActionRow(loading = true, onCompute = {}, onCancel = {})
+ }
+ compose.waitForIdle()
+
+ compose.onNodeWithTag("cancel_button").assertExists()
+ // 运行中不允许再次点击计算——改动之前只有矩阵页和绘图页有这个保护。
+ compose.onNodeWithText("计算").assertIsNotEnabled()
+ }
+
+ @Test
+ fun tappingCancelInvokesCallback() {
+ var cancels = 0
+ compose.setContent {
+ ComputeActionRow(loading = true, onCompute = {}, onCancel = { cancels++ })
+ }
+ compose.waitForIdle()
+
+ compose.onNodeWithTag("cancel_button").performClick()
+ compose.waitForIdle()
+
+ assertEquals(1, cancels)
+ }
+}
diff --git a/app/src/test/kotlin/com/paruh/maxmath/ui/plot/Plot2DPainterPaletteTest.kt b/app/src/test/kotlin/com/paruh/maxmath/ui/plot/Plot2DPainterPaletteTest.kt
new file mode 100644
index 0000000..d764167
--- /dev/null
+++ b/app/src/test/kotlin/com/paruh/maxmath/ui/plot/Plot2DPainterPaletteTest.kt
@@ -0,0 +1,71 @@
+package com.paruh.maxmath.ui.plot
+
+import android.graphics.Color as AndroidColor
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.paruh.maxmath.ui.theme.PlotPalette
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.annotation.Config
+import org.robolectric.annotation.GraphicsMode
+
+/**
+ * 钉住「导出的 PNG 恒为浅色」这个决定。
+ *
+ * 保存/分享出去的图会进相册、会发到聊天和文档里,深色底板在那些场景看起来
+ * 像渲染出错;而另一条 2D 保存路径直接复制引擎产出的白底 matplotlib PNG,
+ * 实时路径若跟随主题,同一个「保存 PNG」会因为用户有没有拖动过而给出两种
+ * 观感完全不同的文件。有人以后想把默认值改成跟随主题,这条会先亮红灯。
+ */
+@RunWith(AndroidJUnit4::class)
+@GraphicsMode(GraphicsMode.Mode.NATIVE)
+@Config(sdk = [34])
+class Plot2DPainterPaletteTest {
+
+ private val range = PlotRange(-2.0, 2.0, -2.0, 2.0)
+
+ /** 采样点避开边框、网格线与坐标轴。 */
+ private fun backgroundLuminance(argb: Int): Double {
+ val r = AndroidColor.red(argb) / 255.0
+ val g = AndroidColor.green(argb) / 255.0
+ val b = AndroidColor.blue(argb) / 255.0
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b
+ }
+
+ @Test
+ fun exportDefaultsToLightRegardlessOfTheme() {
+ val bitmap = Plot2DPainter.renderToBitmap(
+ width = 64,
+ height = 64,
+ range = range,
+ expressions = emptyList(),
+ annotations = null,
+ )
+ val lum = backgroundLuminance(bitmap.getPixel(3, 3))
+ assertTrue("导出图底色应为浅色,实测亮度 $lum", lum > 0.8)
+ }
+
+ @Test
+ fun darkPaletteIsHonouredWhenExplicitlyRequested() {
+ // 屏幕上的实时画布走的就是这条路径,深色主题必须真的画成深色。
+ val bitmap = Plot2DPainter.renderToBitmap(
+ width = 64,
+ height = 64,
+ range = range,
+ expressions = emptyList(),
+ annotations = null,
+ palette = PlotPalette.Dark,
+ )
+ val lum = backgroundLuminance(bitmap.getPixel(3, 3))
+ assertTrue("深色配色下底色应为深色,实测亮度 $lum", lum < 0.2)
+ }
+
+ @Test
+ fun paletteSwitchingIsNotSticky() {
+ // applyPalette 用引用相等做门禁,切回来必须真的切回来,
+ // 否则深色渲染一次之后导出就永久变深了。
+ Plot2DPainter.renderToBitmap(64, 64, range, emptyList(), null, palette = PlotPalette.Dark)
+ val backToLight = Plot2DPainter.renderToBitmap(64, 64, range, emptyList(), null)
+ assertTrue(backgroundLuminance(backToLight.getPixel(3, 3)) > 0.8)
+ }
+}
diff --git a/app/src/test/kotlin/com/paruh/maxmath/ui/plot/PlotScreenStateTest.kt b/app/src/test/kotlin/com/paruh/maxmath/ui/plot/PlotScreenStateTest.kt
index a17f262..1cc369b 100644
--- a/app/src/test/kotlin/com/paruh/maxmath/ui/plot/PlotScreenStateTest.kt
+++ b/app/src/test/kotlin/com/paruh/maxmath/ui/plot/PlotScreenStateTest.kt
@@ -2,6 +2,7 @@ package com.paruh.maxmath.ui.plot
import androidx.compose.ui.semantics.SemanticsActions
import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performSemanticsAction
import androidx.test.ext.junit.runners.AndroidJUnit4
@@ -63,7 +64,8 @@ class PlotScreenStateTest {
compose.waitForIdle()
compose.onNodeWithText("错误详情").assertExists()
- compose.onNodeWithText("▼").performSemanticsAction(SemanticsActions.OnClick)
+ compose.onNodeWithTag("error_expand_toggle")
+ .performSemanticsAction(SemanticsActions.OnClick)
compose.waitForIdle()
compose.onNodeWithText("GLITCH-MARKER").assertExists()
}
diff --git a/app/src/test/kotlin/com/paruh/maxmath/ui/theme/ColorSchemeTest.kt b/app/src/test/kotlin/com/paruh/maxmath/ui/theme/ColorSchemeTest.kt
new file mode 100644
index 0000000..e7d7ff4
--- /dev/null
+++ b/app/src/test/kotlin/com/paruh/maxmath/ui/theme/ColorSchemeTest.kt
@@ -0,0 +1,91 @@
+package com.paruh.maxmath.ui.theme
+
+import androidx.compose.material3.ColorScheme
+import androidx.compose.ui.graphics.Color
+import org.junit.Assert.assertNotEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+/**
+ * 手写 35 个配色角色最典型的失误就是复制粘贴时漏改一处,
+ * 结果某个 onX 和它的容器色撞在一起——界面上表现为文字直接消失。
+ * 这里把这类错误挡在编译之后、上手之前。
+ */
+class ColorSchemeTest {
+
+ private val pairsOf: (ColorScheme) -> List> = { s ->
+ listOf(
+ Triple("primary", s.onPrimary, s.primary),
+ Triple("primaryContainer", s.onPrimaryContainer, s.primaryContainer),
+ Triple("secondary", s.onSecondary, s.secondary),
+ Triple("secondaryContainer", s.onSecondaryContainer, s.secondaryContainer),
+ Triple("tertiary", s.onTertiary, s.tertiary),
+ Triple("tertiaryContainer", s.onTertiaryContainer, s.tertiaryContainer),
+ Triple("error", s.onError, s.error),
+ Triple("errorContainer", s.onErrorContainer, s.errorContainer),
+ Triple("background", s.onBackground, s.background),
+ Triple("surface", s.onSurface, s.surface),
+ Triple("surfaceVariant", s.onSurfaceVariant, s.surfaceVariant),
+ Triple("inverseSurface", s.inverseOnSurface, s.inverseSurface),
+ )
+ }
+
+ @Test
+ fun `light scheme has no role colliding with its container`() {
+ assertDistinct(MaxMathLightColors)
+ }
+
+ @Test
+ fun `dark scheme has no role colliding with its container`() {
+ assertDistinct(MaxMathDarkColors)
+ }
+
+ @Test
+ fun `light and dark actually differ`() {
+ // 防止有人把两套方案指到同一批常量上。
+ assertNotEquals(MaxMathLightColors.surface, MaxMathDarkColors.surface)
+ assertNotEquals(MaxMathLightColors.primary, MaxMathDarkColors.primary)
+ assertNotEquals(MaxMathLightColors.onSurface, MaxMathDarkColors.onSurface)
+ }
+
+ @Test
+ fun `every text pair clears WCAG AA`() {
+ listOf(
+ "light" to MaxMathLightColors,
+ "dark" to MaxMathDarkColors,
+ ).forEach { (name, scheme) ->
+ pairsOf(scheme).forEach { (role, fg, bg) ->
+ val ratio = contrast(fg, bg)
+ assertTrue(
+ "$name/$role 对比度只有 %.2f,低于 AA 要求的 4.5".format(ratio),
+ ratio >= 4.5,
+ )
+ }
+ }
+ }
+
+ private fun assertDistinct(scheme: ColorScheme) {
+ pairsOf(scheme).forEach { (role, fg, bg) ->
+ assertNotEquals("$role 的前景色与容器色相同", fg, bg)
+ assertNotEquals("$role 前景色未指定", Color.Unspecified, fg)
+ assertNotEquals("$role 容器色未指定", Color.Unspecified, bg)
+ }
+ }
+
+ /** WCAG 2.1 相对亮度对比度。 */
+ private fun contrast(a: Color, b: Color): Double {
+ val la = luminance(a)
+ val lb = luminance(b)
+ val hi = maxOf(la, lb)
+ val lo = minOf(la, lb)
+ return (hi + 0.05) / (lo + 0.05)
+ }
+
+ private fun luminance(c: Color): Double {
+ fun channel(v: Float): Double {
+ val d = v.toDouble()
+ return if (d <= 0.03928) d / 12.92 else Math.pow((d + 0.055) / 1.055, 2.4)
+ }
+ return 0.2126 * channel(c.red) + 0.7152 * channel(c.green) + 0.0722 * channel(c.blue)
+ }
+}