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) + } +}