From 0291e3ac495868db199ffc141bbb04db22b3d8cd Mon Sep 17 00:00:00 2001 From: lizy Date: Sun, 20 Sep 2026 16:07:25 +0800 Subject: [PATCH 1/9] feat: add Android platform support to example app Scaffold the example's Android host and make the example layout responsive for narrow screens, with a widget test covering a 320x640 viewport. --- example/.metadata | 5 +- example/analysis_options.yaml | 1 + example/android/.gitignore | 14 ++ example/android/app/build.gradle.kts | 49 ++++++ .../android/app/src/debug/AndroidManifest.xml | 7 + .../android/app/src/main/AndroidManifest.xml | 45 ++++++ .../com/example/example/MainActivity.kt | 5 + .../res/drawable-v21/launch_background.xml | 12 ++ .../main/res/drawable/launch_background.xml | 12 ++ .../src/main/res/mipmap-hdpi/ic_launcher.png | Bin 0 -> 544 bytes .../src/main/res/mipmap-mdpi/ic_launcher.png | Bin 0 -> 442 bytes .../src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 721 bytes .../main/res/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 1031 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 1443 bytes .../app/src/main/res/values-night/styles.xml | 18 +++ .../app/src/main/res/values/styles.xml | 18 +++ .../app/src/profile/AndroidManifest.xml | 7 + example/android/build.gradle.kts | 24 +++ example/android/gradle.properties | 6 + .../gradle/wrapper/gradle-wrapper.properties | 5 + example/android/settings.gradle.kts | 26 ++++ example/lib/main.dart | 141 +++++++++++------- example/pubspec.lock | 42 +++--- example/test/widget_test.dart | 19 +++ 24 files changed, 383 insertions(+), 73 deletions(-) create mode 100644 example/android/.gitignore create mode 100644 example/android/app/build.gradle.kts create mode 100644 example/android/app/src/debug/AndroidManifest.xml create mode 100644 example/android/app/src/main/AndroidManifest.xml create mode 100644 example/android/app/src/main/kotlin/com/example/example/MainActivity.kt create mode 100644 example/android/app/src/main/res/drawable-v21/launch_background.xml create mode 100644 example/android/app/src/main/res/drawable/launch_background.xml create mode 100644 example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png create mode 100644 example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png create mode 100644 example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png create mode 100644 example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png create mode 100644 example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png create mode 100644 example/android/app/src/main/res/values-night/styles.xml create mode 100644 example/android/app/src/main/res/values/styles.xml create mode 100644 example/android/app/src/profile/AndroidManifest.xml create mode 100644 example/android/build.gradle.kts create mode 100644 example/android/gradle.properties create mode 100644 example/android/gradle/wrapper/gradle-wrapper.properties create mode 100644 example/android/settings.gradle.kts diff --git a/example/.metadata b/example/.metadata index c24b9a1..f6f0896 100644 --- a/example/.metadata +++ b/example/.metadata @@ -4,7 +4,7 @@ # This file should be version controlled and should not be manually edited. version: - revision: "00b0c91f06209d9e4a41f71b7a512d6eb3b9c694" + revision: "d3b14c876900e553bc736ca19295fc09e3853e8e" channel: "stable" project_type: app @@ -18,6 +18,9 @@ migration: - platform: macos create_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694 base_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694 + - platform: android + create_revision: d3b14c876900e553bc736ca19295fc09e3853e8e + base_revision: d3b14c876900e553bc736ca19295fc09e3853e8e # User provided section diff --git a/example/analysis_options.yaml b/example/analysis_options.yaml index 5d3e697..b3bebc0 100644 --- a/example/analysis_options.yaml +++ b/example/analysis_options.yaml @@ -11,6 +11,7 @@ analyzer: exclude: - build/** - macos/** + - android/** include: package:flutter_lints/flutter.yaml linter: diff --git a/example/android/.gitignore b/example/android/.gitignore new file mode 100644 index 0000000..be3943c --- /dev/null +++ b/example/android/.gitignore @@ -0,0 +1,14 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java +.cxx/ + +# Remember to never publicly share your keystore. +# See https://flutter.dev/to/reference-keystore +key.properties +**/*.keystore +**/*.jks diff --git a/example/android/app/build.gradle.kts b/example/android/app/build.gradle.kts new file mode 100644 index 0000000..74caf01 --- /dev/null +++ b/example/android/app/build.gradle.kts @@ -0,0 +1,49 @@ +plugins { + id("com.android.application") + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id("dev.flutter.flutter-gradle-plugin") +} + +android { + namespace = "com.example.example" + compileSdk = flutter.compileSdkVersion + ndkVersion = flutter.ndkVersion + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId = "com.example.example" + // You can update the following values to match your application needs. + // For more information, see: https://flutter.dev/to/review-gradle-config. + minSdk = flutter.minSdkVersion + targetSdk = flutter.targetSdkVersion + // Uses the version code from pubspec.yaml. When using split APKs, 1000 * ABI_VERSION + // is added automatically by Flutter. (https://developer.android.com/studio/build/configure-apk-splits#configure-APK-versions) + // You can force using the value of versionCode by specifying the `-P force-version-code-ignoring-abi=true` + // flag during build. + versionCode = flutter.versionCode + versionName = flutter.versionName + } + + buildTypes { + release { + // TODO: Add your own signing config for the release build. + // Signing with the debug keys for now, so `flutter run --release` works. + signingConfig = signingConfigs.getByName("debug") + } + } +} + +kotlin { + compilerOptions { + jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 + } +} + +flutter { + source = "../.." +} diff --git a/example/android/app/src/debug/AndroidManifest.xml b/example/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/example/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/example/android/app/src/main/AndroidManifest.xml b/example/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..74a78b9 --- /dev/null +++ b/example/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/example/android/app/src/main/kotlin/com/example/example/MainActivity.kt b/example/android/app/src/main/kotlin/com/example/example/MainActivity.kt new file mode 100644 index 0000000..ac81bae --- /dev/null +++ b/example/android/app/src/main/kotlin/com/example/example/MainActivity.kt @@ -0,0 +1,5 @@ +package com.example.example + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity : FlutterActivity() diff --git a/example/android/app/src/main/res/drawable-v21/launch_background.xml b/example/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000..f74085f --- /dev/null +++ b/example/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/example/android/app/src/main/res/drawable/launch_background.xml b/example/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..304732f --- /dev/null +++ b/example/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..db77bb4b7b0906d62b1847e87f15cdcacf6a4f29 GIT binary patch literal 544 zcmeAS@N?(olHy`uVBq!ia0vp^9w5xY3?!3`olAj~WQl7;NpOBzNqJ&XDuZK6ep0G} zXKrG8YEWuoN@d~6R2!h8bpbvhu0Wd6uZuB!w&u2PAxD2eNXD>P5D~Wn-+_Wa#27Xc zC?Zj|6r#X(-D3u$NCt}(Ms06KgJ4FxJVv{GM)!I~&n8Bnc94O7-Hd)cjDZswgC;Qs zO=b+9!WcT8F?0rF7!Uys2bs@gozCP?z~o%U|N3vA*22NaGQG zlg@K`O_XuxvZ&Ks^m&R!`&1=spLvfx7oGDKDwpwW`#iqdw@AL`7MR}m`rwr|mZgU`8P7SBkL78fFf!WnuYWm$5Z0 zNXhDbCv&49sM544K|?c)WrFfiZvCi9h0O)B3Pgg&ebxsLQ05GG~ AQ2+n{ literal 0 HcmV?d00001 diff --git a/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..17987b79bb8a35cc66c3c1fd44f5a5526c1b78be GIT binary patch literal 442 zcmeAS@N?(olHy`uVBq!ia0vp^1|ZDA3?vioaBc-sk|nMYCBgY=CFO}lsSJ)O`AMk? zp1FzXsX?iUDV2pMQ*D5Xx&nMcT!A!W`0S9QKQy;}1Cl^CgaH=;G9cpY;r$Q>i*pfB zP2drbID<_#qf;rPZx^FqH)F_D#*k@@q03KywUtLX8Ua?`H+NMzkczFPK3lFz@i_kW%1NOn0|D2I9n9wzH8m|-tHjsw|9>@K=iMBhxvkv6m8Y-l zytQ?X=U+MF$@3 zt`~i=@j|6y)RWMK--}M|=T`o&^Ni>IoWKHEbBXz7?A@mgWoL>!*SXo`SZH-*HSdS+ yn*9;$7;m`l>wYBC5bq;=U}IMqLzqbYCidGC!)_gkIk_C@Uy!y&wkt5C($~2D>~)O*cj@FGjOCM)M>_ixfudOh)?xMu#Fs z#}Y=@YDTwOM)x{K_j*Q;dPdJ?Mz0n|pLRx{4n|)f>SXlmV)XB04CrSJn#dS5nK2lM zrZ9#~WelCp7&e13Y$jvaEXHskn$2V!!DN-nWS__6T*l;H&Fopn?A6HZ-6WRLFP=R` zqG+CE#d4|IbyAI+rJJ`&x9*T`+a=p|0O(+s{UBcyZdkhj=yS1>AirP+0R;mf2uMgM zC}@~JfByORAh4SyRgi&!(cja>F(l*O+nd+@4m$|6K6KDn_&uvCpV23&>G9HJp{xgg zoq1^2_p9@|WEo z*X_Uko@K)qYYv~>43eQGMdbiGbo>E~Q& zrYBH{QP^@Sti!`2)uG{irBBq@y*$B zi#&(U-*=fp74j)RyIw49+0MRPMRU)+a2r*PJ$L5roHt2$UjExCTZSbq%V!HeS7J$N zdG@vOZB4v_lF7Plrx+hxo7(fCV&}fHq)$ literal 0 HcmV?d00001 diff --git a/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..d5f1c8d34e7a88e3f88bea192c3a370d44689c3c GIT binary patch literal 1031 zcmeAS@N?(olHy`uVBq!ia0vp^6F``Q8Ax83A=Cw=BuiW)N`mv#O3D+9QW+dm@{>{( zJaZG%Q-e|yQz{EjrrIztFa`(sgt!6~Yi|1%a`XoT0ojZ}lNrNjb9xjc(B0U1_% zz5^97Xt*%oq$rQy4?0GKNfJ44uvxI)gC`h-NZ|&0-7(qS@?b!5r36oQ}zyZrNO3 zMO=Or+<~>+A&uN&E!^Sl+>xE!QC-|oJv`ApDhqC^EWD|@=#J`=d#Xzxs4ah}w&Jnc z$|q_opQ^2TrnVZ0o~wh<3t%W&flvYGe#$xqda2bR_R zvPYgMcHgjZ5nSA^lJr%;<&0do;O^tDDh~=pIxA#coaCY>&N%M2^tq^U%3DB@ynvKo}b?yu-bFc-u0JHzced$sg7S3zqI(2 z#Km{dPr7I=pQ5>FuK#)QwK?Y`E`B?nP+}U)I#c1+FM*1kNvWG|a(TpksZQ3B@sD~b zpQ2)*V*TdwjFOtHvV|;OsiDqHi=6%)o4b!)x$)%9pGTsE z-JL={-Ffv+T87W(Xpooq<`r*VzWQcgBN$$`u}f>-ZQI1BB8ykN*=e4rIsJx9>z}*o zo~|9I;xof literal 0 HcmV?d00001 diff --git a/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..4d6372eebdb28e45604e46eeda8dd24651419bc0 GIT binary patch literal 1443 zcmb`G{WsKk6vsdJTdFg%tJav9_E4vzrOaqkWF|A724Nly!y+?N9`YV6wZ}5(X(D_N(?!*n3`|_r0Hc?=PQw&*vnU?QTFY zB_MsH|!j$PP;I}?dppoE_gA(4uc!jV&0!l7_;&p2^pxNo>PEcNJv za5_RT$o2Mf!<+r?&EbHH6nMoTsDOa;mN(wv8RNsHpG)`^ymG-S5By8=l9iVXzN_eG%Xg2@Xeq76tTZ*dGh~Lo9vl;Zfs+W#BydUw zCkZ$o1LqWQO$FC9aKlLl*7x9^0q%0}$OMlp@Kk_jHXOjofdePND+j!A{q!8~Jn+s3 z?~~w@4?egS02}8NuulUA=L~QQfm;MzCGd)XhiftT;+zFO&JVyp2mBww?;QByS_1w! zrQlx%{^cMj0|Bo1FjwY@Q8?Hx0cIPF*@-ZRFpPc#bBw{5@tD(5%sClzIfl8WU~V#u zm5Q;_F!wa$BSpqhN>W@2De?TKWR*!ujY;Yylk_X5#~V!L*Gw~;$%4Q8~Mad z@`-kG?yb$a9cHIApZDVZ^U6Xkp<*4rU82O7%}0jjHlK{id@?-wpN*fCHXyXh(bLt* zPc}H-x0e4E&nQ>y%B-(EL=9}RyC%MyX=upHuFhAk&MLbsF0LP-q`XnH78@fT+pKPW zu72MW`|?8ht^tz$iC}ZwLp4tB;Q49K!QCF3@!iB1qOI=?w z7In!}F~ij(18UYUjnbmC!qKhPo%24?8U1x{7o(+?^Zu0Hx81|FuS?bJ0jgBhEMzf< zCgUq7r2OCB(`XkKcN-TL>u5y#dD6D!)5W?`O5)V^>jb)P)GBdy%t$uUMpf$SNV31$ zb||OojAbvMP?T@$h_ZiFLFVHDmbyMhJF|-_)HX3%m=CDI+ID$0^C>kzxprBW)hw(v zr!Gmda);ICoQyhV_oP5+C%?jcG8v+D@9f?Dk*!BxY}dazmrT@64UrP3hlslANK)bq z$67n83eh}OeW&SV@HG95P|bjfqJ7gw$e+`Hxo!4cx`jdK1bJ>YDSpGKLPZ^1cv$ek zIB?0S<#tX?SJCLWdMd{-ME?$hc7A$zBOdIJ)4!KcAwb=VMov)nK;9z>x~rfT1>dS+ zZ6#`2v@`jgbqq)P22H)Tx2CpmM^o1$B+xT6`(v%5xJ(?j#>Q$+rx_R|7TzDZe{J6q zG1*EcU%tE?!kO%^M;3aM6JN*LAKUVb^xz8-Pxo#jR5(-KBeLJvA@-gxNHx0M-ZJLl z;#JwQoh~9V?`UVo#}{6ka@II>++D@%KqGpMdlQ}?9E*wFcf5(#XQnP$Dk5~%iX^>f z%$y;?M0BLp{O3a(-4A?ewryHrrD%cx#Q^%KY1H zNre$ve+vceSLZcNY4U(RBX&)oZn*Py()h)XkE?PL$!bNb{N5FVI2Y%LKEm%yvpyTP z(1P?z~7YxD~Rf<(a@_y` literal 0 HcmV?d00001 diff --git a/example/android/app/src/main/res/values-night/styles.xml b/example/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..06952be --- /dev/null +++ b/example/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/example/android/app/src/main/res/values/styles.xml b/example/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..cb1ef88 --- /dev/null +++ b/example/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/example/android/app/src/profile/AndroidManifest.xml b/example/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/example/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/example/android/build.gradle.kts b/example/android/build.gradle.kts new file mode 100644 index 0000000..dbee657 --- /dev/null +++ b/example/android/build.gradle.kts @@ -0,0 +1,24 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +val newBuildDir: Directory = + rootProject.layout.buildDirectory + .dir("../../build") + .get() +rootProject.layout.buildDirectory.value(newBuildDir) + +subprojects { + val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) + project.layout.buildDirectory.value(newSubprojectBuildDir) +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/example/android/gradle.properties b/example/android/gradle.properties new file mode 100644 index 0000000..e96108c --- /dev/null +++ b/example/android/gradle.properties @@ -0,0 +1,6 @@ +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true +# This newDsl flag was added by the Flutter template +android.newDsl=false +# This builtInKotlin flag was added by the Flutter template +android.builtInKotlin=false diff --git a/example/android/gradle/wrapper/gradle-wrapper.properties b/example/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..a20f2c4 --- /dev/null +++ b/example/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-all.zip diff --git a/example/android/settings.gradle.kts b/example/android/settings.gradle.kts new file mode 100644 index 0000000..b28021a --- /dev/null +++ b/example/android/settings.gradle.kts @@ -0,0 +1,26 @@ +pluginManagement { + val flutterSdkPath = + run { + val properties = java.util.Properties() + file("local.properties").inputStream().use { properties.load(it) } + val flutterSdkPath = properties.getProperty("flutter.sdk") + require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } + flutterSdkPath + } + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id("dev.flutter.flutter-plugin-loader") version "1.0.0" + id("com.android.application") version "9.1.0" apply false + id("org.jetbrains.kotlin.android") version "2.4.0" apply false +} + +include(":app") diff --git a/example/lib/main.dart b/example/lib/main.dart index 1fca173..07aff69 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -44,7 +44,7 @@ G2 X40 Y40 '''; final _pipeline = GcodeReadlinePipeline( - options: const GcodeReadlineOptions(snapshotBatchSize: 1), + options: const GcodeReadlineOptions(snapshotBatchSize: 200), ); GcodeLoadSnapshot? _snapshot; @@ -71,7 +71,10 @@ G2 X40 Y40 final file = await openFile(acceptedTypeGroups: [typeGroup]); if (file == null) return; - await _parseReader(FileGcodeLineReader(file.path), sourceName: file.name); + await _parseSnapshots( + _pipeline.loadFileInBackground(file.path), + sourceName: file.name, + ); } Future _loadSample() { @@ -84,6 +87,13 @@ G2 X40 Y40 Future _parseReader( GcodeLineReader reader, { required String sourceName, + }) { + return _parseSnapshots(_pipeline.load(reader), sourceName: sourceName); + } + + Future _parseSnapshots( + Stream snapshots, { + required String sourceName, }) async { _playbackTimer?.cancel(); setState(() { @@ -95,7 +105,7 @@ G2 X40 Y40 _status = '正在读取 $sourceName'; }); - await for (final snapshot in _pipeline.load(reader)) { + await for (final snapshot in snapshots) { if (!mounted) return; setState(() { _snapshot = snapshot; @@ -159,24 +169,39 @@ G2 X40 Y40 @override Widget build(BuildContext context) { final snapshot = _snapshot; + final compactActions = MediaQuery.sizeOf(context).width < 600; return Scaffold( appBar: AppBar( - title: const Text('G-code Core 绘制示例'), - actions: [ - TextButton.icon( - onPressed: _loading ? null : _loadSample, - icon: const Icon(Icons.data_object), - label: const Text('示例数据'), - ), - const SizedBox(width: 8), - FilledButton.icon( - onPressed: _loading ? null : _pickAndParseFile, - icon: const Icon(Icons.folder_open), - label: const Text('选择 G-code'), - ), - const SizedBox(width: 16), - ], + title: Text(compactActions ? 'G-code' : 'G-code Core 绘制示例'), + actions: compactActions + ? [ + IconButton( + onPressed: _loading ? null : _loadSample, + icon: const Icon(Icons.data_object), + tooltip: '示例数据', + ), + IconButton( + onPressed: _loading ? null : _pickAndParseFile, + icon: const Icon(Icons.folder_open), + tooltip: '选择 G-code', + ), + const SizedBox(width: 8), + ] + : [ + TextButton.icon( + onPressed: _loading ? null : _loadSample, + icon: const Icon(Icons.data_object), + label: const Text('示例数据'), + ), + const SizedBox(width: 8), + FilledButton.icon( + onPressed: _loading ? null : _pickAndParseFile, + icon: const Icon(Icons.folder_open), + label: const Text('选择 G-code'), + ), + const SizedBox(width: 16), + ], ), body: Padding( padding: const EdgeInsets.all(16), @@ -190,39 +215,53 @@ G2 X40 Y40 ), const SizedBox(height: 16), Expanded( - child: Row( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Expanded( - flex: 3, - child: _CanvasPanel( - snapshot: snapshot, - parsing: _loading, - progress: _playbackProgress, - isPlaying: _isPlaying, - speedMultiplier: _speedMultiplier, - onPlay: _play, - onPause: _pause, - onReset: _resetPlayback, - onSeek: _seekPlayback, - onSpeedChange: _setSpeed, - ), - ), - const SizedBox(width: 16), - SizedBox( - width: 360, - child: _ResultPanel( - snapshot: snapshot, - currentIndex: _currentCommandIndex(snapshot), - onCommandTap: (index) { - final total = snapshot?.commands.length ?? 0; - if (total == 0) return; - _pause(); - setState(() => _playbackProgress = (index + 1) / total); - }, - ), - ), - ], + child: LayoutBuilder( + builder: (context, constraints) { + final canvas = _CanvasPanel( + snapshot: snapshot, + parsing: _loading, + progress: _playbackProgress, + isPlaying: _isPlaying, + speedMultiplier: _speedMultiplier, + onPlay: _play, + onPause: _pause, + onReset: _resetPlayback, + onSeek: _seekPlayback, + onSpeedChange: _setSpeed, + ); + final results = _ResultPanel( + snapshot: snapshot, + currentIndex: _currentCommandIndex(snapshot), + onCommandTap: (index) { + final total = snapshot?.commands.length ?? 0; + if (total == 0) return; + _pause(); + setState(() => _playbackProgress = (index + 1) / total); + }, + ); + + if (constraints.maxWidth >= 720) { + return Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded(flex: 3, child: canvas), + const SizedBox(width: 16), + SizedBox(width: 360, child: results), + ], + ); + } + + final canvasHeight = (constraints.maxHeight * 0.58) + .clamp(280.0, 420.0) + .toDouble(); + return ListView( + children: [ + SizedBox(height: canvasHeight, child: canvas), + const SizedBox(height: 16), + SizedBox(height: 420, child: results), + ], + ); + }, ), ), ], diff --git a/example/pubspec.lock b/example/pubspec.lock index 81adb81..e38b60c 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -29,10 +29,10 @@ packages: dependency: transitive description: name: clock - sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + sha256: e51d50bca3217c9a9fa2b41a30e4a38971133f5f9ec7a3d57bae095007f1d28e url: "https://pub.flutter-io.cn" source: hosted - version: "1.1.2" + version: "1.1.3" collection: dependency: transitive description: @@ -45,10 +45,10 @@ packages: dependency: transitive description: name: cross_file - sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937" + sha256: f141ea4f277af142a0356955707f6556f37b03947d39d55585981a06ca437bd6 url: "https://pub.flutter-io.cn" source: hosted - version: "0.3.5+2" + version: "0.3.5+5" cupertino_icons: dependency: "direct main" description: @@ -77,34 +77,34 @@ packages: dependency: transitive description: name: file_selector_android - sha256: "89243030ea4b3463fb402b44d5eeacc4ccb1c46a88870cb2a5080d693200c1ed" + sha256: d670cd0ce77a2e785b18d8b4d0a8d6a222d6a813ec9b7ddf2790a1b4fb6fa92c url: "https://pub.flutter-io.cn" source: hosted - version: "0.5.2+6" + version: "0.5.2+11" file_selector_ios: dependency: transitive description: name: file_selector_ios - sha256: e2ecf2885c121691ce13b60db3508f53c01f869fb6e8dc5c1cfa771e4c46aeca + sha256: "97269e5307a0ab813b1fa2430bada0a96e0afb74848417f8676f64ba5de0051c" url: "https://pub.flutter-io.cn" source: hosted - version: "0.5.3+5" + version: "0.5.3+6" file_selector_linux: dependency: transitive description: name: file_selector_linux - sha256: "2567f398e06ac72dcf2e98a0c95df2a9edd03c2c2e0cacd4780f20cdf56263a0" + sha256: da76400e7872ce7637ffdce12749ec24169c25f6195c28372208e65a24bcd2ab url: "https://pub.flutter-io.cn" source: hosted - version: "0.9.4" + version: "0.9.4+1" file_selector_macos: dependency: transitive description: name: file_selector_macos - sha256: "5e0bbe9c312416f1787a68259ea1505b52f258c587f12920422671807c4d618a" + sha256: d57c62362766b5e7ae739448650b66c6aab7a68ba7ecc65e04018652645ae0f4 url: "https://pub.flutter-io.cn" source: hosted - version: "0.9.5" + version: "0.9.5+1" file_selector_platform_interface: dependency: transitive description: @@ -125,10 +125,10 @@ packages: dependency: transitive description: name: file_selector_windows - sha256: "62197474ae75893a62df75939c777763d39c2bc5f73ce5b88497208bc269abfd" + sha256: fbefc5fb92c6d3cbe8d284a2cd971b593bb07d2cd6da8557b81a862250b4acec url: "https://pub.flutter-io.cn" source: hosted - version: "0.9.3+5" + version: "0.9.3+6" flutter: dependency: "direct main" description: flutter @@ -269,10 +269,10 @@ packages: dependency: transitive description: name: stack_trace - sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + sha256: "277654b3034d17ac6f9f1cb5595db011b1d5d41e8806866db28e0abaa101c490" url: "https://pub.flutter-io.cn" source: hosted - version: "1.12.1" + version: "1.12.2" stream_channel: dependency: transitive description: @@ -317,18 +317,18 @@ packages: dependency: transitive description: name: vector_math - sha256: f36f9f3be64c6198714492bb455c11056e33e2f85d9a0b676a48301e44fdcf47 + sha256: "92b9910f66ed1057fd4da7b040ae7c74cafacf885bdc81be496928d5049b032d" url: "https://pub.flutter-io.cn" source: hosted - version: "2.4.2" + version: "2.4.3" vm_service: dependency: transitive description: name: vm_service - sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360" + sha256: "5f37239c4851efcef929cea7824e76df7f2f0970aef85d66bbc430afa40e72f0" url: "https://pub.flutter-io.cn" source: hosted - version: "15.2.0" + version: "15.3.0" web: dependency: transitive description: @@ -338,5 +338,5 @@ packages: source: hosted version: "1.1.1" sdks: - dart: ">=3.11.5 <4.0.0" + dart: ">=3.12.0 <4.0.0" flutter: ">=3.47.2" diff --git a/example/test/widget_test.dart b/example/test/widget_test.dart index 7c72bb2..2d5799c 100644 --- a/example/test/widget_test.dart +++ b/example/test/widget_test.dart @@ -1,3 +1,5 @@ +import 'package:example/main.dart'; +import 'package:flutter/material.dart'; import 'package:gcode_core/gcode_core.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -16,4 +18,21 @@ void main() { expect(last.stage, GcodeLoadStage.ready); expect(last.commands.length, 2); }); + + testWidgets('example fits a narrow Android-sized viewport', ( + WidgetTester tester, + ) async { + tester.view.physicalSize = const Size(320, 640); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget(const GcodeCoreExampleApp()); + await tester.pump(); + + expect(find.text('G-code'), findsOneWidget); + expect(find.byTooltip('示例数据'), findsOneWidget); + expect(find.byTooltip('选择 G-code'), findsOneWidget); + expect(tester.takeException(), isNull); + }); } From b70fe31b741959f53811db76f69d884787ab9b21 Mon Sep 17 00:00:00 2001 From: lizy Date: Sun, 20 Sep 2026 19:57:51 +0800 Subject: [PATCH 2/9] perf: avoid redundant rebuilds and GPU resource churn Drive playback state through ValueNotifiers, reuse the GPU host buffer across frames, skip repaints when inputs are unchanged, and cache timeline items instead of rebuilding them on every frame. --- example/lib/main.dart | 94 +++++++++++++---------- lib/src/rendering/gpu_toolpath_layer.dart | 19 ++++- lib/src/widgets/command_timeline.dart | 60 +++++++++++---- 3 files changed, 114 insertions(+), 59 deletions(-) diff --git a/example/lib/main.dart b/example/lib/main.dart index 07aff69..1fdbe5a 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -52,16 +52,31 @@ G2 X40 Y40 String _status = '请选择本地 G-code 文件,或加载内置示例。'; bool _loading = false; bool _isPlaying = false; - double _playbackProgress = 1; + final _playbackProgress = ValueNotifier(1); + final _currentCommandIndex = ValueNotifier(-1); double _speedMultiplier = 1; Timer? _playbackTimer; @override void dispose() { _playbackTimer?.cancel(); + _playbackProgress.dispose(); + _currentCommandIndex.dispose(); super.dispose(); } + void _setPlaybackProgress(double value) { + final progress = value.clamp(0.0, 1.0).toDouble(); + _playbackProgress.value = progress; + final commandCount = _snapshot?.commands.length ?? 0; + final index = commandCount == 0 + ? -1 + : (progress * commandCount).ceil().clamp(1, commandCount) - 1; + if (_currentCommandIndex.value != index) { + _currentCommandIndex.value = index; + } + } + Future _pickAndParseFile() async { const typeGroup = XTypeGroup( label: 'G-code', @@ -99,19 +114,19 @@ G2 X40 Y40 setState(() { _loading = true; _isPlaying = false; - _playbackProgress = 1; _sourceName = sourceName; _snapshot = null; _status = '正在读取 $sourceName'; }); + _setPlaybackProgress(1); await for (final snapshot in snapshots) { if (!mounted) return; setState(() { _snapshot = snapshot; _status = snapshot.message; - _playbackProgress = 1; }); + _setPlaybackProgress(1); if (snapshot.stage == GcodeLoadStage.parsing) { await Future.delayed(const Duration(milliseconds: 16)); } @@ -125,16 +140,17 @@ G2 X40 Y40 if ((_snapshot?.segments.isEmpty ?? true) || _loading) return; _playbackTimer?.cancel(); + if (_playbackProgress.value >= 1) { + _setPlaybackProgress(0); + } setState(() => _isPlaying = true); _playbackTimer = Timer.periodic(const Duration(milliseconds: 16), (_) { if (!mounted) return; - final next = _playbackProgress + 0.004 * _speedMultiplier; - setState(() { - _playbackProgress = next.clamp(0, 1); - _isPlaying = _playbackProgress < 1; - }); - if (_playbackProgress >= 1) { + final next = _playbackProgress.value + 0.004 * _speedMultiplier; + _setPlaybackProgress(next); + if (_playbackProgress.value >= 1) { _playbackTimer?.cancel(); + setState(() => _isPlaying = false); } }); } @@ -146,26 +162,18 @@ G2 X40 Y40 void _resetPlayback() { _playbackTimer?.cancel(); - setState(() { - _isPlaying = false; - _playbackProgress = 0; - }); + _setPlaybackProgress(0); + setState(() => _isPlaying = false); } void _seekPlayback(double value) { - setState(() => _playbackProgress = value); + _setPlaybackProgress(value); } void _setSpeed(double value) { setState(() => _speedMultiplier = value); } - int _currentCommandIndex(GcodeLoadSnapshot? snapshot) { - final commandCount = snapshot?.commands.length ?? 0; - if (commandCount == 0) return -1; - return (_playbackProgress * commandCount).ceil().clamp(1, commandCount) - 1; - } - @override Widget build(BuildContext context) { final snapshot = _snapshot; @@ -217,27 +225,33 @@ G2 X40 Y40 Expanded( child: LayoutBuilder( builder: (context, constraints) { - final canvas = _CanvasPanel( - snapshot: snapshot, - parsing: _loading, - progress: _playbackProgress, - isPlaying: _isPlaying, - speedMultiplier: _speedMultiplier, - onPlay: _play, - onPause: _pause, - onReset: _resetPlayback, - onSeek: _seekPlayback, - onSpeedChange: _setSpeed, + final canvas = ValueListenableBuilder( + valueListenable: _playbackProgress, + builder: (context, progress, _) => _CanvasPanel( + snapshot: snapshot, + parsing: _loading, + progress: progress, + isPlaying: _isPlaying, + speedMultiplier: _speedMultiplier, + onPlay: _play, + onPause: _pause, + onReset: _resetPlayback, + onSeek: _seekPlayback, + onSpeedChange: _setSpeed, + ), ); - final results = _ResultPanel( - snapshot: snapshot, - currentIndex: _currentCommandIndex(snapshot), - onCommandTap: (index) { - final total = snapshot?.commands.length ?? 0; - if (total == 0) return; - _pause(); - setState(() => _playbackProgress = (index + 1) / total); - }, + final results = ValueListenableBuilder( + valueListenable: _currentCommandIndex, + builder: (context, currentIndex, _) => _ResultPanel( + snapshot: snapshot, + currentIndex: currentIndex, + onCommandTap: (index) { + final total = snapshot?.commands.length ?? 0; + if (total == 0) return; + _pause(); + _setPlaybackProgress((index + 1) / total); + }, + ), ); if (constraints.maxWidth >= 720) { diff --git a/lib/src/rendering/gpu_toolpath_layer.dart b/lib/src/rendering/gpu_toolpath_layer.dart index 12a465e..bbd71a7 100644 --- a/lib/src/rendering/gpu_toolpath_layer.dart +++ b/lib/src/rendering/gpu_toolpath_layer.dart @@ -87,10 +87,12 @@ class _GpuResources { gpu.gpuContext.createDeviceBufferWithCopy(ByteData.sublistView(data)), offsetInBytes: 0, lengthInBytes: data.lengthInBytes); + host = gpu.gpuContext.createHostBuffer(); } final gpu.RenderPipeline pipeline; final gpu.RenderPipeline guides; late final gpu.BufferView quad; + late final gpu.HostBuffer host; ToolpathViewport? viewport; GcodeBounds? suppliedBounds; gpu.GpuImageSurface? surface; @@ -104,6 +106,12 @@ class _GpuResources { surface = null; vertices = null; segments = null; + viewport = null; + suppliedBounds = null; + bounds = null; + size = null; + width = null; + vertexCount = 0; } void prepare(GpuToolpathLayer input, Size newSize, double dpr) { @@ -220,7 +228,7 @@ class _GpuImageCompositor extends CustomPainter { gpu.ColorAttachment(texture: frame.colorTexture))); pass.setColorBlendEnable(true); pass.setColorBlendEquation(gpu.ColorBlendEquation()); - final host = gpu.gpuContext.createHostBuffer(); + final host = resources.host..reset(); _drawGuides(pass, host, size, 0); if (resources.vertexCount > 0) { pass.clearBindings(); @@ -282,5 +290,12 @@ class _GpuImageCompositor extends CustomPainter { } @override - bool shouldRepaint(covariant _GpuImageCompositor oldDelegate) => true; + bool shouldRepaint(covariant _GpuImageCompositor oldDelegate) { + return !identical(resources, oldDelegate.resources) || + !identical(input.segments, oldDelegate.input.segments) || + input.bounds != oldDelegate.input.bounds || + input.progress != oldDelegate.input.progress || + input.style != oldDelegate.input.style || + dpr != oldDelegate.dpr; + } } diff --git a/lib/src/widgets/command_timeline.dart b/lib/src/widgets/command_timeline.dart index 9b8f78b..f747aad 100644 --- a/lib/src/widgets/command_timeline.dart +++ b/lib/src/widgets/command_timeline.dart @@ -3,7 +3,7 @@ import 'package:flutter/material.dart'; import '../models/gcode_command.dart'; import '../parser/gcode_parse_result.dart'; -class CommandTimeline extends StatelessWidget { +class CommandTimeline extends StatefulWidget { const CommandTimeline({ super.key, required this.commands, @@ -20,12 +20,33 @@ class CommandTimeline extends StatelessWidget { final double? maxHeight; @override - Widget build(BuildContext context) { - final items = _buildTimelineItems(); + State createState() => _CommandTimelineState(); +} + +class _CommandTimelineState extends State { + late List<_TimelineItem> _items; + + @override + void initState() { + super.initState(); + _items = _buildTimelineItems(); + } + @override + void didUpdateWidget(covariant CommandTimeline oldWidget) { + super.didUpdateWidget(oldWidget); + if (!identical(widget.commands, oldWidget.commands) || + !identical(widget.errors, oldWidget.errors)) { + _items = _buildTimelineItems(); + } + } + + @override + Widget build(BuildContext context) { return Container( - constraints: - maxHeight != null ? BoxConstraints(maxHeight: maxHeight!) : null, + constraints: widget.maxHeight != null + ? BoxConstraints(maxHeight: widget.maxHeight!) + : null, decoration: BoxDecoration( color: Colors.grey.shade50, borderRadius: BorderRadius.circular(8), @@ -39,12 +60,12 @@ class CommandTimeline extends StatelessWidget { child: Row( children: [ Text( - '指令列表 (${commands.length})', + '指令列表 (${widget.commands.length})', style: Theme.of(context).textTheme.labelMedium?.copyWith( fontWeight: FontWeight.bold, ), ), - if (errors.isNotEmpty) + if (widget.errors.isNotEmpty) Padding( padding: const EdgeInsets.only(left: 8), child: Container( @@ -55,7 +76,7 @@ class CommandTimeline extends StatelessWidget { borderRadius: BorderRadius.circular(4), ), child: Text( - '${errors.length} 错误', + '${widget.errors.length} 错误', style: const TextStyle( fontSize: 11, color: Colors.red, @@ -71,20 +92,20 @@ class CommandTimeline extends StatelessWidget { Flexible( child: ListView.builder( shrinkWrap: true, - itemCount: items.length, + itemCount: _items.length, itemBuilder: (context, index) { - final item = items[index]; + final item = _items[index]; final cmd = item.command; final error = item.error; - final commandIndex = cmd == null ? -1 : commands.indexOf(cmd); + final commandIndex = item.commandIndex; final isCurrent = - commandIndex >= 0 && commandIndex == currentIndex; + commandIndex >= 0 && commandIndex == widget.currentIndex; final hasError = error != null; final code = cmd?.code; return InkWell( - onTap: onTap != null && commandIndex >= 0 - ? () => onTap!(commandIndex) + onTap: widget.onTap != null && commandIndex >= 0 + ? () => widget.onTap!(commandIndex) : null, child: Container( padding: @@ -179,8 +200,9 @@ class CommandTimeline extends StatelessWidget { List<_TimelineItem> _buildTimelineItems() { final items = <_TimelineItem>[ - for (final command in commands) _TimelineItem.command(command), - for (final error in errors) _TimelineItem.error(error), + for (final (index, command) in widget.commands.indexed) + _TimelineItem.command(command, index), + for (final error in widget.errors) _TimelineItem.error(error), ]; items.sort((a, b) => a.lineNumber.compareTo(b.lineNumber)); return items; @@ -193,12 +215,15 @@ class _TimelineItem { required this.rawLine, this.command, this.error, + this.commandIndex = -1, }); - factory _TimelineItem.command(GcodeCommand command) => _TimelineItem._( + factory _TimelineItem.command(GcodeCommand command, int commandIndex) => + _TimelineItem._( lineNumber: command.lineNumber, rawLine: command.rawLine, command: command, + commandIndex: commandIndex, ); factory _TimelineItem.error(GcodeParseError error) => _TimelineItem._( @@ -211,4 +236,5 @@ class _TimelineItem { final String rawLine; final GcodeCommand? command; final GcodeParseError? error; + final int commandIndex; } From 8c6fe5734bd3851c52ebb081b24f261063247a4a Mon Sep 17 00:00:00 2001 From: lizy Date: Sun, 20 Sep 2026 20:12:44 +0800 Subject: [PATCH 3/9] refactor(example): extract session controller and adaptive widgets Move example state and file picking into GcodeSessionController, split the page into app/page/widgets, and cover controller playback with tests. --- example/README.md | 24 +- example/lib/main.dart | 537 +----------------- example/lib/src/app.dart | 18 + example/lib/src/gcode_example_page.dart | 143 +++++ example/lib/src/gcode_session_controller.dart | 150 +++++ .../lib/src/widgets/gcode_canvas_panel.dart | 117 ++++ .../lib/src/widgets/gcode_result_panel.dart | 90 +++ example/lib/src/widgets/gcode_status_bar.dart | 47 ++ .../test/gcode_session_controller_test.dart | 42 ++ 9 files changed, 623 insertions(+), 545 deletions(-) create mode 100644 example/lib/src/app.dart create mode 100644 example/lib/src/gcode_example_page.dart create mode 100644 example/lib/src/gcode_session_controller.dart create mode 100644 example/lib/src/widgets/gcode_canvas_panel.dart create mode 100644 example/lib/src/widgets/gcode_result_panel.dart create mode 100644 example/lib/src/widgets/gcode_status_bar.dart create mode 100644 example/test/gcode_session_controller_test.dart diff --git a/example/README.md b/example/README.md index 25b6ade..382c432 100644 --- a/example/README.md +++ b/example/README.md @@ -13,21 +13,17 @@ It demonstrates the full local workflow: - Show commands and parse errors with `CommandTimeline`. - Preview the generated path with `PlaybackControls`. -The main integration points are: +The example keeps state and platform access in `GcodeSessionController`, while +the page only composes adaptive widgets. The main integration points are: ```dart -final pipeline = GcodeReadlinePipeline( - options: const GcodeReadlineOptions(snapshotBatchSize: 1), -); - -await for (final snapshot in pipeline.load(FileGcodeLineReader(file.path))) { - setState(() => _snapshot = snapshot); -} +final controller = GcodeSessionController(); +await controller.loadSample(); GcodeCanvas( - segments: snapshot.segments, - progress: playbackProgress, - errorCount: snapshot.errors.length, + segments: controller.snapshot?.segments ?? const [], + progress: controller.playbackProgress.value, + errorCount: controller.snapshot?.errors.length ?? 0, ); ``` @@ -36,6 +32,12 @@ Run it from this directory: ```bash flutter run ``` + +Android build verification runs from this directory: + +```sh +flutter build apk --debug +``` # macOS 本机构建兼容入口 若 Xcode 26.6 卡在 `clang -v -E -dM`,从本目录运行: diff --git a/example/lib/main.dart b/example/lib/main.dart index 1fdbe5a..25c6b42 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -1,538 +1,7 @@ -import 'dart:async'; - -import 'package:file_selector/file_selector.dart'; import 'package:flutter/material.dart'; -import 'package:gcode_core/gcode_core.dart'; - -void main() { - runApp(const GcodeCoreExampleApp()); -} - -class GcodeCoreExampleApp extends StatelessWidget { - const GcodeCoreExampleApp({super.key}); - - @override - Widget build(BuildContext context) { - return MaterialApp( - title: 'G-code Core Example', - debugShowCheckedModeBanner: false, - theme: ThemeData( - colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xff2563eb)), - useMaterial3: true, - ), - home: const GcodeExamplePage(), - ); - } -} - -class GcodeExamplePage extends StatefulWidget { - const GcodeExamplePage({super.key}); - - @override - State createState() => _GcodeExamplePageState(); -} - -class _GcodeExamplePageState extends State { - static const _sampleSource = ''' -G0 X0 Y0 -G1 X30 Y0 F1200 -G1 X30 Y18 -G1 X12 Y18 -G0 X6 Y8 -G1 X22 Y8 -G2 X40 Y40 -'''; - - final _pipeline = GcodeReadlinePipeline( - options: const GcodeReadlineOptions(snapshotBatchSize: 200), - ); - - GcodeLoadSnapshot? _snapshot; - String _sourceName = '未选择文件'; - String _status = '请选择本地 G-code 文件,或加载内置示例。'; - bool _loading = false; - bool _isPlaying = false; - final _playbackProgress = ValueNotifier(1); - final _currentCommandIndex = ValueNotifier(-1); - double _speedMultiplier = 1; - Timer? _playbackTimer; - - @override - void dispose() { - _playbackTimer?.cancel(); - _playbackProgress.dispose(); - _currentCommandIndex.dispose(); - super.dispose(); - } - - void _setPlaybackProgress(double value) { - final progress = value.clamp(0.0, 1.0).toDouble(); - _playbackProgress.value = progress; - final commandCount = _snapshot?.commands.length ?? 0; - final index = commandCount == 0 - ? -1 - : (progress * commandCount).ceil().clamp(1, commandCount) - 1; - if (_currentCommandIndex.value != index) { - _currentCommandIndex.value = index; - } - } - - Future _pickAndParseFile() async { - const typeGroup = XTypeGroup( - label: 'G-code', - extensions: ['gcode', 'nc', 'tap', 'txt'], - ); - - final file = await openFile(acceptedTypeGroups: [typeGroup]); - if (file == null) return; - - await _parseSnapshots( - _pipeline.loadFileInBackground(file.path), - sourceName: file.name, - ); - } - - Future _loadSample() { - return _parseReader( - const StringGcodeLineReader(_sampleSource), - sourceName: '内置示例', - ); - } - - Future _parseReader( - GcodeLineReader reader, { - required String sourceName, - }) { - return _parseSnapshots(_pipeline.load(reader), sourceName: sourceName); - } - - Future _parseSnapshots( - Stream snapshots, { - required String sourceName, - }) async { - _playbackTimer?.cancel(); - setState(() { - _loading = true; - _isPlaying = false; - _sourceName = sourceName; - _snapshot = null; - _status = '正在读取 $sourceName'; - }); - _setPlaybackProgress(1); - - await for (final snapshot in snapshots) { - if (!mounted) return; - setState(() { - _snapshot = snapshot; - _status = snapshot.message; - }); - _setPlaybackProgress(1); - if (snapshot.stage == GcodeLoadStage.parsing) { - await Future.delayed(const Duration(milliseconds: 16)); - } - } - - if (!mounted) return; - setState(() => _loading = false); - } - - void _play() { - if ((_snapshot?.segments.isEmpty ?? true) || _loading) return; - - _playbackTimer?.cancel(); - if (_playbackProgress.value >= 1) { - _setPlaybackProgress(0); - } - setState(() => _isPlaying = true); - _playbackTimer = Timer.periodic(const Duration(milliseconds: 16), (_) { - if (!mounted) return; - final next = _playbackProgress.value + 0.004 * _speedMultiplier; - _setPlaybackProgress(next); - if (_playbackProgress.value >= 1) { - _playbackTimer?.cancel(); - setState(() => _isPlaying = false); - } - }); - } - - void _pause() { - _playbackTimer?.cancel(); - setState(() => _isPlaying = false); - } - - void _resetPlayback() { - _playbackTimer?.cancel(); - _setPlaybackProgress(0); - setState(() => _isPlaying = false); - } - - void _seekPlayback(double value) { - _setPlaybackProgress(value); - } - - void _setSpeed(double value) { - setState(() => _speedMultiplier = value); - } - - @override - Widget build(BuildContext context) { - final snapshot = _snapshot; - final compactActions = MediaQuery.sizeOf(context).width < 600; - - return Scaffold( - appBar: AppBar( - title: Text(compactActions ? 'G-code' : 'G-code Core 绘制示例'), - actions: compactActions - ? [ - IconButton( - onPressed: _loading ? null : _loadSample, - icon: const Icon(Icons.data_object), - tooltip: '示例数据', - ), - IconButton( - onPressed: _loading ? null : _pickAndParseFile, - icon: const Icon(Icons.folder_open), - tooltip: '选择 G-code', - ), - const SizedBox(width: 8), - ] - : [ - TextButton.icon( - onPressed: _loading ? null : _loadSample, - icon: const Icon(Icons.data_object), - label: const Text('示例数据'), - ), - const SizedBox(width: 8), - FilledButton.icon( - onPressed: _loading ? null : _pickAndParseFile, - icon: const Icon(Icons.folder_open), - label: const Text('选择 G-code'), - ), - const SizedBox(width: 16), - ], - ), - body: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - _StatusBar( - sourceName: _sourceName, - status: _status, - loading: _loading, - ), - const SizedBox(height: 16), - Expanded( - child: LayoutBuilder( - builder: (context, constraints) { - final canvas = ValueListenableBuilder( - valueListenable: _playbackProgress, - builder: (context, progress, _) => _CanvasPanel( - snapshot: snapshot, - parsing: _loading, - progress: progress, - isPlaying: _isPlaying, - speedMultiplier: _speedMultiplier, - onPlay: _play, - onPause: _pause, - onReset: _resetPlayback, - onSeek: _seekPlayback, - onSpeedChange: _setSpeed, - ), - ); - final results = ValueListenableBuilder( - valueListenable: _currentCommandIndex, - builder: (context, currentIndex, _) => _ResultPanel( - snapshot: snapshot, - currentIndex: currentIndex, - onCommandTap: (index) { - final total = snapshot?.commands.length ?? 0; - if (total == 0) return; - _pause(); - _setPlaybackProgress((index + 1) / total); - }, - ), - ); - - if (constraints.maxWidth >= 720) { - return Row( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Expanded(flex: 3, child: canvas), - const SizedBox(width: 16), - SizedBox(width: 360, child: results), - ], - ); - } - - final canvasHeight = (constraints.maxHeight * 0.58) - .clamp(280.0, 420.0) - .toDouble(); - return ListView( - children: [ - SizedBox(height: canvasHeight, child: canvas), - const SizedBox(height: 16), - SizedBox(height: 420, child: results), - ], - ); - }, - ), - ), - ], - ), - ), - ); - } -} - -class _StatusBar extends StatelessWidget { - const _StatusBar({ - required this.sourceName, - required this.status, - required this.loading, - }); - - final String sourceName; - final String status; - final bool loading; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - - return DecoratedBox( - decoration: BoxDecoration( - border: Border.all(color: theme.colorScheme.outlineVariant), - borderRadius: BorderRadius.circular(8), - ), - child: Padding( - padding: const EdgeInsets.all(12), - child: Row( - children: [ - if (loading) - const SizedBox.square( - dimension: 18, - child: CircularProgressIndicator(strokeWidth: 2), - ) - else - const Icon(Icons.route), - const SizedBox(width: 12), - Expanded( - child: Text( - '$sourceName - $status', - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - ), - ], - ), - ), - ); - } -} - -class _CanvasPanel extends StatelessWidget { - const _CanvasPanel({ - required this.snapshot, - required this.parsing, - required this.progress, - required this.isPlaying, - required this.speedMultiplier, - required this.onPlay, - required this.onPause, - required this.onReset, - required this.onSeek, - required this.onSpeedChange, - }); - - final GcodeLoadSnapshot? snapshot; - final bool parsing; - final double progress; - final bool isPlaying; - final double speedMultiplier; - final VoidCallback onPlay; - final VoidCallback onPause; - final VoidCallback onReset; - final ValueChanged onSeek; - final ValueChanged onSpeedChange; - - @override - Widget build(BuildContext context) { - final segments = snapshot?.segments ?? const []; - final errors = snapshot?.errors.length ?? 0; - - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Expanded( - child: Stack( - children: [ - Positioned.fill( - child: GcodeCanvas( - segments: segments, - progress: parsing ? 1 : progress, - errorCount: errors, - bounds: snapshot?.bounds, - ), - ), - Positioned( - left: 12, - top: 12, - child: _CanvasLegend( - parsing: parsing, - segments: segments.length, - mainSegments: segments - .where( - (segment) => segment.type == GcodeSegmentType.linear, - ) - .length, - ), - ), - ], - ), - ), - const SizedBox(height: 12), - PlaybackControls( - isPlaying: isPlaying, - progress: parsing ? 1 : progress, - speedMultiplier: speedMultiplier, - onPlay: onPlay, - onPause: onPause, - onReset: onReset, - onSeek: onSeek, - onSpeedChange: onSpeedChange, - ), - ], - ); - } -} - -class _CanvasLegend extends StatelessWidget { - const _CanvasLegend({ - required this.parsing, - required this.segments, - required this.mainSegments, - }); - - final bool parsing; - final int segments; - final int mainSegments; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - - return DecoratedBox( - decoration: BoxDecoration( - color: theme.colorScheme.surface.withValues(alpha: 0.9), - border: Border.all(color: theme.colorScheme.outlineVariant), - borderRadius: BorderRadius.circular(8), - ), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), - child: DefaultTextStyle( - style: theme.textTheme.labelMedium!, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(parsing ? '动态解析绘制中' : 'GPU 轨迹绘制'), - const SizedBox(height: 4), - Text('主线段 G1: $mainSegments'), - Text('移动段 G0/G1: $segments'), - ], - ), - ), - ), - ); - } -} - -class _ResultPanel extends StatelessWidget { - const _ResultPanel({ - required this.snapshot, - required this.currentIndex, - required this.onCommandTap, - }); - - final GcodeLoadSnapshot? snapshot; - final int currentIndex; - final ValueChanged onCommandTap; - - @override - Widget build(BuildContext context) { - final current = snapshot; - - if (current == null) { - return const Center(child: Text('解析结果会显示在这里')); - } - - return ListView( - children: [ - Wrap( - spacing: 8, - runSpacing: 8, - children: [ - _Metric(label: '行数', value: current.linesRead.toString()), - _Metric(label: '指令', value: current.commands.length.toString()), - _Metric(label: '轨迹', value: current.segments.length.toString()), - _Metric(label: '错误', value: current.errors.length.toString()), - ], - ), - const SizedBox(height: 16), - CommandTimeline( - commands: current.commands, - errors: current.errors, - currentIndex: currentIndex, - onTap: onCommandTap, - maxHeight: 360, - ), - const SizedBox(height: 16), - Text('解析错误', style: Theme.of(context).textTheme.titleMedium), - const SizedBox(height: 8), - if (current.errors.isEmpty) - const Text('无') - else - for (final error in current.errors) - ListTile( - dense: true, - leading: const Icon(Icons.warning_amber), - title: Text('第 ${error.lineNumber} 行'), - subtitle: Text('${error.message}\n${error.rawLine}'), - ), - ], - ); - } -} - -class _Metric extends StatelessWidget { - const _Metric({required this.label, required this.value}); - final String label; - final String value; +import 'src/app.dart'; - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); +export 'src/app.dart'; - return SizedBox( - width: 78, - child: DecoratedBox( - decoration: BoxDecoration( - color: theme.colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(8), - ), - child: Padding( - padding: const EdgeInsets.all(10), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(label, style: theme.textTheme.labelMedium), - const SizedBox(height: 4), - Text(value, style: theme.textTheme.titleLarge), - ], - ), - ), - ), - ); - } -} +void main() => runApp(const GcodeCoreExampleApp()); diff --git a/example/lib/src/app.dart b/example/lib/src/app.dart new file mode 100644 index 0000000..97db949 --- /dev/null +++ b/example/lib/src/app.dart @@ -0,0 +1,18 @@ +import 'package:flutter/material.dart'; + +import 'gcode_example_page.dart'; + +class GcodeCoreExampleApp extends StatelessWidget { + const GcodeCoreExampleApp({super.key}); + + @override + Widget build(BuildContext context) => MaterialApp( + title: 'G-code Core Example', + debugShowCheckedModeBanner: false, + theme: ThemeData( + colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xff2563eb)), + useMaterial3: true, + ), + home: const GcodeExamplePage(), + ); +} diff --git a/example/lib/src/gcode_example_page.dart b/example/lib/src/gcode_example_page.dart new file mode 100644 index 0000000..ee15552 --- /dev/null +++ b/example/lib/src/gcode_example_page.dart @@ -0,0 +1,143 @@ +import 'package:flutter/material.dart'; + +import 'gcode_session_controller.dart'; +import 'widgets/gcode_canvas_panel.dart'; +import 'widgets/gcode_result_panel.dart'; +import 'widgets/gcode_status_bar.dart'; + +class GcodeExamplePage extends StatefulWidget { + const GcodeExamplePage({super.key, this.controller}); + + final GcodeSessionController? controller; + + @override + State createState() => _GcodeExamplePageState(); +} + +class _GcodeExamplePageState extends State { + late final GcodeSessionController _controller = + widget.controller ?? GcodeSessionController(); + late final bool _ownsController = widget.controller == null; + + @override + void dispose() { + if (_ownsController) _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ListenableBuilder( + listenable: _controller, + builder: (context, _) { + final compact = MediaQuery.sizeOf(context).width < 600; + return Scaffold( + appBar: AppBar( + title: Text(compact ? 'G-code' : 'G-code Core 绘制示例'), + actions: _buildActions(compact), + ), + body: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + GcodeStatusBar( + sourceName: _controller.sourceName, + status: _controller.status, + loading: _controller.loading, + ), + const SizedBox(height: 16), + Expanded(child: _buildWorkspace()), + ], + ), + ), + ); + }, + ); + } + + List _buildActions(bool compact) { + final sample = _controller.loading ? null : _controller.loadSample; + final pick = _controller.loading ? null : _controller.pickAndParseFile; + if (compact) { + return [ + IconButton( + onPressed: sample, + icon: const Icon(Icons.data_object), + tooltip: '示例数据', + ), + IconButton( + onPressed: pick, + icon: const Icon(Icons.folder_open), + tooltip: '选择 G-code', + ), + const SizedBox(width: 8), + ]; + } + return [ + TextButton.icon( + onPressed: sample, + icon: const Icon(Icons.data_object), + label: const Text('示例数据'), + ), + const SizedBox(width: 8), + FilledButton.icon( + onPressed: pick, + icon: const Icon(Icons.folder_open), + label: const Text('选择 G-code'), + ), + const SizedBox(width: 16), + ]; + } + + Widget _buildWorkspace() { + final canvas = ValueListenableBuilder( + valueListenable: _controller.playbackProgress, + builder: (context, progress, _) => GcodeCanvasPanel( + snapshot: _controller.snapshot, + parsing: _controller.loading, + progress: progress, + isPlaying: _controller.isPlaying, + speedMultiplier: _controller.speedMultiplier, + onPlay: _controller.play, + onPause: _controller.pause, + onReset: _controller.resetPlayback, + onSeek: _controller.seekPlayback, + onSpeedChange: _controller.setSpeed, + ), + ); + final results = ValueListenableBuilder( + valueListenable: _controller.currentCommandIndex, + builder: (context, index, _) => GcodeResultPanel( + snapshot: _controller.snapshot, + currentIndex: index, + onCommandTap: _controller.selectCommand, + ), + ); + + return LayoutBuilder( + builder: (context, constraints) { + if (constraints.maxWidth >= 720) { + return Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded(flex: 3, child: canvas), + const SizedBox(width: 16), + SizedBox(width: 360, child: results), + ], + ); + } + final canvasHeight = (constraints.maxHeight * 0.58) + .clamp(280.0, 420.0) + .toDouble(); + return ListView( + children: [ + SizedBox(height: canvasHeight, child: canvas), + const SizedBox(height: 16), + SizedBox(height: 420, child: results), + ], + ); + }, + ); + } +} diff --git a/example/lib/src/gcode_session_controller.dart b/example/lib/src/gcode_session_controller.dart new file mode 100644 index 0000000..83e7ce8 --- /dev/null +++ b/example/lib/src/gcode_session_controller.dart @@ -0,0 +1,150 @@ +import 'dart:async'; + +import 'package:file_selector/file_selector.dart'; +import 'package:flutter/foundation.dart'; +import 'package:gcode_core/gcode_core.dart'; + +class GcodeSessionController extends ChangeNotifier { + GcodeSessionController({GcodeReadlinePipeline? pipeline}) + : _pipeline = + pipeline ?? + GcodeReadlinePipeline( + options: const GcodeReadlineOptions(snapshotBatchSize: 200), + ); + + static const sampleSource = ''' +G0 X0 Y0 +G1 X30 Y0 F1200 +G1 X30 Y18 +G1 X12 Y18 +G0 X6 Y8 +G1 X22 Y8 +G2 X40 Y40 +'''; + + final GcodeReadlinePipeline _pipeline; + final playbackProgress = ValueNotifier(1); + final currentCommandIndex = ValueNotifier(-1); + + GcodeLoadSnapshot? snapshot; + String sourceName = '未选择文件'; + String status = '请选择本地 G-code 文件,或加载内置示例。'; + bool loading = false; + bool isPlaying = false; + double speedMultiplier = 1; + + Timer? _playbackTimer; + bool _disposed = false; + + Future pickAndParseFile() async { + const types = XTypeGroup( + label: 'G-code', + extensions: ['gcode', 'nc', 'tap', 'txt'], + ); + final file = await openFile(acceptedTypeGroups: [types]); + if (file == null || _disposed) return; + await parseSnapshots( + _pipeline.loadFileInBackground(file.path), + sourceName: file.name, + ); + } + + Future loadSample() => parseSnapshots( + _pipeline.load(const StringGcodeLineReader(sampleSource)), + sourceName: '内置示例', + ); + + Future parseSnapshots( + Stream snapshots, { + required String sourceName, + }) async { + _playbackTimer?.cancel(); + loading = true; + isPlaying = false; + this.sourceName = sourceName; + snapshot = null; + status = '正在读取 $sourceName'; + _setPlaybackProgress(1); + notifyListeners(); + + await for (final next in snapshots) { + if (_disposed) return; + snapshot = next; + status = next.message; + _setPlaybackProgress(1); + notifyListeners(); + if (next.stage == GcodeLoadStage.parsing) { + await Future.delayed(const Duration(milliseconds: 16)); + } + } + + if (_disposed) return; + loading = false; + notifyListeners(); + } + + void play() { + if ((snapshot?.segments.isEmpty ?? true) || loading) return; + _playbackTimer?.cancel(); + if (playbackProgress.value >= 1) _setPlaybackProgress(0); + isPlaying = true; + notifyListeners(); + _playbackTimer = Timer.periodic(const Duration(milliseconds: 16), (_) { + if (_disposed) return; + _setPlaybackProgress(playbackProgress.value + 0.004 * speedMultiplier); + if (playbackProgress.value >= 1) { + _playbackTimer?.cancel(); + isPlaying = false; + notifyListeners(); + } + }); + } + + void pause() { + _playbackTimer?.cancel(); + isPlaying = false; + notifyListeners(); + } + + void resetPlayback() { + _playbackTimer?.cancel(); + _setPlaybackProgress(0); + isPlaying = false; + notifyListeners(); + } + + void seekPlayback(double value) => _setPlaybackProgress(value); + + void setSpeed(double value) { + speedMultiplier = value; + notifyListeners(); + } + + void selectCommand(int index) { + final total = snapshot?.commands.length ?? 0; + if (total == 0) return; + pause(); + _setPlaybackProgress((index + 1) / total); + } + + void _setPlaybackProgress(double value) { + final progress = value.clamp(0.0, 1.0).toDouble(); + playbackProgress.value = progress; + final count = snapshot?.commands.length ?? 0; + final index = count == 0 + ? -1 + : (progress * count).ceil().clamp(1, count) - 1; + if (currentCommandIndex.value != index) { + currentCommandIndex.value = index; + } + } + + @override + void dispose() { + _disposed = true; + _playbackTimer?.cancel(); + playbackProgress.dispose(); + currentCommandIndex.dispose(); + super.dispose(); + } +} diff --git a/example/lib/src/widgets/gcode_canvas_panel.dart b/example/lib/src/widgets/gcode_canvas_panel.dart new file mode 100644 index 0000000..249419a --- /dev/null +++ b/example/lib/src/widgets/gcode_canvas_panel.dart @@ -0,0 +1,117 @@ +import 'package:flutter/material.dart'; +import 'package:gcode_core/gcode_core.dart'; + +class GcodeCanvasPanel extends StatelessWidget { + const GcodeCanvasPanel({ + super.key, + required this.snapshot, + required this.parsing, + required this.progress, + required this.isPlaying, + required this.speedMultiplier, + required this.onPlay, + required this.onPause, + required this.onReset, + required this.onSeek, + required this.onSpeedChange, + }); + + final GcodeLoadSnapshot? snapshot; + final bool parsing; + final double progress; + final bool isPlaying; + final double speedMultiplier; + final VoidCallback onPlay; + final VoidCallback onPause; + final VoidCallback onReset; + final ValueChanged onSeek; + final ValueChanged onSpeedChange; + + @override + Widget build(BuildContext context) { + final segments = snapshot?.segments ?? const []; + final errors = snapshot?.errors.length ?? 0; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded( + child: Stack( + children: [ + Positioned.fill( + child: GcodeCanvas( + segments: segments, + progress: parsing ? 1 : progress, + errorCount: errors, + bounds: snapshot?.bounds, + ), + ), + Positioned( + left: 12, + top: 12, + child: _CanvasLegend( + parsing: parsing, + segments: segments.length, + mainSegments: segments + .where( + (segment) => segment.type == GcodeSegmentType.linear, + ) + .length, + ), + ), + ], + ), + ), + const SizedBox(height: 12), + PlaybackControls( + isPlaying: isPlaying, + progress: parsing ? 1 : progress, + speedMultiplier: speedMultiplier, + onPlay: onPlay, + onPause: onPause, + onReset: onReset, + onSeek: onSeek, + onSpeedChange: onSpeedChange, + ), + ], + ); + } +} + +class _CanvasLegend extends StatelessWidget { + const _CanvasLegend({ + required this.parsing, + required this.segments, + required this.mainSegments, + }); + + final bool parsing; + final int segments; + final int mainSegments; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return DecoratedBox( + decoration: BoxDecoration( + color: theme.colorScheme.surface.withValues(alpha: 0.9), + border: Border.all(color: theme.colorScheme.outlineVariant), + borderRadius: BorderRadius.circular(8), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + child: DefaultTextStyle( + style: theme.textTheme.labelMedium!, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(parsing ? '动态解析绘制中' : 'GPU 轨迹绘制'), + const SizedBox(height: 4), + Text('主线段 G1: $mainSegments'), + Text('移动段 G0/G1: $segments'), + ], + ), + ), + ), + ); + } +} diff --git a/example/lib/src/widgets/gcode_result_panel.dart b/example/lib/src/widgets/gcode_result_panel.dart new file mode 100644 index 0000000..e659f60 --- /dev/null +++ b/example/lib/src/widgets/gcode_result_panel.dart @@ -0,0 +1,90 @@ +import 'package:flutter/material.dart'; +import 'package:gcode_core/gcode_core.dart'; + +class GcodeResultPanel extends StatelessWidget { + const GcodeResultPanel({ + super.key, + required this.snapshot, + required this.currentIndex, + required this.onCommandTap, + }); + + final GcodeLoadSnapshot? snapshot; + final int currentIndex; + final ValueChanged onCommandTap; + + @override + Widget build(BuildContext context) { + final current = snapshot; + if (current == null) { + return const Center(child: Text('解析结果会显示在这里')); + } + return ListView( + children: [ + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + _Metric(label: '行数', value: current.linesRead.toString()), + _Metric(label: '指令', value: current.commands.length.toString()), + _Metric(label: '轨迹', value: current.segments.length.toString()), + _Metric(label: '错误', value: current.errors.length.toString()), + ], + ), + const SizedBox(height: 16), + CommandTimeline( + commands: current.commands, + errors: current.errors, + currentIndex: currentIndex, + onTap: onCommandTap, + maxHeight: 360, + ), + const SizedBox(height: 16), + Text('解析错误', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 8), + if (current.errors.isEmpty) + const Text('无') + else + for (final error in current.errors) + ListTile( + dense: true, + leading: const Icon(Icons.warning_amber), + title: Text('第 ${error.lineNumber} 行'), + subtitle: Text('${error.message}\n${error.rawLine}'), + ), + ], + ); + } +} + +class _Metric extends StatelessWidget { + const _Metric({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return SizedBox( + width: 78, + child: DecoratedBox( + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + ), + child: Padding( + padding: const EdgeInsets.all(10), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: theme.textTheme.labelMedium), + const SizedBox(height: 4), + Text(value, style: theme.textTheme.titleLarge), + ], + ), + ), + ), + ); + } +} diff --git a/example/lib/src/widgets/gcode_status_bar.dart b/example/lib/src/widgets/gcode_status_bar.dart new file mode 100644 index 0000000..0571648 --- /dev/null +++ b/example/lib/src/widgets/gcode_status_bar.dart @@ -0,0 +1,47 @@ +import 'package:flutter/material.dart'; + +class GcodeStatusBar extends StatelessWidget { + const GcodeStatusBar({ + super.key, + required this.sourceName, + required this.status, + required this.loading, + }); + + final String sourceName; + final String status; + final bool loading; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return DecoratedBox( + decoration: BoxDecoration( + border: Border.all(color: theme.colorScheme.outlineVariant), + borderRadius: BorderRadius.circular(8), + ), + child: Padding( + padding: const EdgeInsets.all(12), + child: Row( + children: [ + if (loading) + const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + else + const Icon(Icons.route), + const SizedBox(width: 12), + Expanded( + child: Text( + '$sourceName - $status', + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ); + } +} diff --git a/example/test/gcode_session_controller_test.dart b/example/test/gcode_session_controller_test.dart new file mode 100644 index 0000000..1d4e86b --- /dev/null +++ b/example/test/gcode_session_controller_test.dart @@ -0,0 +1,42 @@ +import 'package:example/src/gcode_session_controller.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:gcode_core/gcode_core.dart'; + +void main() { + test('loads the sample and exposes its parse error', () async { + final controller = GcodeSessionController(); + addTearDown(controller.dispose); + + await controller.loadSample(); + + expect(controller.loading, isFalse); + expect(controller.snapshot?.stage, GcodeLoadStage.ready); + expect(controller.snapshot?.errors, hasLength(1)); + expect(controller.sourceName, '内置示例'); + }); + + test('play restarts a completed toolpath from the beginning', () async { + final controller = GcodeSessionController(); + addTearDown(controller.dispose); + await controller.loadSample(); + + expect(controller.playbackProgress.value, 1); + controller.play(); + + expect(controller.isPlaying, isTrue); + expect(controller.playbackProgress.value, 0); + controller.pause(); + }); + + test('selecting a command seeks playback and updates its index', () async { + final controller = GcodeSessionController(); + addTearDown(controller.dispose); + await controller.loadSample(); + final commandCount = controller.snapshot!.commands.length; + + controller.selectCommand(2); + + expect(controller.currentCommandIndex.value, 2); + expect(controller.playbackProgress.value, closeTo(3 / commandCount, 1e-9)); + }); +} From 80b1f18b4e3ea210a615c4e056933b435fb14677 Mon Sep 17 00:00:00 2001 From: lizy Date: Sun, 20 Sep 2026 20:12:45 +0800 Subject: [PATCH 4/9] ci: add quality, build, and platform-contract workflows Document the platform contract in AGENTS.md, run the example tests in the local gate, and guard declared hosts in CI. --- .github/workflows/ci.yml | 79 ++++++++++++++++++++++++++++++++++++++++ AGENTS.md | 73 +++++++++++++++++++++++++++++++++++++ README.md | 7 ++++ 3 files changed, 159 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 AGENTS.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..da22185 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,79 @@ +name: CI + +on: + push: + pull_request: + workflow_dispatch: + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +env: + FLUTTER_VERSION: 3.47.2 + +jobs: + quality: + name: Analyze and test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: subosito/flutter-action@v2 + with: + flutter-version: ${{ env.FLUTTER_VERSION }} + channel: stable + cache: true + - run: flutter pub get + - run: flutter analyze + - run: flutter test + - run: flutter test + working-directory: example + + android: + name: Android debug build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: subosito/flutter-action@v2 + with: + flutter-version: ${{ env.FLUTTER_VERSION }} + channel: stable + cache: true + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 17 + - run: flutter pub get + working-directory: example + - run: flutter build apk --debug + working-directory: example + + macos: + name: macOS release build + runs-on: macos-15 + steps: + - uses: actions/checkout@v4 + - uses: subosito/flutter-action@v2 + with: + flutter-version: ${{ env.FLUTTER_VERSION }} + channel: stable + cache: true + - run: flutter precache --macos + - run: flutter pub get + working-directory: example + - run: python3 tool/macos_run.py --mode release --build-only + working-directory: example + + platform-contract: + name: Platform contract + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Verify declared platform hosts + run: | + test -d example/android + test -d example/macos + test ! -d example/web + test ! -d example/ios + test ! -d example/linux + test ! -d example/windows diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..c8e30ff --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,73 @@ +# Agent guide + +## Repository purpose + +`gcode_core` parses a deliberately small G-code subset, builds two-dimensional +toolpaths, and renders them through Flutter GPU. The package is GPU-only; do not +silently introduce a second renderer or claim a platform is supported from a +successful cross-compile alone. + +## Layout and ownership + +- `lib/src/parser`, `models`, `services`: platform-neutral parsing and geometry. +- `lib/src/data/readers`: native file-system readers; currently uses `dart:io`. +- `lib/src/rendering`: Flutter GPU resources, geometry, surfaces, and shaders. +- `lib/src/widgets`: reusable package UI. +- `shaders`: source shaders plus the checked-in generated shader bundle. +- `example/lib/src/gcode_session_controller.dart`: example state and playback. +- `example/lib/src/gcode_example_page.dart`: page composition only. +- `example/lib/src/widgets`: independently testable example UI. +- `example/lib/gpu_validation.dart`: native GPU lifecycle/performance harness. +- `docs/evidence`: durable runtime evidence; do not rewrite historical evidence. + +## Current platform contract + +- macOS: primary validated GPU platform. +- Android: host and APK build are present; runtime GPU/device evidence is pending. +- iOS, Linux, Windows: not supported until hosts, builds, and native evidence land. +- Web: unsupported while `dart:io` and the GPU-only renderer remain unconditional. + +Update the platform-contract CI job and this section together when adding a +host. A build is only build evidence. Runtime support requires a platform report +with device/OS, Flutter revision, artifact revision, file-picker behavior, GPU +initialization, screenshots, and frame/memory measurements. + +## Change boundaries + +- Keep parsing behavior out of widgets. +- Keep file-picker calls in the example controller or a platform service. +- Treat segment lists as immutable; replace the list when geometry changes. +- Reuse GPU buffers and surfaces across frames. Dispose `ui.Image` handles. +- Do not rebuild geometry for playback-only progress changes. +- Preserve unrelated evidence and generated platform files. + +## Test growth order + +1. Pure unit tests for parser, bounds, builders, and viewport math. +2. Controller tests for loading, playback, replay, seeking, and disposal. +3. Widget tests at 320, 600, 720, and desktop widths. +4. Android emulator integration tests for picker cancellation and sample load. +5. Native GPU profile runs using `gpu_validation.dart`. +6. Add iOS/Windows/Linux build jobs only with their corresponding host changes. + +Minimum local gate from the repository root: + +```sh +flutter analyze +flutter test +(cd example && flutter test) +``` + +Run Android builds from `example`, not the package root: + +```sh +cd example +flutter build apk --debug +``` + +macOS uses its checked compatibility entrypoint: + +```sh +cd example +python3 tool/macos_run.py --mode release --build-only +``` diff --git a/README.md b/README.md index b482ba7..74d33b9 100644 --- a/README.md +++ b/README.md @@ -92,8 +92,15 @@ fallback renderer. ```bash flutter test +(cd example && flutter test) ``` +CI keeps separate quality, Android build, macOS build, and platform-contract +jobs. Android currently has build evidence; native GPU runtime acceptance is +still pending. iOS, Linux, Windows, and Web are intentionally not declared as +supported hosts yet. See `AGENTS.md` for the evidence required when adding a +platform and the preferred order for growing tests. + ## Example Run the Flutter example app: From e219cd193c1c5738ad24f8a032d6f2425dab7dbc Mon Sep 17 00:00:00 2001 From: lizy Date: Sun, 20 Sep 2026 20:12:46 +0800 Subject: [PATCH 5/9] fix(example): allow selecting macOS build architecture Default the macOS run helper to the host architecture and expose an --arch flag instead of hardcoding arm64. --- example/tool/macos_run.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/example/tool/macos_run.py b/example/tool/macos_run.py index 76d7be6..646fa7e 100644 --- a/example/tool/macos_run.py +++ b/example/tool/macos_run.py @@ -3,6 +3,7 @@ import argparse import os import pathlib +import platform import plistlib import subprocess @@ -10,6 +11,7 @@ parser.add_argument('--mode', choices=['debug', 'profile', 'release'], default='debug') parser.add_argument('--target', default='lib/main.dart') parser.add_argument('--build-only', action='store_true') +parser.add_argument('--arch', choices=['arm64', 'x86_64'], default=platform.machine()) args = parser.parse_args() root = pathlib.Path(__file__).resolve().parents[1] subprocess.run(['python3',str(root/'tool/build_gpu_shaders.py')],check=True) @@ -17,7 +19,7 @@ subprocess.run(['flutter','build','macos',f'--{args.mode}','--config-only','-t',args.target],cwd=root,check=True) subprocess.run(['xcodebuild','-workspace','macos/Runner.xcworkspace','-scheme','Runner', '-configuration',args.mode.capitalize(),'-derivedDataPath','build/macos', - '-destination','platform=macOS,arch=arm64', + '-destination',f'platform=macOS,arch={args.arch}', 'CC='+str(root/'tool/macos/compiler_probe.py'),'COMPILER_INDEX_STORE_ENABLE=NO'],cwd=root,check=True) products=root/'build/macos/Build/Products'/args.mode.capitalize() apps=list(products.glob('*.app')) From 3a4d45e22f59ac06b5dd9328f870b047de132943 Mon Sep 17 00:00:00 2001 From: lizy Date: Mon, 21 Sep 2026 10:54:44 +0800 Subject: [PATCH 6/9] feat(android): constrain example to API 29+ and arm64-v8a Pin minSdk to 29 and filter ABIs to arm64-v8a, and document the Android support contract in AGENTS.md and the READMEs. --- AGENTS.md | 9 ++++++--- README.md | 5 +++-- example/README.md | 11 +++++++++-- example/android/app/build.gradle.kts | 8 +++++++- 4 files changed, 25 insertions(+), 8 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index c8e30ff..e4d0c24 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,7 +23,9 @@ successful cross-compile alone. ## Current platform contract - macOS: primary validated GPU platform. -- Android: host and APK build are present; runtime GPU/device evidence is pending. +- Android: API 29+ and ARM64-only. The host and APK build are present; runtime + GPU/device evidence is pending. Do not add ARM32 or x86 compatibility without + an explicit product decision. - iOS, Linux, Windows: not supported until hosts, builds, and native evidence land. - Web: unsupported while `dart:io` and the GPU-only renderer remain unconditional. @@ -46,7 +48,8 @@ initialization, screenshots, and frame/memory measurements. 1. Pure unit tests for parser, bounds, builders, and viewport math. 2. Controller tests for loading, playback, replay, seeking, and disposal. 3. Widget tests at 320, 600, 720, and desktop widths. -4. Android emulator integration tests for picker cancellation and sample load. +4. Android ARM64 API 29 and API 35 integration tests for picker cancellation + and sample load. Do not use x86/x86_64 emulator evidence. 5. Native GPU profile runs using `gpu_validation.dart`. 6. Add iOS/Windows/Linux build jobs only with their corresponding host changes. @@ -62,7 +65,7 @@ Run Android builds from `example`, not the package root: ```sh cd example -flutter build apk --debug +flutter build apk --debug --target-platform android-arm64 ``` macOS uses its checked compatibility entrypoint: diff --git a/README.md b/README.md index 74d33b9..c889e39 100644 --- a/README.md +++ b/README.md @@ -96,8 +96,9 @@ flutter test ``` CI keeps separate quality, Android build, macOS build, and platform-contract -jobs. Android currently has build evidence; native GPU runtime acceptance is -still pending. iOS, Linux, Windows, and Web are intentionally not declared as +jobs. Android is deliberately constrained to API 29+ and `arm64-v8a`; it +currently has build evidence, while native GPU runtime acceptance is still +pending. iOS, Linux, Windows, and Web are intentionally not declared as supported hosts yet. See `AGENTS.md` for the evidence required when adding a platform and the preferred order for growing tests. diff --git a/example/README.md b/example/README.md index 382c432..4f72f2a 100644 --- a/example/README.md +++ b/example/README.md @@ -33,10 +33,17 @@ Run it from this directory: flutter run ``` -Android build verification runs from this directory: +Android support is intentionally limited to API 29+ on `arm64-v8a`. Build +verification runs from this directory: ```sh -flutter build apk --debug +flutter build apk --debug --target-platform android-arm64 +``` + +For Play distribution, keep the same ABI contract: + +```sh +flutter build appbundle --release --target-platform android-arm64 ``` # macOS 本机构建兼容入口 diff --git a/example/android/app/build.gradle.kts b/example/android/app/build.gradle.kts index 74caf01..d22d0fb 100644 --- a/example/android/app/build.gradle.kts +++ b/example/android/app/build.gradle.kts @@ -19,8 +19,14 @@ android { applicationId = "com.example.example" // You can update the following values to match your application needs. // For more information, see: https://flutter.dev/to/review-gradle-config. - minSdk = flutter.minSdkVersion + // Flutter GPU/Impeller is the product baseline; older Android versions + // and non-ARM64 devices are intentionally outside the support contract. + minSdk = 29 targetSdk = flutter.targetSdkVersion + ndk { + abiFilters.clear() + abiFilters += "arm64-v8a" + } // Uses the version code from pubspec.yaml. When using split APKs, 1000 * ABI_VERSION // is added automatically by Flutter. (https://developer.android.com/studio/build/configure-apk-splits#configure-APK-versions) // You can force using the value of versionCode by specifying the `-P force-version-code-ignoring-abi=true` From 284580935121005150d4f958c3a50fb72bddec9d Mon Sep 17 00:00:00 2001 From: lizy Date: Mon, 21 Sep 2026 10:54:46 +0800 Subject: [PATCH 7/9] ci: enforce Android API and ABI contract Build the debug APK for android-arm64 only and assert minSdk 29 plus a single arm64-v8a native ABI, with matching platform-contract greps. --- .github/workflows/ci.yml | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index da22185..12ba319 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,8 +45,14 @@ jobs: java-version: 17 - run: flutter pub get working-directory: example - - run: flutter build apk --debug + - run: flutter build apk --debug --target-platform android-arm64 working-directory: example + - name: Verify API and ABI contract in APK + working-directory: example + run: | + apk=build/app/outputs/flutter-apk/app-debug.apk + test "$(apkanalyzer manifest min-sdk "$apk")" = "29" + test "$(unzip -Z1 "$apk" | awk -F/ '/^lib\// {print $2}' | sort -u)" = "arm64-v8a" macos: name: macOS release build @@ -77,3 +83,5 @@ jobs: test ! -d example/ios test ! -d example/linux test ! -d example/windows + grep -F 'minSdk = 29' example/android/app/build.gradle.kts + grep -F 'abiFilters += "arm64-v8a"' example/android/app/build.gradle.kts From c215d74588296ccb3bc85df7bc9003c498f7b560 Mon Sep 17 00:00:00 2001 From: lizy Date: Wed, 23 Sep 2026 16:30:26 +0800 Subject: [PATCH 8/9] release: prepare gcode_core 0.2.0 --- .pubignore | 10 +++++++ CHANGELOG.md | 14 ++++++++++ LICENSE | 21 +++++++++++++++ README.md | 64 +++++++++++++++++++++++++++++--------------- example/pubspec.lock | 2 +- pubspec.yaml | 5 ++-- 6 files changed, 91 insertions(+), 25 deletions(-) create mode 100644 .pubignore create mode 100644 LICENSE diff --git a/.pubignore b/.pubignore new file mode 100644 index 0000000..609b070 --- /dev/null +++ b/.pubignore @@ -0,0 +1,10 @@ +.github/ +.agents/ +.codex/ +build/ +docs/ +AGENTS.md +AI_ANALYSIS.md +CONTEXT.md +OWNERS.md +PHASE_SUMMARY.md diff --git a/CHANGELOG.md b/CHANGELOG.md index fcada0a..49d5664 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## 0.2.0 — 2026-09-23 + +First stable package release. It promotes the Flutter GPU renderer from the +macOS prerelease and adds the Android API 29+ ARM64 host contract, adaptive +example UI, session-controller extraction, and CI coverage for package tests, +Android builds, macOS builds, and declared platform support. + +### Platform support + +- macOS: Flutter GPU runtime baseline validated. +- Android: API 29+ ARM64 host, build, and physical-device runtime validated. +- Web, Windows, Linux, and iOS: not supported by the GPU renderer in this + release. + ## 0.2.0-dev.1 — 2026-09-06 First macOS prerelease, distributed by Git tag / GitHub Release. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..cc3c17c --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 lizy-coding + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index c889e39..3b5f1c4 100644 --- a/README.md +++ b/README.md @@ -2,23 +2,37 @@ ![example](https://github.com/lizy-coding/gcode_core/blob/master/gcode_print.gif) -G-code parsing and visualization package extracted for Flutter Forge. +G-code parsing, streaming toolpath construction, playback UI, and Flutter GPU +visualization for Flutter applications. -## First macOS prerelease: 0.2.0-dev.1 +## Install -This release is distributed through GitHub/Git, not pub.dev. Pin the release tag -instead of following `dev`: +Version 0.2.0 is published on pub.dev: ```yaml dependencies: - gcode_core: - git: - url: https://github.com/lizy-coding/gcode_core.git - ref: v0.2.0-dev.1 + gcode_core: ^0.2.0 ``` -See [release notes](docs/releases/0.2.0-dev.1.md) and -[CHANGELOG](CHANGELOG.md) for breaking changes and validation limits. +Flutter 3.47.2 or newer is required. The package bundles its compiled shader +asset; consumers do not need to copy shader files manually. + +## Platform support + +| Platform | Status | Requirements | +| --- | --- | --- | +| macOS | Supported | macOS 12+, Impeller and Flutter GPU enabled | +| Android | Supported | API 29+, ARM64, Impeller and Flutter GPU enabled | +| iOS | Not supported | No validated host contract in 0.2.0 | +| Windows | Not supported | Flutter GPU renderer is not admitted in 0.2.0 | +| Linux | Not supported | No validated host contract in 0.2.0 | +| Web | Not supported | The renderer and file reader use native-only APIs | + +Unsupported platforms do not imply that parsing concepts are platform-specific; +the published package as a whole includes a GPU-only Flutter renderer and is +released only against the hosts listed as supported. + +### Host configuration For a macOS host, use Flutter 3.47.2 and add these keys to the top-level dict in `macos/Runner/Info.plist`: @@ -30,11 +44,11 @@ For a macOS host, use Flutter 3.47.2 and add these keys to the top-level dict in ``` -The host needs a macOS deployment target of at least 12.0; runtime evidence is -currently limited to macOS 26.5 on Apple Silicon. The package bundles its shader -asset automatically. Example Xcode/CocoaPods workarounds do not propagate into -consumer apps and should only be adopted if the same build issue occurs. -This is a Flutter package; its public entry point is not a pure Dart CLI API. +The macOS deployment target must be at least 12.0. On Android, use a minimum SDK +of 29, build for `arm64-v8a`, and keep Impeller enabled. The example project is +the reference host configuration for both platforms. Example Xcode/CocoaPods +workarounds do not propagate into consumer apps and should only be adopted if +the same build issue occurs. ## Scope @@ -53,7 +67,8 @@ Flutter 3.47.2 or newer is required. `GcodeCanvas` is GPU-only: G0 dashes, G1 lines, background paths, playback, grid, origin, tool head and glow are all rendered by GPU shaders. There is no Canvas backend or automatic fallback. Flutter only composites the resulting image and displays ordinary UI widgets. -Only macOS has been exercised in this implementation phase. +The renderer has no Canvas fallback. Unsupported GPU initialization is surfaced +as an error so applications can provide an explicit unavailable state. ```dart GcodeCanvas( @@ -88,7 +103,7 @@ fields (`toolHeadColor`, `toolHeadGlowColor`, `originDotColor`), replacing Paint objects. Unsupported GPU initialization is reported as an error, never a fallback renderer. -## Test +## Validation ```bash flutter test @@ -96,11 +111,10 @@ flutter test ``` CI keeps separate quality, Android build, macOS build, and platform-contract -jobs. Android is deliberately constrained to API 29+ and `arm64-v8a`; it -currently has build evidence, while native GPU runtime acceptance is still -pending. iOS, Linux, Windows, and Web are intentionally not declared as -supported hosts yet. See `AGENTS.md` for the evidence required when adding a -platform and the preferred order for growing tests. +jobs. Android is deliberately constrained to API 29+ and `arm64-v8a`. Native +acceptance evidence remains platform-specific; adding a new host requires its +own build, runtime, rendering, lifecycle, and performance evidence. See +`AGENTS.md` for the admission contract. ## Example @@ -109,6 +123,8 @@ Run the Flutter example app: ```bash cd example flutter run -d macos +# or an API 29+ ARM64 Android device +flutter run -d ``` IDE runs use `example/lib/main.dart` with the macOS device. The example's @@ -144,3 +160,7 @@ G1 X10 Y10 } } ``` + +## License + +MIT. See [LICENSE](LICENSE). diff --git a/example/pubspec.lock b/example/pubspec.lock index e38b60c..3c9f869 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -163,7 +163,7 @@ packages: path: ".." relative: true source: path - version: "0.2.0-dev.1" + version: "0.2.0" http: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 6635ab4..ccdeb53 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,8 @@ name: gcode_core description: G-code parsing, line reading, toolpath building, and Flutter visualization widgets. -publish_to: 'none' -version: 0.2.0-dev.1 +repository: https://github.com/lizy-coding/gcode_core +issue_tracker: https://github.com/lizy-coding/gcode_core/issues +version: 0.2.0 environment: sdk: '>=3.6.0 <4.0.0' From 318dd628a1e3900b0c8b90d7883983f850c7cb34 Mon Sep 17 00:00:00 2001 From: lizy Date: Wed, 23 Sep 2026 16:37:23 +0800 Subject: [PATCH 9/9] fix(ci): resolve Android SDK analyzer path --- .github/workflows/ci.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 12ba319..7f62c1d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,7 +51,9 @@ jobs: working-directory: example run: | apk=build/app/outputs/flutter-apk/app-debug.apk - test "$(apkanalyzer manifest min-sdk "$apk")" = "29" + apkanalyzer="$ANDROID_SDK_ROOT/cmdline-tools/latest/bin/apkanalyzer" + test -x "$apkanalyzer" + test "$("$apkanalyzer" manifest min-sdk "$apk")" = "29" test "$(unzip -Z1 "$apk" | awk -F/ '/^lib\// {print $2}' | sort -u)" = "arm64-v8a" macos: