This commit is contained in:
2026-08-08 10:57:54 +08:00
parent d0d48c1ffc
commit 604af909b0
184 changed files with 12521 additions and 12722 deletions
+45 -4
View File
@@ -1,4 +1,45 @@
unpackage # Miscellaneous
dist *.class
node_modules *.log
.DS_Store *.pyc
*.swp
.DS_Store
.atom/
.build/
.buildlog/
.history
.svn/
.swiftpm/
migrate_working_dir/
# IntelliJ related
*.iml
*.ipr
*.iws
.idea/
# The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line
# is commented out by default.
#.vscode/
# Flutter/Dart/Pub related
**/doc/api/
**/ios/Flutter/.last_build_id
.dart_tool/
.flutter-plugins-dependencies
.pub-cache/
.pub/
/build/
/coverage/
# Symbolication related
app.*.symbols
# Obfuscation related
app.*.map.json
# Android Studio will place build artifacts here
/android/app/debug
/android/app/profile
/android/app/release
+33
View File
@@ -0,0 +1,33 @@
# This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc.
#
# This file should be version controlled and should not be manually edited.
version:
revision: "924134a44c189315be2148659913dda1671cbe99"
channel: "stable"
project_type: app
# Tracks metadata for the flutter migrate command
migration:
platforms:
- platform: root
create_revision: 924134a44c189315be2148659913dda1671cbe99
base_revision: 924134a44c189315be2148659913dda1671cbe99
- platform: android
create_revision: 924134a44c189315be2148659913dda1671cbe99
base_revision: 924134a44c189315be2148659913dda1671cbe99
- platform: ios
create_revision: 924134a44c189315be2148659913dda1671cbe99
base_revision: 924134a44c189315be2148659913dda1671cbe99
# User provided section
# List of Local paths (relative to this file) that should be
# ignored by the migrate tool.
#
# Files that are not part of the templates will be ignored by default.
unmanaged_files:
- 'lib/main.dart'
- 'ios/Runner.xcodeproj/project.pbxproj'
+3
View File
@@ -0,0 +1,3 @@
{
"java.compile.nullAnalysis.mode": "disabled"
}
+17
View File
@@ -0,0 +1,17 @@
# arcx
A new Flutter project.
## Getting Started
This project is a starting point for a Flutter application.
A few resources to get you started if this is your first Flutter project:
- [Learn Flutter](https://docs.flutter.dev/get-started/learn-flutter)
- [Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
- [Flutter learning resources](https://docs.flutter.dev/reference/learning-resources)
For help getting started with Flutter development, view the
[online documentation](https://docs.flutter.dev/), which offers tutorials,
samples, guidance on mobile development, and a full API reference.
+28
View File
@@ -0,0 +1,28 @@
# This file configures the analyzer, which statically analyzes Dart code to
# check for errors, warnings, and lints.
#
# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
# invoked from the command line by running `flutter analyze`.
# The following line activates a set of recommended lints for Flutter apps,
# packages, and plugins designed to encourage good coding practices.
include: package:flutter_lints/flutter.yaml
linter:
# The lint rules applied to this project can be customized in the
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
# included above or to enable additional rules. A list of all available lints
# and their documentation is published at https://dart.dev/lints.
#
# Instead of disabling a lint rule for the entire project in the
# section below, it can also be suppressed for a single line of code
# or a specific dart file by using the `// ignore: name_of_lint` and
# `// ignore_for_file: name_of_lint` syntax on the line or in the file
# producing the lint.
rules:
# avoid_print: false # Uncomment to disable the `avoid_print` rule
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options
+14
View File
@@ -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
+45
View File
@@ -0,0 +1,45 @@
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.shelingxingqiu.arcx"
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.shelingxingqiu.arcx"
// 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
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 = "../.."
}
@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>
+45
View File
@@ -0,0 +1,45 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:label="arcx"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
</manifest>
@@ -0,0 +1,5 @@
package com.shelingxingqiu.arcx
import io.flutter.embedding.android.FlutterActivity
class MainActivity : FlutterActivity()
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?android:colorBackground" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>
Binary file not shown.

After

Width:  |  Height:  |  Size: 544 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 442 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 721 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>
@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>
+24
View File
@@ -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<Delete>("clean") {
delete(rootProject.layout.buildDirectory)
}
+6
View File
@@ -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
+5
View File
@@ -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.1.0-all.zip
+26
View File
@@ -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.0.1" apply false
id("org.jetbrains.kotlin.android") version "2.3.20" apply false
}
include(":app")
+1
View File
@@ -0,0 +1 @@
Place image assets for the local-first Flutter app here.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

+165
View File
@@ -0,0 +1,165 @@
# 📝 记分本(Point BookFlutter 纯本地版 PRD
## 文档信息
- **文档状态**:已完成(重构版)
- **适用端**iOS / Android 客户端(Flutter 纯本地架构)
- **变现模式**:免费试用 + iOS 苹果内购(IAP 买断/解锁)
- **关联模块**:本地训练统计 / 个人资料管理 / 记分管理 / 苹果内购
---
## 1. 页面概述
### 1.1 页面定位与目标
- **定位**:记分本业务线的 **总入口与本地数据总览页**
- **目标**
1. **零门槛使用**:无任何登录流程,首次打开即可直接查看本地统计或发起记分。
2. **正向数据反馈**:直观展示本地累计射箭数据、日均消耗、环值命中分布与落点热力图。
3. **内购合规转化**:提供免费体验额度,在用户第 N 次点击【开始记分】时,平滑唤起苹果内购付费解锁弹框。
### 1.2 权限与架构变更
- **数据架构****纯本地架构(Local-First)**,无后端服务器、无网络 API,所有训练数据与配置均存储于手机本地数据库(Isar/Hive)。
- **用户体系**:**完全免登录**,移除所有手机号获取、微信登录及协议勾选框。
- **排行榜**:**彻底移除**周榜、点赞及任何他人对比功能。
---
## 2. 页面流转与初始化逻辑
```mermaid
graph TD
A[用户打开 App / 进入首页] --> B[加载本地数据库 Isar]
B --> C[读取/初始化本地 UserProfile]
B --> D[读取/初始化本地 AppConfig]
B --> E[读取本地历史 PointRecord 列表]
C --> F[渲染:顶部个人资料卡片]
D --> G[渲染:用户权益/免费剩余次数]
E --> H[渲染:本地统计指标、落点热力图、环值分布图]
```
---
## 3. 核心功能与交互说明
### 3.1 本地个人资料管理(头像与昵称修改)
- **默认生成**:App 首次启动时,本地自动创建默认 Profile:
- **默认昵称**`弓箭手_XXXX`(四位随机数字)。
- **默认头像**:预设本地 Avatar Asset。
- **编辑交互**
- **入口**:点击首页顶部的个人资料卡片或【编辑】图标。
- **修改昵称**:弹出本地输入框,限制 1~12 个字符。
- **修改头像**:支持从手机本地相册选择图片或调起相机拍摄;选中后将图片异步拷贝至 App 本地沙盒目录 `ApplicationDocumentsDirectory/avatar.png`,数据库中仅保存文件路径。
---
### 3.2 个人训练统计卡片(纯本地计算)
- **关键指标**:今日射箭数、今日消耗、运动强度、训练天数、累计射箭数、平均环数。
- **计算规则**
- **无数据时**:指标统一展示为 `-`
- **今日消耗**$\text{今日箭数} \times 1.6$。
- **运动强度**$\frac{\text{今日箭数} \times 5}{60}$(上限封顶为 10)。
- $> 6$:标记为 **重度**
- $4 \sim 6$:标记为 **中度**
- $< 4$:标记为 **轻度**
---
### 3.3 核心业务入口与苹果内购(IAP)拦截
#### 🔹 入口一:【计分记录】
- **交互**:点击直接进入 `本地计分记录列表页`(支持按弓型/距离/靶纸筛选及本地记录滑动删除)。
#### 🔹 入口二:【开始记分】(含免费试用与内购拦截)
- **参数配置**
- `free_trial_limit`: 可配置免费试用次数(默认 `2` 次)。
- `used_trial_count`: 已使用免费试用次数。
- `is_vip_unlocked`: 是否已购买内购解锁(`true` / `false`)。
- **拦截判定流程**
```mermaid
graph TD
Start[点击“开始记分”] --> VIPCheck{is_vip_unlocked == true ?}
VIPCheck -- 是 --> DraftCheck
VIPCheck -- 否 --> TrialCheck{used_trial_count < free_trial_limit ?}
TrialCheck -- 是 (试用额度内) --> DraftCheck
TrialCheck -- 否 (试用额度已满) --> Paywall[阻断进入,弹出收费解锁弹框]
DraftCheck{本地是否存在未完成草稿?} -- 无草稿 --> CreatePage[进入参数选择/新建记分页]
DraftCheck -- 有草稿 --> DraftConfirmDialog[弹出草稿二选一确认框]
DraftConfirmDialog -- 继续编辑 --> EditPage[进入记分编辑页加载草稿]
DraftConfirmDialog -- 重新计分 --> ClearDraft[清空本地草稿] --> CreatePage
```
> **计数更新时机**:当用户在试用期内完成一次完整记分并点击**【保存记分】**成功写入本地数据库后,系统自动执行 `used_trial_count + 1`。
#### 💳 内购解锁弹窗与结果流转
- **弹窗展示**:包含内购商品名称、价格、买断权益说明、`[立即解锁]` 按钮、`[恢复购买]` 按钮及 `[取消/暂不解锁]` 按钮。
- **支付流转规则**
| 用户操作 / 支付状态 | 系统响应 | 页面流转 |
| ------------------------ | ------------------------------------------ | --------------------------- |
| **点击 [立即解锁]** | 调起 Apple App Store 原生支付组件。 | 界面显示原生 Loading 遮罩。 |
| **支付成功 (Purchased)** | 1. 本地数据库更新 `is_vip_unlocked = true` | |
2. 弹出 Toast 提示:“解锁成功!已获得无限记分权益” | 1. 关闭内购弹窗;
3. **返回【记分本首页】**
4. 再次点击【开始记分】自动解锁放行。 |
| **取消支付 (Canceled)** | 1. 弹出 Toast 提示:“支付已取消” | 1. 关闭内购弹窗;
5. **停留在【记分本首页】**。 |
| **支付失败 (Error)** | 1. 弹出 Toast 提示:“支付失败:[错误信息]” | 1. 关闭 Loading
6. **停留在【记分本首页】**。 |
| **点击 [恢复购买]** | 调起 `InAppPurchase.instance.restorePurchases()` | 查到历史购买凭证后更新 `is_vip_unlocked = true` 并提示“权益已恢复”,关闭弹窗并**返回【记分本首页】**。 |
---
### 3.4 本地图像分析模块(热力图与环值分布)
#### 🎯 落点热力图
- **展示条件**:本地存在历史训练数据时展示。
- **生成方式**:读取本地 `PointRecord` 中的 `hitPoints` 坐标数据,使用 Flutter Canvas 在本地预设靶纸底图上直接绘制热力点覆盖层。
#### 📊 个人环值分布图
- **展示条件**:本地存在历史记录时展示。
- **展示形式**:按 `M / X / 1~10 环` 统计本地所有箭数的占比,以柱状图形式呈现。
---
## 4. 边界异常处理与业务细节(QA 关注)
| 场景 | 预期处理机制 | 备注 / 风险点 |
| --------------------- | ------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------- |
| **首次安装 / 无数据** | 统计数据指标展示 `-`,热力图展示纯靶纸背景图,免费次数显示 `0/2`。 | 避免因为数据为空导致本地 Canvas 渲染报错。 |
| **本地草稿覆盖** | 点击“重新计分”会强制删除本地 SQLite/Isar 中的草稿记录,并覆盖写入。 | 需有二次弹窗明确确认,防止误删未保存数据。 |
| **重装 App / 换手机** | 纯本地 App 不支持跨设备数据同步;用户可通过内购弹窗的 **[恢复购买]** 恢复付费权益。 | 需在 App 内“关于我们”或设置页明确告知“数据存储于本地,卸载 App 会清空训练记录”。 |
| **网络离线内购** | 发起购买需要网络连接,若离线调起内购,提示:“无法连接到 App Store,请检查网络设置”。 | - |
---
---
## 5. 关联页面流转汇总
- 📄 **记分本首页** (`PointBookHomeScreen`):本地数据总览、免费次数提示、个人资料编辑入口、内购弹窗触发。
- 📄 **计分记录列表** (`PointBookListPage`):读取本地 Isar 历史列表、草稿展示与记录删除。
- 📄 **新建参数配置** (`PointBookCreatePage`):选择弓型、距离、靶纸类型及组数。
- 📄 **记分编辑/打靶页** (`PointBookEditPage`):本地实时记录落点与环数,保存时写入本地数据库并扣减试用次数。
@@ -1,390 +0,0 @@
# ARCX iOS 本地打包机落地计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 在本机(或专用 Mac)建立可重复的 ARCX iOS 出包能力:前端资源导出 → 原生工程签名 → 产出可安装/可上架 ipa。
**Architecture:** 采用「两阶段」:Phase A 先用 Mac **安心打包**快速打通证书与出包;Phase B 再搭建完整 **离线 SDK + Xcode** 本地打包机,满足证书/代码不出本机、可脚本化/CI 的需求。`arcx-app` 是 vue-cli/vite 项目,本地打包资源**必须经 HBuilderX 导出**,不能仅靠 `npm run build:ios`
**Tech Stack:** uni-app (Vue3/Vite) · HBuilderX · DCloud iOS 离线 SDK · Xcode 26+ · CocoaPods · Apple Developer 证书/Profile · Transporter
**官方文档入口:**
- 总览:[uni-app 官网](https://uniapp.dcloud.net.cn/) → 发布 → 原生 App(离线)
- 离线 SDK 总入口:[nativesupport AppDocs](https://nativesupport.dcloud.net.cn/AppDocs/README)
- iOS 开发环境/工程配置:[usesdk/ios](https://nativesupport.dcloud.net.cn/AppDocs/usesdk/ios)
- 导出前端资源:[importfeproject/export](https://nativesupport.dcloud.net.cn/AppDocs/importfeproject/export.html)
- 证书/Archive/上传:[package/ios](https://nativesupport.dcloud.net.cn/AppDocs/package/ios.html)
- iOS 原生资源(Info.plist 等):[app-nativeresource-ios](https://uniapp.dcloud.net.cn/tutorial/app-nativeresource-ios)
- 安心打包(Mac 本地签):[SafePack](https://uniapp.dcloud.net.cn/tutorial/build/SafePack.html)
- 模块/Pod 集成(5.13+):[iOSModuleConfig/common](https://nativesupport.dcloud.net.cn/AppDocs/usemodule/iOSModuleConfig/common.html)
---
## 官网结论摘要(先读再落地)
### 两条「本地」路径不要混
| 路径 | 本质 | 证书/前端代码 | 限制 | 适合 |
|------|------|---------------|------|------|
| **安心打包** | 云端生成原生壳,**本机合并代码并签名** | 不上传代码与证书 | 非纯离线;改模块配置会重新拉壳;付费原生插件/原生混淆能力受限场景见文档 | 日常出包、Mac 打包机首选起步 |
| **离线打包** | 下载 iOS 离线 SDK,用 **Xcode** 本地编签 | 全程本机 | 无原生混淆;**付费 uni 原生插件不可用**;须申请 `dcloud_appkey`;编译器版本必须与 SDK 一致 | 强合规、深度原生定制、CI Archive |
### 离线打包标准流水线(官方)
1. **版本对齐**HBuilderX / `npm run info` 的编译器版本 ≡ 离线 SDK 版本(不一致会弹窗且功能异常)。
2. **申请 Appkey**SDK ≥ 3.1.10):在 Info.plist 配置 `dcloud_appkey`Bundle ID + `control.xml` 中 appid 必须与申请一致。
3. **导出资源**HBuilderX → 发行 → 原生 App-本地打包 → **生成本地打包 App 资源**
- **vue-cli 项目不能用纯 CLI 导出**,必须用 HBuilderX 打开项目再导出。
- `manifest.json``appid` 不能手填空值,须由 HBuilderX 获取(本仓库已有 `__UNI__8A880C6`)。
4. **配置 Xcode 工程**`HBuilder-Hello`):
- Bundle ID / Display Name / Version / Build
- 与导出资源中的 `manifest``control.xml` appid 对齐
- 图标、LaunchScreen、隐私描述、所需模块(参考 `Feature-iOS.xls`;新版推荐 CocoaPods `uniapp_subspecs`
- 合并项目内 `nativeResources/ios`(离线时需**手动**合并进 Xcode)
5. **Signing**:生产证书 + Distribution Profile(与 Bundle ID 绑定)。
6. **Product → Archive → 导出 ipa**;上架用 **Transporter** 上传。
### 与本仓库的映射
| 项 | 当前值 | 打包机要求 |
|----|--------|------------|
| 项目形态 | vite + `@dcloudio/uni-app` alpha `4080620251107001` | 离线 SDK 选与编译器对齐的版本;用 HBuilderX 打开本仓库根目录 |
| DCloud appid | `__UNI__8A880C6` | 写入 SDK 工程 `control.xml` / 资源目录名 |
| iOS Bundle ID | `com.shelingxingqiu.arcx` | Apple App ID + Provisioning 必须一致 |
| 版本 | `versionName` 0.1.1 / `versionCode` 101 | Xcode Version / Build 对齐 |
| 证书 | `manifest` 中 p12/profile **已注释** | 打包机钥匙串导入正式证书;勿把密码提交 Git |
| 原生资源 | `nativeResources/ios/info.plist`(含 ATT 文案) | 云包自动合并;离线需手动合并 |
| 本机工具 | macOS 26.1 · Xcode 26.1.1 · CocoaPods 已装 | 已满足官方「Xcode 26.0+」门槛 |
---
## 文件与目录约定(打包机)
建议在打包机固定目录(示例,可按团队规范改):
```text
~/Packaging/
credentials/ # 不进 Git.p12、.mobileprovision、密码备忘(加密磁盘)
sdk/
iOS-SDK-<ver>/ # 解压后的离线 SDK(与编译器同版本)
workspace/
arcx-app/ # git clone
HBuilder-Hello/ # 从 SDK 拷出的长期维护工程(或软链)
artifacts/
ipa/ # 产出
logs/
scripts/
export-note.md # HBuilderX 导出步骤备忘(GUI
sync-www.sh # 把导出的 www/资源拷进原生工程
archive.sh # xcodebuild archive + export
```
项目内相关文件:
- `src/manifest.json` — appid、ios.appid、隐私、图标路径
- `nativeResources/ios/info.plist` — 额外 Info.plist 合并项
- `package.json` — 编译器版本锚点
- `docs/superpowers/plans/2026-08-07-ios-local-pack-machine.md` — 本计划
---
## Phase A:Mac 安心打包机(1–2 天,先打通)
> 目标:Mac 上可重复产出已签名 ipa;证书与业务代码不上传云端(首次仍会提交模块配置拉取原生壳)。
### Task 1: 账号与证书就绪
**Files:**
- 不改仓库;材料落在 `~/Packaging/credentials/`
- [ ] **Step 1: Apple Developer**
准备:
- Apple Developer 账号
- App ID`com.shelingxingqiu.arcx`
- Distribution Certificate.p12 + 密码)
- App Store / Ad Hoc `mobileprovision`(按发布渠道选)
- [ ] **Step 2: 导入钥匙串**
```bash
# 在打包机执行(路径按实际修改)
security import ~/Packaging/credentials/ios_dist.p12 -k ~/Library/Keychains/login.keychain-db -P '<P12_PASSWORD>' -T /usr/bin/codesign
```
Expected: 钥匙串可见「Apple Distribution: …」证书,信任链完整。
- [ ] **Step 3: 在 HBuilderX 配置打包证书**
打开 `src/manifest.json` → App 模块配置 / iOS 打包配置,填入(**勿提交明文密码到 Git**):
- Bundle ID`com.shelingxingqiu.arcx`
- 证书路径、密码、profile 路径
本地可用环境变量或 HBuilderX 图形界面填写;保持仓库内注释状态。
- [ ] **Step 4: 验收清单**
- [ ] Bundle ID 与 Profile 一致
- [ ] 隐私文案与实际上架用途一致(当前含相机/相册/定位/麦克风/跟踪,无用项建议上架前删减)
- [ ] 图标资源 `unpackage/res/icons/` 齐全
### Task 2: HBuilderX 打开 CLI 项目并首次安心打包
**Files:**
- Modify locally only: HBuilderX 打开 `/Users/ZhuanZ/Documents/gdfw/arcx-app`
- Output: `unpackage/release/ipa/`(或 HBuilderX 提示目录)
- [ ] **Step 1: 安装与 HBuilderX 编译器对齐的版本**
对照 `package.json``@dcloudio/uni-app` 版本号 `3.0.0-alpha-4080620251107001`(对应 HX/编译器 4.08.06 一带),安装匹配的 [HBuilderX](https://www.dcloud.io/hbuilderx.html)。
- [ ] **Step 2: 用 HBuilderX 打开项目根目录**
确认能识别 `src/manifest.json`appid 为 `__UNI__8A880C6`
- [ ] **Step 3: 发行 → 原生 App-云端打包,勾选安心打包**
按 [SafePack](https://uniapp.dcloud.net.cn/tutorial/build/SafePack.html)
1. 首次:云端下发原生壳 → 本机合并代码 → 本机签名
2. 后续仅改前端且模块配置不变:本地复用壳,速度更快,且不占免费云打包次数
- [ ] **Step 4: 安装 ipa 到测试机或上传 TestFlight**
Expected: 应用可启动;若提示 appkey/证书问题,回到 Task 1。
- [ ] **Step 5: 记录打包机 SOP 一页纸**
写入 `~/Packaging/scripts/export-note.md`HBuilderX 版本、证书别名、Profile 名称、成功产物路径、耗时。
---
## Phase B:完整离线本地打包机(3–5 天)
> 目标:不依赖云端原生壳;用离线 SDK + Xcode Archive 出包;可脚本化。
### Task 3: 下载并对齐 iOS 离线 SDK
**Files:**
- Create: `~/Packaging/sdk/iOS-SDK-<ver>/`
- [ ] **Step 1: 查编译器版本**
在项目根目录:
```bash
cd /Users/ZhuanZ/Documents/gdfw/arcx-app
# 若已配置 uni info 脚本则:
npx uni -v
# 或在 HBuilderX 中查看关于/编译器版本
```
Expected: 记下与导出资源一致的版本号字符串。
- [ ] **Step 2: 下载同版本 iOS 离线 SDK**
从 [SDK 下载页](https://nativesupport.dcloud.net.cn/AppDocs/download/ios)(以官网当前入口为准)下载,解压到 `~/Packaging/sdk/`
目录应含:
- `HBuilder-Hello` — 打包工程
- `Feature-iOS.xls` — 模块依赖表
- `SDK` — 库与资源
- (新版)`uniapp.podspec` / 示例 `Podfile`
- [ ] **Step 3: 版本一致性门禁**
建立检查表:`编译器版本` = `SDK 发布版本` = `HBuilderX 导出资源所用版本`。三者任一不一致禁止出生产包。
### Task 4: 申请并写入 dcloud_appkey
**Files:**
- Modify (Xcode 工程): `HBuilder-Hello/.../Info.plist`
- 参考:[usesdk/ios 配置 Appkey](https://nativesupport.dcloud.net.cn/AppDocs/usesdk/ios)
- [ ] **Step 1: 在 DCloud 开发者中心申请 Appkey**
绑定:
- DCloud appid`__UNI__8A880C6`
- Bundle ID`com.shelingxingqiu.arcx`
- [ ] **Step 2: Info.plist 增加**
```xml
<key>dcloud_appkey</key>
<string>YOUR_DCLOUD_APPKEY</string>
```
- [ ] **Step 3: 同步修改 control.xml 中的 appid 与 Bundle ID**
Expected: 真机运行不再提示 appkey 错误。
### Task 5: HBuilderX 导出本地打包资源并导入工程
**Files:**
- Output from HX: 通常在项目 `unpackage/resources/` 或发行提示路径下的 www/app 资源
- Modify: `HBuilder-Hello` 内 Pandora/apps 资源目录
- [ ] **Step 1: 导出**
HBuilderX → 发行 → **原生 App-本地打包****生成本地打包 App 资源**
CLI 项目必须这一步,见[官方说明](https://nativesupport.dcloud.net.cn/AppDocs/importfeproject/export.html)
- [ ] **Step 2: 拷贝资源进 HBuilder-Hello**
按官方「导入前端资源」文档:将导出的应用资源放到原生工程 `Pandora/apps/<appid>/www`(目录名 = appid)。
建议脚本骨架 `~/Packaging/scripts/sync-www.sh`
```bash
#!/usr/bin/env bash
set -euo pipefail
APPID="__UNI__8A880C6"
SRC="${1:?usage: sync-www.sh <exported_www_dir>}"
DST="$HOME/Packaging/workspace/HBuilder-Hello/HBuilder/Pandora/apps/${APPID}/www"
rm -rf "$DST"
mkdir -p "$DST"
cp -R "$SRC"/. "$DST"/
echo "synced -> $DST"
```
> 实际 `Pandora` 相对路径以当次 SDK 示例工程为准,首次手工核对后再固化脚本。
- [ ] **Step 3: 合并 `nativeResources/ios`**
将仓库 `nativeResources/ios/info.plist` 中的键(如 `NSUserTrackingUsageDescription`)合并进 Xcode Info.plist;有 `Resources` 时拷入 Bundle Resources。
### Task 6: 配置 Xcode Identity、模块与签名
**Files:**
- Modify: Xcode Target `HBuilder` General / Signing / Info.plist /(新版)Podfile
- [ ] **Step 1: Identity**
| 字段 | 值 |
|------|-----|
| Display Name | ARCX |
| Bundle Identifier | `com.shelingxingqiu.arcx` |
| Version | `0.1.1`(随 `manifest.versionName` |
| Build | `101`(随 `manifest.versionCode` |
同时打开工程内 `manifest`,使 name/version 与上表一致。
- [ ] **Step 2: 模块精简**
本项目 `manifest.app-plus.modules` 当前为空 → 离线工程只保留基础能力。
若后续加支付/推送/登录,按 `Feature-iOS.xls``Podfile``uniapp_subspecs` 开启,再 `pod install`
```bash
cd ~/Packaging/workspace/HBuilder-Hello
pod install
open HBuilder-Hello.xcworkspace # 使用 Pods 时必须开 workspace
```
- [ ] **Step 3: Signing & Capabilities**
- Team + Distribution 证书
- Release 使用正确 Provisioning Profile
- 需要 ATT 时已有隐私描述;按需加 Associated Domains 等
- [ ] **Step 4: 真机 Run 冒烟**
Expected: App 启动到首页 Tab,无「版本不一致 / appkey 错误」弹窗。
### Task 7: Archive 出包与上传
**Files:**
- Create: `~/Packaging/scripts/archive.sh`
- Output: `~/Packaging/artifacts/ipa/`
- [ ] **Step 1: GUI 首次 Archive**
Xcode → Product → Archive → Distribute App → 导出 ipaApp Store Connect 或 Ad Hoc)。
- [ ] **Step 2: 固化命令行(打包机自动化)**
```bash
#!/usr/bin/env bash
set -euo pipefail
WS="$HOME/Packaging/workspace/HBuilder-Hello/HBuilder-Hello.xcworkspace"
SCHEME="HBuilder"
OUT="$HOME/Packaging/artifacts/ipa"
ARCHIVE="$OUT/ARCX.xcarchive"
EXPORT_PLIST="$HOME/Packaging/scripts/ExportOptions.plist"
xcodebuild -workspace "$WS" -scheme "$SCHEME" \
-configuration Release -archivePath "$ARCHIVE" archive
xcodebuild -exportArchive -archivePath "$ARCHIVE" \
-exportOptionsPlist "$EXPORT_PLIST" -exportPath "$OUT"
```
`ExportOptions.plist` 首次用 Xcode 导出后从 Organizer 旁路生成,或按 Apple 文档手写 `method=app-store-connect`
- [ ] **Step 3: 上传**
使用 **Transporter** 上传 ipa 至 App Store Connect[官方](https://nativesupport.dcloud.net.cn/AppDocs/package/ios.html))。
- [ ] **Step 4: 打生产包前检查**
- [ ] `control.xml` **去掉** `debug="true"` / `syncDebug="true"`(自定义基座调试用,留着会导致热更新等问题)
- [ ] Version/Build 已递增
- [ ] 隐私清单(SDK 4.13+ 示例工程含基础 PrivacyInfo)已按模块补齐
---
## Phase C:打包机运维与门禁(持续)
### Task 8: 目录权限、密钥与备份
- [ ] **Step 1:** `credentials/` 放加密卷或 1Password**禁止进 Git**
- [ ] **Step 2:** `.gitignore` 确认忽略 `unpackage/release/`、本地 p12、profile
- [ ] **Step 3:** 每月检查:Apple 证书过期日、HBuilderX/SDK 升级窗口、Xcode 与 macOS 兼容性
### Task 9: 版本升级流程(强制)
每次升级编译器:
1. 升级 HBuilderX / 项目 `@dcloudio/*` 依赖
2. 下载**同版本** iOS 离线 SDK
3. 重新导出资源
4. Diff `HBuilder-Hello` 与新 SDK 示例工程(Pods、隐私清单、基础库)
5. 再 Archive
### Task 10: 验收标准(Definition of Done
- [ ] 安心打包:本机产出可安装 ipa(Phase A)
- [ ] 离线打包:Xcode Archive 产出 ipa,安装后无版本/appkey 弹窗(Phase B
- [ ] 同一 commit 资源可重复出包(脚本化 sync + archive
- [ ] 证书与密码不在仓库中
- [ ] 有一页 SOP:版本号对齐表 + 出包命令 + 回滚方式
---
## 推荐落地节奏
| 周次 | 交付 |
|------|------|
| Day 1 | 证书/Profile + HBuilderX 打开项目 + 安心打包首包 |
| Day 2 | TestFlight/真机验收;写 SOP;决定是否上离线 |
| Day 34 | 下载对齐 SDK、Appkey、导出资源、Xcode Identity/签名 |
| Day 5 | Archive 脚本化 + Transporter;自定义基座(可选) |
**默认建议:** ARCX 现阶段模块少、无付费原生插件 → **先 Phase A(安心打包)**;若合规要求「完全不碰云原生壳」或要 CI Archive,再上 Phase B。
---
## 风险与明确不做
| 风险 | 应对 |
|------|------|
| 编译器与 SDK 版本不一致 | 出包前硬门禁,禁止混用 |
| CLI 无法直接导出本地资源 | 打包机必须装 HBuilderX |
| 离线无法用付费 uni 原生插件 / 原生混淆 | 评估插件策略;必要时改安心/传统云包 |
| manifest 隐私项过多导致审核追问 | 上架前按真实能力删减 |
| p12 密码曾出现在历史注释 | 已注释路径勿回填到 Git;轮换证书 |
---
## Self-Review
1. **Spec coverage** 官网离线流程(版本对齐、Appkey、导出资源、Xcode 配置、证书、Archive、Transporter)与安心打包路径均有 Task;本仓库 Bundle ID/appid/nativeResources 已映射。
2. **Placeholder scan** 脚本中的路径以 SDK 实际目录为准,首次需人工核对后固化(已标明)。
3. **一致性:** Bundle ID 全程使用 `com.shelingxingqiu.arcx`DCloud appid 全程 `__UNI__8A880C6`
+34
View File
@@ -0,0 +1,34 @@
**/dgph
*.mode1v3
*.mode2v3
*.moved-aside
*.pbxuser
*.perspectivev3
**/*sync/
.sconsign.dblite
.tags*
**/.vagrant/
**/DerivedData/
Icon?
**/Pods/
**/.symlinks/
profile
xcuserdata
**/.generated/
Flutter/App.framework
Flutter/Flutter.framework
Flutter/Flutter.podspec
Flutter/Generated.xcconfig
Flutter/ephemeral/
Flutter/app.flx
Flutter/app.zip
Flutter/flutter_assets/
Flutter/flutter_export_environment.sh
ServiceDefinitions.json
Runner/GeneratedPluginRegistrant.*
# Exceptions to above rules.
!default.mode1v3
!default.mode2v3
!default.pbxuser
!default.perspectivev3
+24
View File
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string>App</string>
<key>CFBundleIdentifier</key>
<string>io.flutter.flutter.app</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>App</string>
<key>CFBundlePackageType</key>
<string>FMWK</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>1.0</string>
</dict>
</plist>
+2
View File
@@ -0,0 +1,2 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
#include "Generated.xcconfig"
+2
View File
@@ -0,0 +1,2 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
#include "Generated.xcconfig"
+43
View File
@@ -0,0 +1,43 @@
# Uncomment this line to define a global platform for your project
# platform :ios, '13.0'
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
project 'Runner', {
'Debug' => :debug,
'Profile' => :release,
'Release' => :release,
}
def flutter_root
generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__)
unless File.exist?(generated_xcode_build_settings_path)
raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first"
end
File.foreach(generated_xcode_build_settings_path) do |line|
matches = line.match(/FLUTTER_ROOT\=(.*)/)
return matches[1].strip if matches
end
raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get"
end
require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root)
flutter_ios_podfile_setup
target 'Runner' do
use_frameworks!
flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
target 'RunnerTests' do
inherit! :search_paths
end
end
post_install do |installer|
installer.pods_project.targets.each do |target|
flutter_additional_ios_build_settings(target)
end
end
+623
View File
@@ -0,0 +1,623 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 54;
objects = {
/* Begin PBXBuildFile section */
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; };
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 97C146E61CF9000F007C117D /* Project object */;
proxyType = 1;
remoteGlobalIDString = 97C146ED1CF9000F007C117D;
remoteInfo = Runner;
};
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
9705A1C41CF9048500538489 /* Embed Frameworks */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 10;
files = (
);
name = "Embed Frameworks";
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; };
97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; };
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
97C146EB1CF9000F007C117D /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
331C8082294A63A400263BE5 /* RunnerTests */ = {
isa = PBXGroup;
children = (
331C807B294A618700263BE5 /* RunnerTests.swift */,
);
path = RunnerTests;
sourceTree = "<group>";
};
9740EEB11CF90186004384FC /* Flutter */ = {
isa = PBXGroup;
children = (
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
9740EEB21CF90195004384FC /* Debug.xcconfig */,
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
9740EEB31CF90195004384FC /* Generated.xcconfig */,
);
name = Flutter;
sourceTree = "<group>";
};
97C146E51CF9000F007C117D = {
isa = PBXGroup;
children = (
9740EEB11CF90186004384FC /* Flutter */,
97C146F01CF9000F007C117D /* Runner */,
97C146EF1CF9000F007C117D /* Products */,
331C8082294A63A400263BE5 /* RunnerTests */,
);
sourceTree = "<group>";
};
97C146EF1CF9000F007C117D /* Products */ = {
isa = PBXGroup;
children = (
97C146EE1CF9000F007C117D /* Runner.app */,
331C8081294A63A400263BE5 /* RunnerTests.xctest */,
);
name = Products;
sourceTree = "<group>";
};
97C146F01CF9000F007C117D /* Runner */ = {
isa = PBXGroup;
children = (
97C146FA1CF9000F007C117D /* Main.storyboard */,
97C146FD1CF9000F007C117D /* Assets.xcassets */,
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */,
97C147021CF9000F007C117D /* Info.plist */,
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */,
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
);
path = Runner;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
331C8080294A63A400263BE5 /* RunnerTests */ = {
isa = PBXNativeTarget;
buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
buildPhases = (
331C807D294A63A400263BE5 /* Sources */,
331C807F294A63A400263BE5 /* Resources */,
);
buildRules = (
);
dependencies = (
331C8086294A63A400263BE5 /* PBXTargetDependency */,
);
name = RunnerTests;
productName = RunnerTests;
productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */;
productType = "com.apple.product-type.bundle.unit-test";
};
97C146ED1CF9000F007C117D /* Runner */ = {
isa = PBXNativeTarget;
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
buildPhases = (
9740EEB61CF901F6004384FC /* Run Script */,
97C146EA1CF9000F007C117D /* Sources */,
97C146EB1CF9000F007C117D /* Frameworks */,
97C146EC1CF9000F007C117D /* Resources */,
9705A1C41CF9048500538489 /* Embed Frameworks */,
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
);
buildRules = (
);
dependencies = (
);
name = Runner;
productName = Runner;
productReference = 97C146EE1CF9000F007C117D /* Runner.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
97C146E61CF9000F007C117D /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = YES;
LastUpgradeCheck = 1510;
ORGANIZATIONNAME = "";
TargetAttributes = {
331C8080294A63A400263BE5 = {
CreatedOnToolsVersion = 14.0;
TestTargetID = 97C146ED1CF9000F007C117D;
};
97C146ED1CF9000F007C117D = {
CreatedOnToolsVersion = 7.3.1;
LastSwiftMigration = 1100;
};
};
};
buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */;
compatibilityVersion = "Xcode 9.3";
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = 97C146E51CF9000F007C117D;
productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
97C146ED1CF9000F007C117D /* Runner */,
331C8080294A63A400263BE5 /* RunnerTests */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
331C807F294A63A400263BE5 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
97C146EC1CF9000F007C117D /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */,
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */,
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */,
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
"${TARGET_BUILD_DIR}/${INFOPLIST_PATH}",
);
name = "Thin Binary";
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin";
};
9740EEB61CF901F6004384FC /* Run Script */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
);
name = "Run Script";
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
331C807D294A63A400263BE5 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
97C146EA1CF9000F007C117D /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
331C8086294A63A400263BE5 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 97C146ED1CF9000F007C117D /* Runner */;
targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin PBXVariantGroup section */
97C146FA1CF9000F007C117D /* Main.storyboard */ = {
isa = PBXVariantGroup;
children = (
97C146FB1CF9000F007C117D /* Base */,
);
name = Main.storyboard;
sourceTree = "<group>";
};
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = {
isa = PBXVariantGroup;
children = (
97C147001CF9000F007C117D /* Base */,
);
name = LaunchScreen.storyboard;
sourceTree = "<group>";
};
/* End PBXVariantGroup section */
/* Begin XCBuildConfiguration section */
249021D3217E4FDB00AE95B9 /* Profile */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
TARGETED_DEVICE_FAMILY = "1,2";
VALIDATE_PRODUCT = YES;
};
name = Profile;
};
249021D4217E4FDB00AE95B9 /* Profile */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = PUMC8UCWCK;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.shelingxingqiu.arcx;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
};
name = Profile;
};
331C8088294A63A400263BE5 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.shelingxingqiu.arcx.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
};
name = Debug;
};
331C8089294A63A400263BE5 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.shelingxingqiu.arcx.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
};
name = Release;
};
331C808A294A63A400263BE5 /* Profile */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.shelingxingqiu.arcx.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
};
name = Profile;
};
97C147031CF9000F007C117D /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
97C147041CF9000F007C117D /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
TARGETED_DEVICE_FAMILY = "1,2";
VALIDATE_PRODUCT = YES;
};
name = Release;
};
97C147061CF9000F007C117D /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = PUMC8UCWCK;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.shelingxingqiu.arcx;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
};
name = Debug;
};
97C147071CF9000F007C117D /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = PUMC8UCWCK;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.shelingxingqiu.arcx;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
331C8088294A63A400263BE5 /* Debug */,
331C8089294A63A400263BE5 /* Release */,
331C808A294A63A400263BE5 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = {
isa = XCConfigurationList;
buildConfigurations = (
97C147031CF9000F007C117D /* Debug */,
97C147041CF9000F007C117D /* Release */,
249021D3217E4FDB00AE95B9 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = {
isa = XCConfigurationList;
buildConfigurations = (
97C147061CF9000F007C117D /* Debug */,
97C147071CF9000F007C117D /* Release */,
249021D4217E4FDB00AE95B9 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = 97C146E61CF9000F007C117D /* Project object */;
}
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>
@@ -2,7 +2,7 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>NSUserTrackingUsageDescription</key> <key>IDEDidComputeMac32BitWarning</key>
<string>We use tracking to analyze in-app clicks and usage to continuously improve the product experience.</string> <true/>
</dict> </dict>
</plist> </plist>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PreviewsEnabled</key>
<false/>
</dict>
</plist>
@@ -0,0 +1,101 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1510"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
shouldUseLaunchSchemeArgsEnv = "YES">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</MacroExpansion>
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "331C8080294A63A400263BE5"
BuildableName = "RunnerTests.xctest"
BlueprintName = "RunnerTests"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
enableGPUValidationMode = "1"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Profile"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
+7
View File
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "group:Runner.xcodeproj">
</FileRef>
</Workspace>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PreviewsEnabled</key>
<false/>
</dict>
</plist>
+16
View File
@@ -0,0 +1,16 @@
import Flutter
import UIKit
@main
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
}
}
@@ -0,0 +1,122 @@
{
"images" : [
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "Icon-App-20x20@2x.png",
"scale" : "2x"
},
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "Icon-App-20x20@3x.png",
"scale" : "3x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@1x.png",
"scale" : "1x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@2x.png",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@3x.png",
"scale" : "3x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "Icon-App-40x40@2x.png",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "Icon-App-40x40@3x.png",
"scale" : "3x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "Icon-App-60x60@2x.png",
"scale" : "2x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "Icon-App-60x60@3x.png",
"scale" : "3x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "Icon-App-20x20@1x.png",
"scale" : "1x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "Icon-App-20x20@2x.png",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "Icon-App-29x29@1x.png",
"scale" : "1x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "Icon-App-29x29@2x.png",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "Icon-App-40x40@1x.png",
"scale" : "1x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "Icon-App-40x40@2x.png",
"scale" : "2x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "Icon-App-76x76@1x.png",
"scale" : "1x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "Icon-App-76x76@2x.png",
"scale" : "2x"
},
{
"size" : "83.5x83.5",
"idiom" : "ipad",
"filename" : "Icon-App-83.5x83.5@2x.png",
"scale" : "2x"
},
{
"size" : "1024x1024",
"idiom" : "ios-marketing",
"filename" : "Icon-App-1024x1024@1x.png",
"scale" : "1x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 295 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 450 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 462 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 704 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 586 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 862 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 862 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 762 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -0,0 +1,23 @@
{
"images" : [
{
"idiom" : "universal",
"filename" : "LaunchImage.png",
"scale" : "1x"
},
{
"idiom" : "universal",
"filename" : "LaunchImage@2x.png",
"scale" : "2x"
},
{
"idiom" : "universal",
"filename" : "LaunchImage@3x.png",
"scale" : "3x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

@@ -0,0 +1,5 @@
# Launch Screen Assets
You can customize the launch screen with your own desired assets by replacing the image files in this directory.
You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images.
@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="12121" systemVersion="16G29" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="12089"/>
</dependencies>
<scenes>
<!--View Controller-->
<scene sceneID="EHf-IW-A2E">
<objects>
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
<layoutGuides>
<viewControllerLayoutGuide type="top" id="Ydg-fD-yQy"/>
<viewControllerLayoutGuide type="bottom" id="xbc-2k-c8Z"/>
</layoutGuides>
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4">
</imageView>
</subviews>
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<constraints>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="1a2-6s-vTC"/>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="4X2-HB-R7a"/>
</constraints>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="53" y="375"/>
</scene>
</scenes>
<resources>
<image name="LaunchImage" width="168" height="185"/>
</resources>
</document>
+26
View File
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="10117" systemVersion="15F34" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" initialViewController="BYZ-38-t0r">
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="10085"/>
</dependencies>
<scenes>
<!--Flutter View Controller-->
<scene sceneID="tne-QT-ifu">
<objects>
<viewController id="BYZ-38-t0r" customClass="FlutterViewController" sceneMemberID="viewController">
<layoutGuides>
<viewControllerLayoutGuide type="top" id="y3c-jy-aDJ"/>
<viewControllerLayoutGuide type="bottom" id="wfy-db-euE"/>
</layoutGuides>
<view key="view" contentMode="scaleToFill" id="8bC-Xf-vdC">
<rect key="frame" x="0.0" y="0.0" width="600" height="600"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="calibratedWhite"/>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
</objects>
</scene>
</scenes>
</document>
+74
View File
@@ -0,0 +1,74 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Arcx</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>arcx</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
<key>UISceneConfigurations</key>
<dict>
<key>UIWindowSceneSessionRoleApplication</key>
<array>
<dict>
<key>UISceneClassName</key>
<string>UIWindowScene</string>
<key>UISceneConfigurationName</key>
<string>flutter</string>
<key>UISceneDelegateClassName</key>
<string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
<key>UISceneStoryboardFile</key>
<string>Main</string>
</dict>
</array>
</dict>
</dict>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>NSCameraUsageDescription</key>
<string>用于拍摄并设置个人头像</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>用于从相册选择并设置个人头像</string>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
</dict>
</plist>
+1
View File
@@ -0,0 +1 @@
#import "GeneratedPluginRegistrant.h"
+6
View File
@@ -0,0 +1,6 @@
import Flutter
import UIKit
class SceneDelegate: FlutterSceneDelegate {
}
+12
View File
@@ -0,0 +1,12 @@
import Flutter
import UIKit
import XCTest
class RunnerTests: XCTestCase {
func testExample() {
// If you add code to the Runner application, consider adding tests here.
// See https://developer.apple.com/documentation/xctest for more information about using XCTest.
}
}
+18
View File
@@ -0,0 +1,18 @@
import 'package:flutter/material.dart';
import '../features/home/presentation/point_book_home_screen.dart';
import 'app_theme.dart';
class ArcxApp extends StatelessWidget {
const ArcxApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: '记分本',
debugShowCheckedModeBanner: false,
theme: AppTheme.light(),
home: const PointBookHomeScreen(),
);
}
}
+67
View File
@@ -0,0 +1,67 @@
import 'package:flutter/material.dart';
class AppTheme {
const AppTheme._();
static const Color primary = Color(0xFF1F7A4D);
static const Color primaryDark = Color(0xFF13533A);
static const Color accent = Color(0xFFE8A33D);
static const Color scoreRing = Color(0xFFE0B350);
static const Color hitPoint = Color(0xFFE5484D);
static const Color surfaceMuted = Color(0xFFF4F6F5);
static const Color textSecondary = Color(0xFF6B7280);
static ThemeData light() {
final base = ThemeData.light(useMaterial3: true);
return base.copyWith(
colorScheme: const ColorScheme.light(
primary: primary,
secondary: accent,
surface: Colors.white,
onPrimary: Colors.white,
onSecondary: Colors.black,
),
scaffoldBackgroundColor: surfaceMuted,
appBarTheme: const AppBarTheme(
backgroundColor: primary,
foregroundColor: Colors.white,
elevation: 0,
centerTitle: true,
),
cardTheme: CardThemeData(
color: Colors.white,
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
margin: EdgeInsets.zero,
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: primary,
foregroundColor: Colors.white,
minimumSize: const Size.fromHeight(48),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
foregroundColor: primary,
minimumSize: const Size.fromHeight(48),
side: const BorderSide(color: primary),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
inputDecorationTheme: InputDecorationTheme(
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 14,
),
),
);
}
}
+11
View File
@@ -0,0 +1,11 @@
import 'package:flutter/widgets.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'app/app.dart';
import 'data/local/app_database.dart';
Future<void> bootstrap() async {
WidgetsFlutterBinding.ensureInitialized();
await AppDatabase.instance.open();
runApp(const ProviderScope(child: ArcxApp()));
}
+15
View File
@@ -0,0 +1,15 @@
class AppDefaults {
const AppDefaults._();
static const int singletonId = 1;
static const int defaultFreeTrialLimit = 2;
static const int nicknameRandomMin = 1000;
static const int nicknameRandomMax = 9999;
static const String defaultAvatarAssetPath =
'assets/images/avatar_default.png';
static const String localDatabaseName = 'arcx_local_first_db';
static const String avatarFileName = 'avatar.png';
}
+15
View File
@@ -0,0 +1,15 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:isar/isar.dart';
import '../../data/local/app_database.dart';
/// Exposes the opened Isar instance. Throws if [AppDatabase.open] has not run.
final isarProvider = FutureProvider<Isar>((ref) => AppDatabase.instance.open());
/// Apple IAP product identifier for the one-time unlock.
/// Configure the matching product in App Store Connect when publishing.
const String kUnlockProductId = 'com.shelingxingqiu.arcx.unlock';
/// Whether the build is a debug build (used to surface a dev-only unlock).
final bool kIsDebugBuild = kDebugMode;
+74
View File
@@ -0,0 +1,74 @@
import 'package:isar/isar.dart';
import 'package:path_provider/path_provider.dart';
import '../../core/constants/app_defaults.dart';
import 'models/models.dart';
class AppDatabase {
AppDatabase._();
static final AppDatabase instance = AppDatabase._();
Isar? _isar;
Future<Isar> open() async {
final existing = _isar;
if (existing != null && existing.isOpen) {
return existing;
}
final directory = await getApplicationDocumentsDirectory();
final isar = await Isar.open(
<CollectionSchema>[
UserProfileSchema,
AppConfigSchema,
PointRecordSchema,
],
name: AppDefaults.localDatabaseName,
directory: directory.path,
inspector: false,
);
await _ensureBootstrapData(isar);
_isar = isar;
return isar;
}
Future<void> close() async {
final database = _isar;
if (database == null || !database.isOpen) {
return;
}
await database.close();
_isar = null;
}
Future<void> resetSingletonData() async {
final isar = await open();
await isar.writeTxn(() async {
await isar.userProfiles.put(UserProfile.createDefault());
await isar.appConfigs.put(AppConfig.createDefault());
});
}
Future<void> _ensureBootstrapData(Isar isar) async {
final hasProfile =
await isar.userProfiles.get(AppDefaults.singletonId) != null;
final hasConfig = await isar.appConfigs.get(AppDefaults.singletonId) != null;
if (hasProfile && hasConfig) {
return;
}
await isar.writeTxn(() async {
if (!hasProfile) {
await isar.userProfiles.put(UserProfile.createDefault());
}
if (!hasConfig) {
await isar.appConfigs.put(AppConfig.createDefault());
}
});
}
}
+76
View File
@@ -0,0 +1,76 @@
import 'package:isar/isar.dart';
import '../../../core/constants/app_defaults.dart';
part 'app_config.g.dart';
enum EntitlementSource {
none,
purchase,
restore,
}
@collection
class AppConfig {
AppConfig();
Id id = AppDefaults.singletonId;
@Index(unique: true, replace: true)
late String configKey;
@Index(type: IndexType.value)
late DateTime updatedAt;
late DateTime createdAt;
int freeTrialLimit = AppDefaults.defaultFreeTrialLimit;
int usedTrialCount = 0;
bool isVipUnlocked = false;
DateTime? vipUnlockedAt;
String? lastIapErrorMessage;
@enumerated
EntitlementSource entitlementSource = EntitlementSource.none;
factory AppConfig.createDefault({DateTime? now}) {
final current = now ?? DateTime.now();
return AppConfig()
..configKey = 'local_app_config'
..createdAt = current
..updatedAt = current
..freeTrialLimit = AppDefaults.defaultFreeTrialLimit
..usedTrialCount = 0
..isVipUnlocked = false
..entitlementSource = EntitlementSource.none;
}
int get remainingTrialCount {
final remaining = freeTrialLimit - usedTrialCount;
return remaining > 0 ? remaining : 0;
}
bool get canStartScoring => isVipUnlocked || usedTrialCount < freeTrialLimit;
AppConfig copyWith({
int? freeTrialLimit,
int? usedTrialCount,
bool? isVipUnlocked,
DateTime? vipUnlockedAt,
String? lastIapErrorMessage,
EntitlementSource? entitlementSource,
DateTime? updatedAt,
}) {
return AppConfig()
..id = id
..configKey = configKey
..createdAt = createdAt
..updatedAt = updatedAt ?? DateTime.now()
..freeTrialLimit = freeTrialLimit ?? this.freeTrialLimit
..usedTrialCount = usedTrialCount ?? this.usedTrialCount
..isVipUnlocked = isVipUnlocked ?? this.isVipUnlocked
..vipUnlockedAt = vipUnlockedAt ?? this.vipUnlockedAt
..lastIapErrorMessage = lastIapErrorMessage ?? this.lastIapErrorMessage
..entitlementSource = entitlementSource ?? this.entitlementSource;
}
}
File diff suppressed because it is too large Load Diff
+3
View File
@@ -0,0 +1,3 @@
export 'app_config.dart';
export 'point_record.dart';
export 'user_profile.dart';
+151
View File
@@ -0,0 +1,151 @@
import 'package:isar/isar.dart';
part 'point_record.g.dart';
enum PointRecordStatus {
draft,
completed,
}
enum BowType {
recurve,
compound,
barebow,
traditional,
longbow,
other,
}
enum TargetFaceType {
cm40,
cm60,
cm80,
cm122,
vegas3Spot,
vertical3Spot,
custom,
}
@embedded
class HitPoint {
HitPoint({
this.arrowIndex = 0,
this.endIndex = 0,
this.normalizedX = 0,
this.normalizedY = 0,
this.score,
this.isMiss = false,
});
int arrowIndex;
int endIndex;
/// Range: -1.0 ~ 1.0. The painter can map the normalized coordinate
/// directly onto different target canvases.
double normalizedX;
double normalizedY;
int? score;
bool isMiss;
}
@collection
class PointRecord {
PointRecord();
Id id = Isar.autoIncrement;
@Index(type: IndexType.value)
late DateTime updatedAt;
@Index(type: IndexType.value)
late DateTime createdAt;
@Index(type: IndexType.value)
DateTime? completedAt;
@enumerated
PointRecordStatus status = PointRecordStatus.draft;
@enumerated
BowType bowType = BowType.recurve;
@enumerated
TargetFaceType targetFaceType = TargetFaceType.cm40;
late String title;
int distanceMeters = 18;
int endCount = 6;
int arrowsPerEnd = 6;
String? note;
List<int> scores = <int>[];
List<HitPoint> hitPoints = <HitPoint>[];
bool get isDraft => status == PointRecordStatus.draft;
int get totalArrows => scores.length;
int get totalScore => scores.fold<int>(0, (sum, item) => sum + item);
double? get averageScore =>
scores.isEmpty ? null : totalScore / scores.length;
factory PointRecord.createDraft({
required BowType bowType,
required TargetFaceType targetFaceType,
required int distanceMeters,
required int endCount,
required int arrowsPerEnd,
String? title,
DateTime? now,
}) {
final current = now ?? DateTime.now();
return PointRecord()
..title = title ?? '未完成记分'
..status = PointRecordStatus.draft
..bowType = bowType
..targetFaceType = targetFaceType
..distanceMeters = distanceMeters
..endCount = endCount
..arrowsPerEnd = arrowsPerEnd
..createdAt = current
..updatedAt = current;
}
PointRecord markCompleted({DateTime? completedAt}) {
final current = completedAt ?? DateTime.now();
return PointRecord()
..id = id
..title = title
..status = PointRecordStatus.completed
..bowType = bowType
..targetFaceType = targetFaceType
..distanceMeters = distanceMeters
..endCount = endCount
..arrowsPerEnd = arrowsPerEnd
..note = note
..scores = List<int>.from(scores)
..hitPoints = List<HitPoint>.from(hitPoints)
..createdAt = createdAt
..updatedAt = current
..completedAt = current;
}
/// Returns a draft copy with updated scores/hit points (used while editing).
PointRecord copyWithScores(List<int> scores, List<HitPoint> hitPoints) {
return PointRecord()
..id = id
..title = title
..status = status
..bowType = bowType
..targetFaceType = targetFaceType
..distanceMeters = distanceMeters
..endCount = endCount
..arrowsPerEnd = arrowsPerEnd
..note = note
..scores = List<int>.from(scores)
..hitPoints = List<HitPoint>.from(hitPoints)
..createdAt = createdAt
..updatedAt = DateTime.now()
..completedAt = completedAt;
}
}
File diff suppressed because it is too large Load Diff
+75
View File
@@ -0,0 +1,75 @@
import 'dart:math';
import 'package:isar/isar.dart';
import '../../../core/constants/app_defaults.dart';
part 'user_profile.g.dart';
enum AvatarSource {
asset,
localFile,
}
@collection
class UserProfile {
UserProfile();
Id id = AppDefaults.singletonId;
@Index(unique: true, replace: true)
late String profileKey;
@Index(type: IndexType.value)
late DateTime updatedAt;
late DateTime createdAt;
late String nickname;
String? avatarLocalPath;
late String avatarAssetPath;
@enumerated
AvatarSource avatarSource = AvatarSource.asset;
factory UserProfile.createDefault({DateTime? now, Random? random}) {
final current = now ?? DateTime.now();
final generator = random ?? Random();
final suffix = AppDefaults.nicknameRandomMin +
generator.nextInt(
AppDefaults.nicknameRandomMax - AppDefaults.nicknameRandomMin + 1,
);
return UserProfile()
..profileKey = 'local_user_profile'
..createdAt = current
..updatedAt = current
..nickname = '弓箭手_$suffix'
..avatarAssetPath = AppDefaults.defaultAvatarAssetPath
..avatarSource = AvatarSource.asset;
}
String get displayAvatarPath =>
avatarSource == AvatarSource.localFile &&
avatarLocalPath != null &&
avatarLocalPath!.trim().isNotEmpty
? avatarLocalPath!
: avatarAssetPath;
UserProfile copyWith({
String? nickname,
String? avatarLocalPath,
String? avatarAssetPath,
AvatarSource? avatarSource,
DateTime? updatedAt,
}) {
return UserProfile()
..id = id
..profileKey = profileKey
..createdAt = createdAt
..updatedAt = updatedAt ?? DateTime.now()
..nickname = nickname ?? this.nickname
..avatarLocalPath = avatarLocalPath ?? this.avatarLocalPath
..avatarAssetPath = avatarAssetPath ?? this.avatarAssetPath
..avatarSource = avatarSource ?? this.avatarSource;
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,61 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/constants/app_defaults.dart';
import '../../../core/providers/app_providers.dart';
import '../../../data/local/models/models.dart';
final appConfigProvider =
AsyncNotifierProvider<AppConfigNotifier, AppConfig>(AppConfigNotifier.new);
class AppConfigNotifier extends AsyncNotifier<AppConfig> {
@override
Future<AppConfig> build() async {
final isar = await ref.watch(isarProvider.future);
final config = await isar.appConfigs.get(AppDefaults.singletonId);
if (config != null) return config;
final fresh = AppConfig.createDefault();
await isar.writeTxn(() => isar.appConfigs.put(fresh));
return fresh;
}
Future<AppConfig> _persist(AppConfig config) async {
final isar = await ref.read(isarProvider.future);
await isar.writeTxn(() => isar.appConfigs.put(config));
state = AsyncData(config);
return config;
}
/// Called after a scoring session is saved successfully.
Future<void> incrementTrialCount() async {
final current = state.value;
if (current == null || current.isVipUnlocked) return;
final next = current.copyWith(
usedTrialCount: current.usedTrialCount + 1,
);
await _persist(next);
}
/// Marks the app as unlocked via IAP purchase or restore.
Future<void> markUnlocked({required bool fromRestore}) async {
final current = state.value;
if (current == null) return;
final next = current.copyWith(
isVipUnlocked: true,
vipUnlockedAt: DateTime.now(),
entitlementSource:
fromRestore ? EntitlementSource.restore : EntitlementSource.purchase,
);
await _persist(next);
}
Future<void> setLastError(String? message) async {
final current = state.value;
if (current == null) return;
await _persist(current.copyWith(lastIapErrorMessage: message));
}
/// Debug-only: instantly unlock without going through App Store.
Future<void> debugUnlock() async {
await markUnlocked(fromRestore: false);
}
}
@@ -0,0 +1,223 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:arcx/app/app_theme.dart';
import 'package:arcx/data/local/models/models.dart';
import 'package:arcx/features/config/application/app_config_controller.dart';
import 'package:arcx/features/home/presentation/widgets/heatmap_card.dart';
import 'package:arcx/features/home/presentation/widgets/profile_card.dart';
import 'package:arcx/features/home/presentation/widgets/score_distribution_card.dart';
import 'package:arcx/features/home/presentation/widgets/stats_card.dart';
import 'package:arcx/features/iap/presentation/iap_unlock_dialog.dart';
import 'package:arcx/features/profile/presentation/profile_edit_dialog.dart';
import 'package:arcx/features/records/presentation/point_book_list_page.dart';
import 'package:arcx/features/scoring/application/scoring_service.dart';
import 'package:arcx/features/scoring/presentation/point_book_create_page.dart';
import 'package:arcx/features/scoring/presentation/point_book_edit_page.dart';
import 'package:arcx/features/stats/application/stats.dart';
class PointBookHomeScreen extends ConsumerWidget {
const PointBookHomeScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final statsAsync = ref.watch(statsProvider);
final configAsync = ref.watch(appConfigProvider);
return Scaffold(
backgroundColor: AppTheme.surfaceMuted,
appBar: AppBar(title: const Text('记分本')),
body: statsAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('数据加载失败:$e')),
data: (stats) => RefreshIndicator(
onRefresh: () async => ref.invalidate(statsProvider),
child: ListView(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 32),
children: [
ProfileCard(onEdit: () => _editProfile(context)),
const SizedBox(height: 12),
_EntitlementBanner(configAsync: configAsync),
const SizedBox(height: 12),
StatsCard(stats: stats),
const SizedBox(height: 12),
HeatmapCard(stats: stats),
const SizedBox(height: 12),
ScoreDistributionCard(stats: stats),
const SizedBox(height: 20),
_EntryButtons(
onOpenRecords: () => _openRecords(context),
onStartScoring: () => _startScoring(context, ref),
),
],
),
),
),
);
}
Future<void> _editProfile(BuildContext context) async {
await showDialog<void>(
context: context,
builder: (_) => const ProfileEditDialog(),
);
}
Future<void> _openRecords(BuildContext context) async {
await Navigator.of(context).push(
MaterialPageRoute<void>(builder: (_) => const PointBookListPage()),
);
}
Future<void> _startScoring(BuildContext context, WidgetRef ref) async {
final config = ref.read(appConfigProvider).value;
if (config == null) return;
// IAP / trial gate.
if (!config.canStartScoring) {
await showDialog<void>(
context: context,
builder: (_) => const IapUnlockDialog(),
);
return;
}
// Draft check.
final notifier = ref.read(pointRecordsProvider.notifier);
final draft = await notifier.currentDraft();
if (draft != null && context.mounted) {
final choice = await _showDraftConfirm(context);
if (choice == null) return;
if (choice == true) {
await _goToEdit(context, draft);
return;
}
await notifier.clearDrafts();
}
if (!context.mounted) return;
await _goToCreate(context);
}
Future<void> _goToCreate(BuildContext context) async {
final created = await Navigator.of(context).push<PointRecord>(
MaterialPageRoute<PointRecord>(
builder: (_) => const PointBookCreatePage(),
),
);
if (created != null && context.mounted) {
await _goToEdit(context, created, replacePrevious: true);
}
}
Future<void> _goToEdit(
BuildContext context,
PointRecord record, {
bool replacePrevious = false,
}) async {
final route = MaterialPageRoute<void>(
builder: (_) => PointBookEditPage(recordId: record.id),
);
if (replacePrevious) {
Navigator.of(context).pushReplacement(route);
} else {
await Navigator.of(context).push(route);
}
}
Future<bool?> _showDraftConfirm(BuildContext context) {
return showDialog<bool>(
context: context,
builder: (_) => AlertDialog(
title: const Text('存在未完成草稿'),
content: const Text('检测到上次有未完成的记分草稿,是否继续编辑?选择“重新计分”将删除该草稿。'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('重新计分'),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('继续编辑'),
),
],
),
);
}
}
class _EntitlementBanner extends StatelessWidget {
const _EntitlementBanner({required this.configAsync});
final AsyncValue<AppConfig> configAsync;
@override
Widget build(BuildContext context) {
return configAsync.when(
loading: () => const SizedBox.shrink(),
error: (_, __) => const SizedBox.shrink(),
data: (config) {
final unlocked = config.isVipUnlocked;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: unlocked
? AppTheme.primary.withValues(alpha: 0.12)
: AppTheme.accent.withValues(alpha: 0.18),
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Icon(
unlocked ? Icons.verified_outlined : Icons.timer_outlined,
color: unlocked ? AppTheme.primary : AppTheme.accent,
size: 20,
),
const SizedBox(width: 8),
Expanded(
child: Text(
unlocked
? '已解锁无限记分权益'
: '免费试用剩余 ${config.remainingTrialCount}/${config.freeTrialLimit}',
style: TextStyle(
fontSize: 13,
color: unlocked ? AppTheme.primaryDark : AppTheme.accent,
fontWeight: FontWeight.w600,
),
),
),
],
),
);
},
);
}
}
class _EntryButtons extends StatelessWidget {
const _EntryButtons({required this.onOpenRecords, required this.onStartScoring});
final VoidCallback onOpenRecords;
final VoidCallback onStartScoring;
@override
Widget build(BuildContext context) {
return Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: onOpenRecords,
icon: const Icon(Icons.list_alt_outlined),
label: const Text('计分记录'),
),
),
const SizedBox(width: 12),
Expanded(
child: ElevatedButton.icon(
onPressed: onStartScoring,
icon: const Icon(Icons.play_arrow),
label: const Text('开始记分'),
),
),
],
);
}
}
@@ -0,0 +1,63 @@
import 'package:flutter/material.dart';
import 'package:arcx/app/app_theme.dart';
import 'package:arcx/data/local/models/models.dart';
import 'package:arcx/features/scoring/presentation/widgets/target_face_painter.dart';
import 'package:arcx/features/stats/application/stats.dart';
class HeatmapCard extends StatelessWidget {
const HeatmapCard({super.key, required this.stats});
final StatsSummary stats;
@override
Widget build(BuildContext context) {
final hasHits = stats.hitPoints.isNotEmpty;
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'落点热力图',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
),
const SizedBox(height: 12),
Center(
child: AspectRatio(
aspectRatio: 1,
child: hasHits
? CustomPaint(
painter: TargetFacePainter(
hitPoints: stats.hitPoints,
hitColor: AppTheme.hitPoint,
),
child: const SizedBox.expand(),
)
: CustomPaint(
painter: TargetFacePainter(
hitPoints: const [],
showAllHits: false,
),
child: const SizedBox.expand(),
),
),
),
if (!hasHits)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
'暂无落点数据,完成一次记分后展示',
style: TextStyle(
fontSize: 13,
color: AppTheme.textSecondary,
),
),
),
],
),
),
);
}
}
@@ -0,0 +1,93 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:arcx/app/app_theme.dart';
import 'package:arcx/core/constants/app_defaults.dart';
import 'package:arcx/data/local/models/models.dart';
import 'package:arcx/features/profile/application/profile_controller.dart';
class ProfileCard extends ConsumerWidget {
const ProfileCard({super.key, required this.onEdit});
final VoidCallback onEdit;
@override
Widget build(BuildContext context, WidgetRef ref) {
final profileAsync = ref.watch(profileProvider);
return profileAsync.when(
loading: () => const _CardShell(child: SizedBox(height: 64)),
error: (e, _) => _CardShell(child: Text('加载失败:$e')),
data: (profile) => _CardShell(
child: Row(
children: [
_Avatar(profile: profile),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
profile.nickname,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 4),
Text(
'本地用户',
style: TextStyle(
fontSize: 13,
color: AppTheme.textSecondary,
),
),
],
),
),
IconButton(
icon: const Icon(Icons.edit_outlined),
color: AppTheme.primary,
onPressed: onEdit,
),
],
),
),
);
}
}
class _Avatar extends StatelessWidget {
const _Avatar({required this.profile});
final UserProfile profile;
@override
Widget build(BuildContext context) {
final source = profile.avatarSource;
final image =
source == AvatarSource.localFile &&
profile.avatarLocalPath != null &&
profile.avatarLocalPath!.isNotEmpty
? FileImage(File(profile.avatarLocalPath!))
: const AssetImage(AppDefaults.defaultAvatarAssetPath);
return CircleAvatar(
radius: 30,
backgroundColor: AppTheme.surfaceMuted,
backgroundImage: image as ImageProvider,
);
}
}
class _CardShell extends StatelessWidget {
const _CardShell({required this.child});
final Widget child;
@override
Widget build(BuildContext context) {
return Card(
child: Padding(padding: const EdgeInsets.all(16), child: child),
);
}
}
@@ -0,0 +1,118 @@
import 'package:flutter/material.dart';
import 'package:arcx/app/app_theme.dart';
import 'package:arcx/features/stats/application/stats.dart';
class ScoreDistributionCard extends StatelessWidget {
const ScoreDistributionCard({super.key, required this.stats});
final StatsSummary stats;
@override
Widget build(BuildContext context) {
final buckets = stats.scoreDistribution;
final hasData = buckets.isNotEmpty;
final maxCount = hasData
? buckets.map((b) => b.count).reduce((a, b) => a > b ? a : b)
: 1;
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'环值分布',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
),
const SizedBox(height: 12),
if (!hasData)
Padding(
padding: const EdgeInsets.symmetric(vertical: 24),
child: Center(
child: Text(
'暂无数据',
style: TextStyle(
fontSize: 13,
color: AppTheme.textSecondary,
),
),
),
)
else
SizedBox(
height: 140,
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
for (var i = 0; i < buckets.length; i++) ...[
Expanded(
child: _Bar(
label: buckets[i].label,
count: buckets[i].count,
ratio: buckets[i].count / maxCount,
),
),
if (i < buckets.length - 1) const SizedBox(width: 6),
],
],
),
),
],
),
),
);
}
}
class _Bar extends StatelessWidget {
const _Bar({
required this.label,
required this.count,
required this.ratio,
});
final String label;
final int count;
final double ratio;
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final maxH = constraints.maxHeight - 28; // reserve space for label
final h = (maxH * ratio).clamp(4.0, maxH);
return Column(
mainAxisAlignment: MainAxisAlignment.end,
children: [
Text(
'$count',
style: TextStyle(
fontSize: 11,
color: AppTheme.textSecondary,
),
),
const SizedBox(height: 4),
Container(
width: double.infinity,
height: h,
decoration: BoxDecoration(
color: AppTheme.primary,
borderRadius: BorderRadius.circular(4),
),
),
const SizedBox(height: 4),
Text(
label,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
],
);
},
);
}
}
@@ -0,0 +1,144 @@
import 'package:flutter/material.dart';
import 'package:arcx/app/app_theme.dart';
import 'package:arcx/features/stats/application/stats.dart';
class StatsCard extends StatelessWidget {
const StatsCard({super.key, required this.stats});
final StatsSummary stats;
@override
Widget build(BuildContext context) {
final hasData = stats.hasData;
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Text(
'训练统计',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
),
const Spacer(),
Text(
'今日',
style: TextStyle(fontSize: 13, color: AppTheme.textSecondary),
),
],
),
const SizedBox(height: 12),
_StatGrid(
items: [
_StatItem(
label: '今日射箭',
value: hasData ? '${stats.todayArrowCount}' : '-',
unit: '',
),
_StatItem(
label: '今日消耗',
value: hasData ? stats.todayCalories.toStringAsFixed(1) : '-',
unit: '千卡',
),
_StatItem(
label: '运动强度',
value: hasData ? stats.todayIntensityLabel : '-',
unit: hasData
? stats.todayIntensityRaw.toStringAsFixed(1)
: '',
),
],
),
const Divider(height: 28),
_StatGrid(
items: [
_StatItem(
label: '训练天数',
value: hasData ? '${stats.trainingDays}' : '-',
unit: '',
),
_StatItem(
label: '累计射箭',
value: hasData ? '${stats.totalArrowCount}' : '-',
unit: '',
),
_StatItem(
label: '平均环数',
value: hasData ? stats.averageScoreLabel : '-',
unit: '',
),
],
),
],
),
),
);
}
}
class _StatGrid extends StatelessWidget {
const _StatGrid({required this.items});
final List<_StatItem> items;
@override
Widget build(BuildContext context) {
return Row(
children: [
for (var i = 0; i < items.length; i++) ...[
Expanded(child: items[i]),
if (i < items.length - 1) const SizedBox(width: 8),
],
],
);
}
}
class _StatItem extends StatelessWidget {
const _StatItem({
required this.label,
required this.value,
required this.unit,
});
final String label;
final String value;
final String unit;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: TextStyle(fontSize: 12, color: AppTheme.textSecondary),
),
const SizedBox(height: 4),
Row(
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: [
Text(
value,
style: const TextStyle(
fontSize: 22,
fontWeight: FontWeight.w700,
color: AppTheme.primaryDark,
),
),
if (unit.isNotEmpty) ...[
const SizedBox(width: 2),
Text(
unit,
style: TextStyle(fontSize: 12, color: AppTheme.textSecondary),
),
],
],
),
],
);
}
}
@@ -0,0 +1,226 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:in_app_purchase/in_app_purchase.dart';
import '../../../core/providers/app_providers.dart';
import '../../config/application/app_config_controller.dart';
/// Observable state for the in-app purchase flow.
class IapState {
const IapState({
this.product,
this.isLoading = false,
this.isPurchasing = false,
this.errorMessage,
this.productsAvailable = false,
this.lastEvent,
});
final ProductDetails? product;
final bool isLoading;
final bool isPurchasing;
final String? errorMessage;
final bool productsAvailable;
/// Last human-readable event (e.g. "解锁成功").
final String? lastEvent;
IapState copyWith({
ProductDetails? product,
bool? isLoading,
bool? isPurchasing,
String? errorMessage,
bool? productsAvailable,
String? lastEvent,
}) {
return IapState(
product: product ?? this.product,
isLoading: isLoading ?? this.isLoading,
isPurchasing: isPurchasing ?? this.isPurchasing,
errorMessage: errorMessage,
productsAvailable: productsAvailable ?? this.productsAvailable,
lastEvent: lastEvent,
);
}
}
final iapControllerProvider = NotifierProvider<IapController, IapState>(
IapController.new,
);
class IapController extends Notifier<IapState> {
late final InAppPurchase _store = InAppPurchase.instance;
StreamSubscription<List<PurchaseDetails>>? _sub;
@override
IapState build() {
ref.onDispose(() {
_sub?.cancel();
_sub = null;
});
_init();
return const IapState(isLoading: true);
}
Future<void> _init() async {
final available = await _store.isAvailable();
if (!available) {
state = state.copyWith(
isLoading: false,
productsAvailable: false,
errorMessage: '无法连接到 App Store,请检查网络设置',
);
return;
}
_sub = _store.purchaseStream.listen(
_onPurchaseUpdated,
onError: (Object e) {
state = state.copyWith(isPurchasing: false, errorMessage: '支付失败:$e');
},
);
await _loadProduct();
}
Future<void> _loadProduct() async {
final response = await _store.queryProductDetails({kUnlockProductId});
final error = response.error;
if (error != null) {
state = state.copyWith(
isLoading: false,
productsAvailable: false,
errorMessage: '商品信息加载失败:${error.message}',
);
return;
}
final products = response.productDetails;
if (products.isEmpty) {
// No product configured in App Store Connect yet.
state = state.copyWith(
isLoading: false,
productsAvailable: false,
errorMessage: null,
);
return;
}
final product = products.first;
state = state.copyWith(
isLoading: false,
productsAvailable: true,
product: product,
errorMessage: null,
);
}
Future<void> buy() async {
final product = state.product;
if (product == null) {
state = state.copyWith(errorMessage: '商品尚未就绪,请稍后再试');
return;
}
state = state.copyWith(
isPurchasing: true,
errorMessage: null,
lastEvent: null,
);
final param = PurchaseParam(productDetails: product);
final ok = await _store.buyNonConsumable(purchaseParam: param);
if (!ok) {
state = state.copyWith(isPurchasing: false, errorMessage: '支付已取消');
}
}
Future<void> restore() async {
state = state.copyWith(
isLoading: true,
errorMessage: null,
lastEvent: null,
);
await _store.restorePurchases();
// The purchase stream will deliver restored purchases; clear loading after
// a short delay in case nothing arrives.
Future<void>.delayed(const Duration(seconds: 2), () {
if (state.isLoading) {
state = state.copyWith(
isLoading: false,
lastEvent: state.lastEvent ?? '未找到历史购买记录',
);
}
});
}
void _onPurchaseUpdated(List<PurchaseDetails> purchases) {
for (final purchase in purchases) {
_handle(purchase);
}
}
Future<void> _handle(PurchaseDetails purchase) async {
switch (purchase.status) {
case PurchaseStatus.purchased:
case PurchaseStatus.restored:
if (purchase.pendingCompletePurchase) {
await _store.completePurchase(purchase);
}
await ref
.read(appConfigProvider.notifier)
.markUnlocked(
fromRestore: purchase.status == PurchaseStatus.restored,
);
state = IapState(
product: state.product,
productsAvailable: state.productsAvailable,
lastEvent: purchase.status == PurchaseStatus.restored
? '权益已恢复'
: '解锁成功!已获得无限记分权益',
);
break;
case PurchaseStatus.error:
state = state.copyWith(
isPurchasing: false,
errorMessage: '支付失败:${purchase.error?.message ?? '未知错误'}',
);
break;
case PurchaseStatus.canceled:
state = state.copyWith(isPurchasing: false, lastEvent: '支付已取消');
break;
case PurchaseStatus.pending:
// Purchase awaiting approval/processing; keep the loading state.
state = state.copyWith(isPurchasing: true);
break;
}
}
/// Debug-only unlock used so the app remains usable without App Store
/// Connect configuration during development.
Future<void> debugUnlock() async {
await ref.read(appConfigProvider.notifier).debugUnlock();
state = state.copyWith(lastEvent: '已通过开发模式解锁');
}
void clearEvent() {
state = state.copyWith(lastEvent: null, errorMessage: null);
}
}
@visibleForTesting
IapState iapStateForTest({
ProductDetails? product,
bool isLoading = false,
bool isPurchasing = false,
String? errorMessage,
bool productsAvailable = false,
String? lastEvent,
}) {
return IapState(
product: product,
isLoading: isLoading,
isPurchasing: isPurchasing,
errorMessage: errorMessage,
productsAvailable: productsAvailable,
lastEvent: lastEvent,
);
}
@@ -0,0 +1,156 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:arcx/app/app_theme.dart';
import 'package:arcx/features/iap/application/iap_controller.dart';
class IapUnlockDialog extends ConsumerWidget {
const IapUnlockDialog({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final iap = ref.watch(iapControllerProvider);
final product = iap.product;
ref.listen(iapControllerProvider, (prev, next) {
if (next.lastEvent != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(next.lastEvent!)),
);
if (next.lastEvent!.contains('解锁') ||
next.lastEvent!.contains('恢复')) {
Navigator.of(context).pop();
}
} else if (next.errorMessage != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(next.errorMessage!)),
);
}
});
return AlertDialog(
title: const Text('解锁记分本'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'免费试用次数已用完。购买后即可永久解锁无限记分权益,无需联网即可使用。',
style: TextStyle(fontSize: 14),
),
const SizedBox(height: 16),
_PriceRow(
title: product?.title ?? '记分本永久解锁',
price: iap.productsAvailable && product != null
? product.price
: '',
),
const SizedBox(height: 8),
_FeatureItem(text: '无限次记分训练'),
_FeatureItem(text: '本地永久保存训练数据'),
_FeatureItem(text: '一次购买,永久使用'),
if (iap.isLoading) ...[
const SizedBox(height: 12),
const Center(child: CircularProgressIndicator()),
],
],
),
),
actions: [
TextButton(
onPressed: iap.isPurchasing
? null
: () => Navigator.of(context).pop(),
child: const Text('暂不解锁'),
),
TextButton(
onPressed: iap.isPurchasing ? null : () => ref.read(iapControllerProvider.notifier).restore(),
child: const Text('恢复购买'),
),
FilledButton(
onPressed: iap.isPurchasing || iap.isLoading
? null
: () => _unlock(context, ref, iap),
child: iap.isPurchasing
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
)
: const Text('立即解锁'),
),
],
);
}
Future<void> _unlock(
BuildContext context,
WidgetRef ref,
IapState iap,
) async {
// In debug builds with no App Store product configured, fall back to a
// dev unlock so the app remains usable for testing.
if (kDebugMode && !iap.productsAvailable) {
await ref.read(iapControllerProvider.notifier).debugUnlock();
if (context.mounted) Navigator.of(context).pop();
return;
}
await ref.read(iapControllerProvider.notifier).buy();
}
}
class _PriceRow extends StatelessWidget {
const _PriceRow({required this.title, required this.price});
final String title;
final String price;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: AppTheme.surfaceMuted,
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Expanded(
child: Text(
title,
style: const TextStyle(fontWeight: FontWeight.w600),
),
),
Text(
price,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: AppTheme.primary,
),
),
],
),
);
}
}
class _FeatureItem extends StatelessWidget {
const _FeatureItem({required this.text});
final String text;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(top: 8),
child: Row(
children: [
const Icon(Icons.check_circle, color: AppTheme.primary, size: 18),
const SizedBox(width: 8),
Text(text, style: const TextStyle(fontSize: 13)),
],
),
);
}
}
@@ -0,0 +1,80 @@
import 'dart:io';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:image_picker/image_picker.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import '../../../core/constants/app_defaults.dart';
import '../../../core/providers/app_providers.dart';
import '../../../data/local/models/models.dart';
final profileProvider =
AsyncNotifierProvider<ProfileNotifier, UserProfile>(ProfileNotifier.new);
class ProfileNotifier extends AsyncNotifier<UserProfile> {
@override
Future<UserProfile> build() async {
final isar = await ref.watch(isarProvider.future);
final profile = await isar.userProfiles.get(AppDefaults.singletonId);
if (profile != null) return profile;
// Bootstrap should have created one; create defensively.
final fresh = UserProfile.createDefault();
await isar.writeTxn(() => isar.userProfiles.put(fresh));
return fresh;
}
Future<void> updateNickname(String nickname) async {
final trimmed = nickname.trim();
if (trimmed.isEmpty) return;
final current = state.value;
if (current == null) return;
final updated = current.copyWith(nickname: trimmed);
final isar = await ref.read(isarProvider.future);
await isar.writeTxn(() => isar.userProfiles.put(updated));
state = AsyncData(updated);
}
/// Picks an image from gallery or camera, copies it into the app sandbox
/// and stores the local file path on the profile.
Future<bool> updateAvatar({required bool fromCamera}) async {
final current = state.value;
if (current == null) return false;
final picker = ImagePicker();
final xfile = await picker.pickImage(
source: fromCamera ? ImageSource.camera : ImageSource.gallery,
maxWidth: 512,
maxHeight: 512,
imageQuality: 85,
);
if (xfile == null) return false;
final docs = await getApplicationDocumentsDirectory();
final destPath = p.join(docs.path, AppDefaults.avatarFileName);
final sourceFile = File(xfile.path);
await sourceFile.copy(destPath);
final updated = current.copyWith(
avatarLocalPath: destPath,
avatarSource: AvatarSource.localFile,
);
final isar = await ref.read(isarProvider.future);
await isar.writeTxn(() => isar.userProfiles.put(updated));
state = AsyncData(updated);
return true;
}
Future<void> resetAvatar() async {
final current = state.value;
if (current == null) return;
final updated = current.copyWith(
avatarAssetPath: AppDefaults.defaultAvatarAssetPath,
avatarLocalPath: null,
avatarSource: AvatarSource.asset,
);
final isar = await ref.read(isarProvider.future);
await isar.writeTxn(() => isar.userProfiles.put(updated));
state = AsyncData(updated);
}
}
@@ -0,0 +1,169 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:arcx/app/app_theme.dart';
import 'package:arcx/core/constants/app_defaults.dart';
import 'package:arcx/data/local/models/models.dart';
import 'package:arcx/features/profile/application/profile_controller.dart';
class ProfileEditDialog extends ConsumerStatefulWidget {
const ProfileEditDialog({super.key});
@override
ConsumerState<ProfileEditDialog> createState() => _ProfileEditDialogState();
}
class _ProfileEditDialogState extends ConsumerState<ProfileEditDialog> {
late final TextEditingController _controller;
bool _saving = false;
@override
void initState() {
super.initState();
final profile = ref.read(profileProvider).value;
_controller = TextEditingController(text: profile?.nickname ?? '');
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final profile = ref.watch(profileProvider).value;
return AlertDialog(
title: const Text('编辑个人资料'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
GestureDetector(
onTap: _saving ? null : () => _pickAvatar(context),
child: _AvatarPreview(profile: profile),
),
if (profile?.avatarSource == AvatarSource.localFile)
TextButton.icon(
onPressed: _saving ? null : _resetAvatar,
icon: const Icon(Icons.refresh, size: 18),
label: const Text('恢复默认头像'),
),
const SizedBox(height: 8),
TextField(
controller: _controller,
maxLength: 12,
decoration: const InputDecoration(
labelText: '昵称',
hintText: '1~12 个字符',
counterText: '',
),
),
],
),
),
actions: [
TextButton(
onPressed: _saving ? null : () => Navigator.pop(context),
child: const Text('取消'),
),
FilledButton(
onPressed: _saving ? null : _save,
child: const Text('保存'),
),
],
);
}
Future<void> _pickAvatar(BuildContext context) async {
final choice = await showModalBottomSheet<AvatarAction>(
context: context,
builder: (_) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.photo_outlined),
title: const Text('从相册选择'),
onTap: () => Navigator.pop(context, AvatarAction.gallery),
),
ListTile(
leading: const Icon(Icons.camera_alt_outlined),
title: const Text('拍摄照片'),
onTap: () => Navigator.pop(context, AvatarAction.camera),
),
],
),
),
);
if (choice == null) return;
setState(() => _saving = true);
await ref
.read(profileProvider.notifier)
.updateAvatar(fromCamera: choice == AvatarAction.camera);
if (mounted) setState(() => _saving = false);
}
Future<void> _resetAvatar() async {
setState(() => _saving = true);
await ref.read(profileProvider.notifier).resetAvatar();
if (mounted) setState(() => _saving = false);
}
Future<void> _save() async {
final name = _controller.text.trim();
if (name.isEmpty) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('昵称不能为空')));
return;
}
setState(() => _saving = true);
await ref.read(profileProvider.notifier).updateNickname(name);
if (mounted) {
setState(() => _saving = false);
Navigator.pop(context);
}
}
}
enum AvatarAction { gallery, camera }
class _AvatarPreview extends StatelessWidget {
const _AvatarPreview({required this.profile});
final UserProfile? profile;
@override
Widget build(BuildContext context) {
final p = profile;
final ImageProvider image;
if (p != null &&
p.avatarSource == AvatarSource.localFile &&
p.avatarLocalPath != null &&
p.avatarLocalPath!.isNotEmpty) {
image = FileImage(File(p.avatarLocalPath!));
} else {
image = const AssetImage(AppDefaults.defaultAvatarAssetPath);
}
return Stack(
alignment: Alignment.bottomRight,
children: [
CircleAvatar(
radius: 44,
backgroundColor: AppTheme.surfaceMuted,
backgroundImage: image,
),
Container(
padding: const EdgeInsets.all(6),
decoration: const BoxDecoration(
color: AppTheme.primary,
shape: BoxShape.circle,
),
child: const Icon(Icons.edit, color: Colors.white, size: 16),
),
],
);
}
}
@@ -0,0 +1,314 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:arcx/app/app_theme.dart';
import 'package:arcx/data/local/models/models.dart';
import 'package:arcx/features/scoring/application/scoring_service.dart';
import 'package:arcx/features/scoring/presentation/point_book_edit_page.dart';
class PointBookListPage extends ConsumerStatefulWidget {
const PointBookListPage({super.key});
@override
ConsumerState<PointBookListPage> createState() => _PointBookListPageState();
}
class _PointBookListPageState extends ConsumerState<PointBookListPage> {
BowType? _bowFilter;
TargetFaceType? _targetFilter;
@override
Widget build(BuildContext context) {
final recordsAsync = ref.watch(pointRecordsProvider);
return Scaffold(
appBar: AppBar(title: const Text('计分记录')),
body: Column(
children: [
_FilterBar(
bowFilter: _bowFilter,
targetFilter: _targetFilter,
onBowChanged: (v) => setState(() => _bowFilter = v),
onTargetChanged: (v) => setState(() => _targetFilter = v),
),
Expanded(
child: recordsAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('加载失败:$e')),
data: (records) {
final filtered = _applyFilters(records);
if (filtered.isEmpty) {
return _EmptyState(hasAny: records.isNotEmpty);
}
return ListView.separated(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 24),
itemCount: filtered.length,
separatorBuilder: (_, __) => const SizedBox(height: 8),
itemBuilder: (_, index) {
final record = filtered[index];
return _RecordTile(
record: record,
onTap: () => _open(context, record),
onDelete: () => _confirmDelete(context, record),
);
},
);
},
),
),
],
),
);
}
List<PointRecord> _applyFilters(List<PointRecord> records) {
var result = records;
if (_bowFilter != null) {
result = result.where((r) => r.bowType == _bowFilter).toList();
}
if (_targetFilter != null) {
result = result.where((r) => r.targetFaceType == _targetFilter).toList();
}
return result;
}
Future<void> _open(BuildContext context, PointRecord record) async {
await Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => PointBookEditPage(recordId: record.id, readOnly: true),
),
);
}
Future<void> _confirmDelete(BuildContext context, PointRecord record) async {
final ok = await showDialog<bool>(
context: context,
builder: (_) => AlertDialog(
title: const Text('删除记录'),
content: const Text('确定删除该记分记录吗?此操作不可撤销。'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('取消'),
),
FilledButton(
style: FilledButton.styleFrom(backgroundColor: Colors.red),
onPressed: () => Navigator.pop(context, true),
child: const Text('删除'),
),
],
),
);
if (ok == true) {
await ref.read(pointRecordsProvider.notifier).delete(record.id);
}
}
}
class _FilterBar extends StatelessWidget {
const _FilterBar({
required this.bowFilter,
required this.targetFilter,
required this.onBowChanged,
required this.onTargetChanged,
});
final BowType? bowFilter;
final TargetFaceType? targetFilter;
final ValueChanged<BowType?> onBowChanged;
final ValueChanged<TargetFaceType?> onTargetChanged;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
child: Row(
children: [
Expanded(
child: _Dropdown<BowType>(
label: '弓型',
value: bowFilter,
items: BowType.values,
labelOf: _bowLabel,
onChanged: onBowChanged,
),
),
const SizedBox(width: 12),
Expanded(
child: _Dropdown<TargetFaceType>(
label: '靶纸',
value: targetFilter,
items: TargetFaceType.values,
labelOf: _targetLabel,
onChanged: onTargetChanged,
),
),
],
),
);
}
}
class _Dropdown<T> extends StatelessWidget {
const _Dropdown({
required this.label,
required this.value,
required this.items,
required this.labelOf,
required this.onChanged,
});
final String label;
final T? value;
final List<T> items;
final String Function(T) labelOf;
final ValueChanged<T?> onChanged;
@override
Widget build(BuildContext context) {
return DropdownButtonFormField<T>(
value: value,
decoration: InputDecoration(
labelText: label,
isDense: true,
contentPadding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(10)),
),
items: [
DropdownMenuItem<T>(value: null, child: Text('全部')),
...items.map((e) => DropdownMenuItem<T>(
value: e,
child: Text(labelOf(e)),
)),
],
onChanged: onChanged,
);
}
}
class _RecordTile extends StatelessWidget {
const _RecordTile({
required this.record,
required this.onTap,
required this.onDelete,
});
final PointRecord record;
final VoidCallback onTap;
final VoidCallback onDelete;
@override
Widget build(BuildContext context) {
final isDraft = record.isDraft;
return Card(
child: ListTile(
onTap: onTap,
title: Row(
children: [
Expanded(
child: Text(
record.title,
style: const TextStyle(fontWeight: FontWeight.w600),
),
),
if (isDraft)
Container(
padding:
const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: AppTheme.accent.withValues(alpha: 0.18),
borderRadius: BorderRadius.circular(6),
),
child: Text(
'草稿',
style: TextStyle(
fontSize: 11,
color: AppTheme.accent,
fontWeight: FontWeight.w600,
),
),
),
],
),
subtitle: Text(
'${_bowLabel(record.bowType)} · ${record.distanceMeters}m · ${_targetLabel(record.targetFaceType)}\n'
'${_formatDate(record.createdAt)} ${record.totalArrows}${record.averageScore != null ? '${record.averageScore!.toStringAsFixed(1)}' : ''}',
style: TextStyle(fontSize: 12, color: AppTheme.textSecondary),
),
isThreeLine: true,
trailing: IconButton(
icon: const Icon(Icons.delete_outline, color: Colors.red),
onPressed: onDelete,
),
),
);
}
}
class _EmptyState extends StatelessWidget {
const _EmptyState({required this.hasAny});
final bool hasAny;
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.history,
size: 64,
color: AppTheme.textSecondary.withValues(alpha: 0.4),
),
const SizedBox(height: 12),
Text(
hasAny ? '当前筛选条件下无记录' : '暂无记分记录',
style: TextStyle(color: AppTheme.textSecondary),
),
],
),
);
}
}
String _bowLabel(BowType t) {
switch (t) {
case BowType.recurve:
return '反曲弓';
case BowType.compound:
return '复合弓';
case BowType.barebow:
return '光弓';
case BowType.traditional:
return '传统弓';
case BowType.longbow:
return '长弓';
case BowType.other:
return '其他';
}
}
String _targetLabel(TargetFaceType t) {
switch (t) {
case TargetFaceType.cm40:
return '40cm';
case TargetFaceType.cm60:
return '60cm';
case TargetFaceType.cm80:
return '80cm';
case TargetFaceType.cm122:
return '122cm';
case TargetFaceType.vegas3Spot:
return '维加斯三联';
case TargetFaceType.vertical3Spot:
return '垂直三联';
case TargetFaceType.custom:
return '自定义';
}
}
String _formatDate(DateTime dt) {
return '${dt.year}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')} '
'${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}';
}
@@ -0,0 +1,62 @@
import '../../../data/local/models/models.dart';
/// Computes archery score from a normalized hit coordinate.
///
/// Normalized coordinates use the range [-1, 1] where (0, 0) is the target
/// center and radius 1.0 is the outer scoring edge. Hits outside radius 1.0
/// count as a miss (M, score 0).
class ScoreMath {
const ScoreMath._();
/// Returns the score [0..10] for a hit at [normalizedX]/[normalizedY].
static int scoreFor(double normalizedX, double normalizedY) {
final d = distance(normalizedX, normalizedY);
if (d > 1.0) return 0; // miss
final ring = (d * 10).floor();
var score = 10 - ring;
if (score < 1) score = 1;
if (score > 10) score = 10;
return score;
}
/// Euclidean distance from center for normalized coords.
static double distance(double normalizedX, double normalizedY) {
final sq = normalizedX * normalizedX + normalizedY * normalizedY;
if (sq <= 0) return 0.0;
return _sqrt(sq);
}
/// Converts a pixel tap (relative to a square target of [size]) into a
/// normalized coordinate in [-1, 1] and returns the resulting [HitPoint].
static HitPoint hitPointFromPixels(
double localX,
double localY,
double size, {
required int arrowIndex,
required int endIndex,
}) {
final half = size / 2;
final nx = (localX - half) / half;
final ny = (localY - half) / half;
final score = scoreFor(nx, ny);
return HitPoint(
arrowIndex: arrowIndex,
endIndex: endIndex,
normalizedX: nx,
normalizedY: ny,
score: score,
isMiss: score == 0,
);
}
}
double _sqrt(double x) {
// Newton's method; precise enough for scoring distances.
if (x < 0) return double.nan;
if (x == 0) return 0.0;
var r = x;
for (var i = 0; i < 20; i++) {
r = (r + x / r) / 2;
}
return r;
}
@@ -0,0 +1,100 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:isar/isar.dart';
import '../../../core/providers/app_providers.dart';
import '../../../data/local/models/models.dart';
/// All local [PointRecord]s (drafts + completed), newest first.
final pointRecordsProvider =
AsyncNotifierProvider<PointRecordsNotifier, List<PointRecord>>(
PointRecordsNotifier.new,
);
/// Loads a single record by id (used by the edit/view page).
final recordByIdProvider =
FutureProvider.family<PointRecord?, int>((ref, id) async {
final isar = await ref.watch(isarProvider.future);
return isar.pointRecords.get(id);
});
class PointRecordsNotifier extends AsyncNotifier<List<PointRecord>> {
@override
Future<List<PointRecord>> build() async {
final isar = await ref.watch(isarProvider.future);
return isar.pointRecords.where().sortByCreatedAtDesc().findAll();
}
Future<Isar> _isar() => ref.read(isarProvider.future);
/// Returns the current draft record, if any.
Future<PointRecord?> currentDraft() async {
final isar = await _isar();
return isar.pointRecords
.filter()
.statusEqualTo(PointRecordStatus.draft)
.sortByCreatedAtDesc()
.findFirst();
}
/// Creates a new draft from scoring parameters and returns it.
Future<PointRecord> createDraft({
required BowType bowType,
required TargetFaceType targetFaceType,
required int distanceMeters,
required int endCount,
required int arrowsPerEnd,
String? title,
}) async {
final isar = await _isar();
final draft = PointRecord.createDraft(
bowType: bowType,
targetFaceType: targetFaceType,
distanceMeters: distanceMeters,
endCount: endCount,
arrowsPerEnd: arrowsPerEnd,
title: title,
);
await isar.writeTxn(() => isar.pointRecords.put(draft));
state = AsyncData([draft, ...?state.value]);
return draft;
}
/// Replaces a record (used by the edit page to persist ongoing changes).
Future<void> upsert(PointRecord record) async {
final isar = await _isar();
final updated = record..updatedAt = DateTime.now();
await isar.writeTxn(() => isar.pointRecords.put(updated));
state = AsyncData(state.value?.map((e) => e.id == updated.id ? updated : e).toList() ?? [updated]);
}
/// Marks a draft as completed and persists it. Does NOT touch trial count.
Future<PointRecord> complete(PointRecord record) async {
final completed = record.markCompleted();
final isar = await _isar();
await isar.writeTxn(() => isar.pointRecords.put(completed));
state = AsyncData(
state.value?.map((e) => e.id == completed.id ? completed : e).toList() ?? [completed],
);
return completed;
}
/// Deletes a single record by id.
Future<void> delete(int id) async {
final isar = await _isar();
await isar.writeTxn(() => isar.pointRecords.delete(id));
state = AsyncData(state.value?.where((e) => e.id != id).toList() ?? const []);
}
/// Removes any existing draft records (used when the user picks
/// "重新计分" on the home draft-confirmation dialog).
Future<void> clearDrafts() async {
final isar = await _isar();
final drafts = await isar.pointRecords
.filter()
.statusEqualTo(PointRecordStatus.draft)
.findAll();
if (drafts.isEmpty) return;
await isar.writeTxn(() => isar.pointRecords.deleteAll(drafts.map((e) => e.id).toList()));
state = AsyncData(state.value?.where((e) => !drafts.any((d) => d.id == e.id)).toList() ?? const []);
}
}
@@ -0,0 +1,302 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:arcx/app/app_theme.dart';
import 'package:arcx/data/local/models/models.dart';
import 'package:arcx/features/scoring/application/scoring_service.dart';
class PointBookCreatePage extends ConsumerStatefulWidget {
const PointBookCreatePage({super.key});
@override
ConsumerState<PointBookCreatePage> createState() =>
_PointBookCreatePageState();
}
class _PointBookCreatePageState extends ConsumerState<PointBookCreatePage> {
BowType _bowType = BowType.recurve;
TargetFaceType _targetFace = TargetFaceType.cm40;
int _distance = 18;
int _endCount = 6;
int _arrowsPerEnd = 6;
bool _creating = false;
static const _distances = [10, 18, 25, 30, 40, 50, 60, 70, 90];
static const _endOptions = [3, 4, 5, 6, 8, 10, 12];
static const _arrowOptions = [3, 4, 5, 6, 12];
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('新建记分')),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
_SectionTitle('弓型'),
_ChipGroup(
options: BowType.values,
value: _bowType,
labelOf: _bowLabel,
onChanged: (v) => setState(() => _bowType = v),
),
const SizedBox(height: 16),
_SectionTitle('距离'),
_ChipGroup<int>(
options: _distances,
value: _distance,
labelOf: (v) => '${v}m',
onChanged: (v) => setState(() => _distance = v),
),
const SizedBox(height: 16),
_SectionTitle('靶纸类型'),
_ChipGroup(
options: TargetFaceType.values,
value: _targetFace,
labelOf: _targetLabel,
onChanged: (v) => setState(() => _targetFace = v),
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: _NumberPicker(
label: '组数',
value: _endCount,
options: _endOptions,
onChanged: (v) => setState(() => _endCount = v),
),
),
const SizedBox(width: 12),
Expanded(
child: _NumberPicker(
label: '每组箭数',
value: _arrowsPerEnd,
options: _arrowOptions,
onChanged: (v) => setState(() => _arrowsPerEnd = v),
),
),
],
),
const SizedBox(height: 32),
_Summary(
bowType: _bowType,
distance: _distance,
targetFace: _targetFace,
endCount: _endCount,
arrowsPerEnd: _arrowsPerEnd,
),
const SizedBox(height: 16),
FilledButton.icon(
onPressed: _creating ? null : _start,
icon: const Icon(Icons.play_arrow),
label: const Text('开始记分'),
),
],
),
);
}
Future<void> _start() async {
setState(() => _creating = true);
final draft = await ref.read(pointRecordsProvider.notifier).createDraft(
bowType: _bowType,
targetFaceType: _targetFace,
distanceMeters: _distance,
endCount: _endCount,
arrowsPerEnd: _arrowsPerEnd,
);
if (mounted) {
setState(() => _creating = false);
Navigator.of(context).pop(draft);
}
}
}
class _SectionTitle extends StatelessWidget {
const _SectionTitle(this.text);
final String text;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text(
text,
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600),
),
);
}
}
class _ChipGroup<T> extends StatelessWidget {
const _ChipGroup({
required this.options,
required this.value,
required this.labelOf,
required this.onChanged,
});
final List<T> options;
final T value;
final String Function(T) labelOf;
final ValueChanged<T> onChanged;
@override
Widget build(BuildContext context) {
return Wrap(
spacing: 8,
runSpacing: 8,
children: options.map((o) {
final selected = o == value;
return ChoiceChip(
label: Text(labelOf(o)),
selected: selected,
onSelected: (_) => onChanged(o),
selectedColor: AppTheme.primary,
labelStyle: TextStyle(
color: selected ? Colors.white : Colors.black87,
),
);
}).toList(),
);
}
}
class _NumberPicker extends StatelessWidget {
const _NumberPicker({
required this.label,
required this.value,
required this.options,
required this.onChanged,
});
final String label;
final int value;
final List<int> options;
final ValueChanged<int> onChanged;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.black12),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(label, style: const TextStyle(fontSize: 13)),
Row(
children: [
IconButton(
icon: const Icon(Icons.remove_circle_outline),
onPressed: () {
final i = options.indexOf(value);
if (i > 0) onChanged(options[i - 1]);
},
),
Text(
'$value',
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
),
),
IconButton(
icon: const Icon(Icons.add_circle_outline),
onPressed: () {
final i = options.indexOf(value);
if (i < options.length - 1) onChanged(options[i + 1]);
},
),
],
),
],
),
);
}
}
class _Summary extends StatelessWidget {
const _Summary({
required this.bowType,
required this.distance,
required this.targetFace,
required this.endCount,
required this.arrowsPerEnd,
});
final BowType bowType;
final int distance;
final TargetFaceType targetFace;
final int endCount;
final int arrowsPerEnd;
@override
Widget build(BuildContext context) {
final total = endCount * arrowsPerEnd;
return Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: AppTheme.primary.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Expanded(
child: Text(
'${_bowLabel(bowType)} · ${distance}m · ${_targetLabel(targetFace)}',
style: const TextStyle(fontSize: 13),
),
),
Text(
'$endCount组×$arrowsPerEnd支 = $total支',
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w700,
color: AppTheme.primaryDark,
),
),
],
),
);
}
}
String _bowLabel(BowType t) {
switch (t) {
case BowType.recurve:
return '反曲弓';
case BowType.compound:
return '复合弓';
case BowType.barebow:
return '光弓';
case BowType.traditional:
return '传统弓';
case BowType.longbow:
return '长弓';
case BowType.other:
return '其他';
}
}
String _targetLabel(TargetFaceType t) {
switch (t) {
case TargetFaceType.cm40:
return '40cm';
case TargetFaceType.cm60:
return '60cm';
case TargetFaceType.cm80:
return '80cm';
case TargetFaceType.cm122:
return '122cm';
case TargetFaceType.vegas3Spot:
return '维加斯三联';
case TargetFaceType.vertical3Spot:
return '垂直三联';
case TargetFaceType.custom:
return '自定义';
}
}
@@ -0,0 +1,398 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:arcx/app/app_theme.dart';
import 'package:arcx/features/config/application/app_config_controller.dart';
import 'package:arcx/data/local/models/models.dart';
import 'package:arcx/features/scoring/application/score_math.dart';
import 'package:arcx/features/scoring/application/scoring_service.dart';
import 'package:arcx/features/scoring/presentation/widgets/target_face_painter.dart';
class PointBookEditPage extends ConsumerStatefulWidget {
const PointBookEditPage({
super.key,
required this.recordId,
this.readOnly = false,
});
final int recordId;
final bool readOnly;
@override
ConsumerState<PointBookEditPage> createState() => _PointBookEditPageState();
}
class _PointBookEditPageState extends ConsumerState<PointBookEditPage> {
late List<int> _scores;
late List<HitPoint> _hitPoints;
bool _initialized = false;
bool _saving = false;
PointRecord? _record;
@override
Widget build(BuildContext context) {
final recordAsync = ref.watch(recordByIdProvider(widget.recordId));
return Scaffold(
appBar: AppBar(
title: Text(widget.readOnly ? '记分详情' : '记分中'),
actions: [
if (!widget.readOnly)
TextButton.icon(
onPressed: _saving ? null : _save,
icon: const Icon(Icons.check, color: Colors.white),
label: const Text('保存', style: TextStyle(color: Colors.white)),
),
],
),
body: recordAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('加载失败:$e')),
data: (record) {
if (record == null) {
return const Center(child: Text('记录不存在'));
}
if (!_initialized) {
_record = record;
_scores = List<int>.from(record.scores);
_hitPoints = List<HitPoint>.from(record.hitPoints);
_initialized = true;
}
return _buildBody(context, record);
},
),
);
}
Widget _buildBody(BuildContext context, PointRecord record) {
final totalArrows = record.endCount * record.arrowsPerEnd;
final placed = _scores.length;
final isComplete = placed >= totalArrows;
return Column(
children: [
_ProgressHeader(
record: record,
placed: placed,
total: totalArrows,
currentEnd: _currentEnd(record),
currentArrow: _currentArrow(record),
),
const SizedBox(height: 12),
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: LayoutBuilder(
builder: (context, constraints) {
final size = constraints.maxWidth;
return GestureDetector(
onTapUp: widget.readOnly || isComplete || _saving
? null
: (details) => _onTap(details, size, record),
child: Stack(
alignment: Alignment.center,
children: [
SizedBox(
width: size,
height: size,
child: CustomPaint(
painter: TargetFacePainter(
hitPoints: _hitPoints,
highlightIndex: _hitPoints.isEmpty
? null
: _hitPoints.length - 1,
),
),
),
if (widget.readOnly && _hitPoints.isEmpty)
Text(
'无落点数据',
style: TextStyle(color: AppTheme.textSecondary),
),
if (!widget.readOnly && isComplete)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
decoration: BoxDecoration(
color: Colors.black54,
borderRadius: BorderRadius.circular(8),
),
child: const Text(
'已射完,点击右上角保存',
style: TextStyle(color: Colors.white),
),
),
],
),
);
},
),
),
),
const SizedBox(height: 12),
_EndSummary(record: record, scores: _scores),
if (!widget.readOnly)
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
child: Row(
children: [
OutlinedButton.icon(
onPressed: _saving || _scores.isEmpty ? null : _undo,
icon: const Icon(Icons.undo),
label: const Text('撤销'),
),
const Spacer(),
FilledButton(
onPressed: _saving ? null : _save,
child: const Text('保存记分'),
),
],
),
),
],
);
}
int _currentEnd(PointRecord record) {
if (_scores.isEmpty) return 0;
return (_scores.length - 1) ~/ record.arrowsPerEnd;
}
int _currentArrow(PointRecord record) {
if (_scores.isEmpty) return 0;
return (_scores.length - 1) % record.arrowsPerEnd;
}
void _onTap(TapUpDetails details, double size, PointRecord record) {
final localX = details.localPosition.dx;
final localY = details.localPosition.dy;
final totalArrows = record.endCount * record.arrowsPerEnd;
if (_scores.length >= totalArrows) return;
final arrowIndex = _scores.length;
final endIndex = arrowIndex ~/ record.arrowsPerEnd;
final hp = ScoreMath.hitPointFromPixels(
localX,
localY,
size,
arrowIndex: arrowIndex,
endIndex: endIndex,
);
setState(() {
_scores.add(hp.score ?? 0);
_hitPoints.add(hp);
});
_persist(record);
}
Future<void> _undo() async {
if (_scores.isEmpty) return;
setState(() {
_scores.removeLast();
_hitPoints.removeLast();
});
if (_record != null) {
await ref
.read(pointRecordsProvider.notifier)
.upsert(_record!.copyWithScores(_scores, _hitPoints));
}
}
Future<void> _persist(PointRecord record) async {
await ref
.read(pointRecordsProvider.notifier)
.upsert(record.copyWithScores(_scores, _hitPoints));
}
Future<void> _save() async {
if (_saving) return;
final record = _record;
if (record == null) return;
setState(() => _saving = true);
final completed = record.copyWithScores(_scores, _hitPoints);
await ref.read(pointRecordsProvider.notifier).complete(completed);
// Consume a trial credit on first successful completion of a draft.
if (record.isDraft) {
await ref.read(appConfigProvider.notifier).incrementTrialCount();
}
if (mounted) {
setState(() => _saving = false);
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('记分已保存')));
Navigator.of(context).popUntil((route) => route.isFirst);
}
}
}
class _ProgressHeader extends StatelessWidget {
const _ProgressHeader({
required this.record,
required this.placed,
required this.total,
required this.currentEnd,
required this.currentArrow,
});
final PointRecord record;
final int placed;
final int total;
final int currentEnd;
final int currentArrow;
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
color: AppTheme.primary.withValues(alpha: 0.06),
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
record.title,
style: const TextStyle(fontWeight: FontWeight.w600),
),
),
Text(
'$placed / $total',
style: const TextStyle(
fontWeight: FontWeight.w700,
color: AppTheme.primaryDark,
),
),
],
),
const SizedBox(height: 6),
if (placed < total)
Text(
'${currentEnd + 1}/${record.endCount} 组 · 本组第 ${currentArrow + 1}/${record.arrowsPerEnd}',
style: TextStyle(fontSize: 12, color: AppTheme.textSecondary),
),
const SizedBox(height: 8),
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
value: total == 0 ? 0 : placed / total,
minHeight: 6,
backgroundColor: AppTheme.primary.withValues(alpha: 0.15),
),
),
],
),
);
}
}
class _EndSummary extends StatelessWidget {
const _EndSummary({required this.record, required this.scores});
final PointRecord record;
final List<int> scores;
@override
Widget build(BuildContext context) {
final fullEnds = scores.length ~/ record.arrowsPerEnd;
final ends = <Widget>[];
for (var e = 0; e < record.endCount; e++) {
final start = e * record.arrowsPerEnd;
final end = start + record.arrowsPerEnd;
final endScores = e < fullEnds
? scores.sublist(start, end)
: (e == fullEnds ? scores.sublist(start) : <int>[]);
final sum = endScores.fold<int>(0, (a, b) => a + b);
ends.add(
_EndChip(
index: e + 1,
scores: endScores,
sum: sum,
isCurrent: e == fullEnds && endScores.length < record.arrowsPerEnd,
),
);
}
return SizedBox(
height: 72,
child: ListView.separated(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 16),
itemCount: ends.length,
separatorBuilder: (_, __) => const SizedBox(width: 8),
itemBuilder: (_, i) => ends[i],
),
);
}
}
class _EndChip extends StatelessWidget {
const _EndChip({
required this.index,
required this.scores,
required this.sum,
required this.isCurrent,
});
final int index;
final List<int> scores;
final int sum;
final bool isCurrent;
@override
Widget build(BuildContext context) {
return Container(
width: 110,
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
decoration: BoxDecoration(
color: isCurrent
? AppTheme.accent.withValues(alpha: 0.12)
: Colors.white,
border: Border.all(color: Colors.black12),
borderRadius: BorderRadius.circular(8),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
'$index组',
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
),
),
const Spacer(),
Text(
'$sum',
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
),
),
],
),
const SizedBox(height: 2),
Expanded(
child: Text(
scores.isEmpty ? '' : scores.map(_scoreLabel).join(' '),
style: const TextStyle(fontSize: 12),
),
),
],
),
);
}
}
String _scoreLabel(int s) {
if (s == 0) return 'M';
return '$s';
}
@@ -0,0 +1,95 @@
import 'package:flutter/material.dart';
import '../../../../data/local/models/models.dart';
/// Paints a standard 10-ring archery target face and optionally overlays
/// recorded [HitPoint]s. Coordinates use the normalized convention where
/// (0, 0) is the center and radius 1.0 is the outer scoring edge.
class TargetFacePainter extends CustomPainter {
TargetFacePainter({
this.hitPoints = const <HitPoint>[],
this.highlightIndex,
this.showAllHits = true,
this.hitColor = const Color(0xFF1A1A1A),
this.ringStrokeWidth = 1.0,
});
final List<HitPoint> hitPoints;
final int? highlightIndex;
final bool showAllHits;
final Color hitColor;
final double ringStrokeWidth;
// Ring colors from center outward: 10,9 gold | 8,7 red | 6,5 blue | 4,3 black | 2,1 white
static const _ringColors = <Color>[
Color(0xFFFFD23F), // 10
Color(0xFFFFD23F), // 9
Color(0xFFE5484D), // 8
Color(0xFFE5484D), // 7
Color(0xFF3B82C4), // 6
Color(0xFF3B82C4), // 5
Color(0xFF2B2B2B), // 4
Color(0xFF2B2B2B), // 3
Color(0xFFF5F5F5), // 2
Color(0xFFF5F5F5), // 1
];
@override
void paint(Canvas canvas, Size size) {
final center = Offset(size.width / 2, size.height / 2);
final radius = size.shortestSide / 2;
// Draw rings from outermost (1) inward to (10).
for (var i = _ringColors.length - 1; i >= 0; i--) {
final ringRadius = radius * (i + 1) / _ringColors.length;
final paint = Paint()..color = _ringColors[i];
canvas.drawCircle(center, ringRadius, paint);
}
// X ring (center dot).
canvas.drawCircle(
center,
radius * 0.05,
Paint()..color = const Color(0xFF8A6D00),
);
// Ring separator lines.
final stroke = Paint()
..color = const Color(0x55000000)
..style = PaintingStyle.stroke
..strokeWidth = ringStrokeWidth;
for (var i = 1; i <= _ringColors.length; i++) {
canvas.drawCircle(center, radius * i / _ringColors.length, stroke);
}
if (!showAllHits) return;
for (var i = 0; i < hitPoints.length; i++) {
final hp = hitPoints[i];
final point = Offset(
center.dx + hp.normalizedX * radius,
center.dy + hp.normalizedY * radius,
);
final isHighlight = i == highlightIndex;
final dotRadius = isHighlight ? radius * 0.045 : radius * 0.03;
canvas.drawCircle(
point,
dotRadius + 2,
Paint()..color = Colors.white,
);
canvas.drawCircle(
point,
dotRadius,
Paint()..color = isHighlight ? const Color(0xFF16A34A) : hitColor,
);
}
}
@override
bool shouldRepaint(covariant TargetFacePainter oldDelegate) {
return oldDelegate.hitPoints.length != hitPoints.length ||
oldDelegate.highlightIndex != highlightIndex ||
oldDelegate.hitColor != hitColor;
}
}
+135
View File
@@ -0,0 +1,135 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:isar/isar.dart';
import '../../../core/providers/app_providers.dart';
import '../../../data/local/models/models.dart';
import '../../scoring/application/scoring_service.dart';
/// Pure-data summary of local archery statistics, derived from completed
/// [PointRecord]s. All display strings are localized in zh-CN.
class StatsSummary {
const StatsSummary({
this.todayArrowCount = 0,
this.trainingDays = 0,
this.totalArrowCount = 0,
this.averageScore,
this.scoreDistribution = const <ScoreBucket>[],
this.hitPoints = const <HitPoint>[],
});
final int todayArrowCount;
final int trainingDays;
final int totalArrowCount;
final double? averageScore;
final List<ScoreBucket> scoreDistribution;
final List<HitPoint> hitPoints;
bool get hasData => totalArrowCount > 0;
double get todayCalories => todayArrowCount * 1.6;
/// Capped at 10 per PRD: todayArrows * 5 / 60.
double get todayIntensityRaw {
final v = todayArrowCount * 5 / 60.0;
return v > 10 ? 10 : v;
}
String get todayIntensityLabel {
final v = todayIntensityRaw;
if (v > 6) return '重度';
if (v >= 4) return '中度';
return '轻度';
}
String get averageScoreLabel =>
averageScore == null ? '-' : averageScore!.toStringAsFixed(1);
}
/// One bucket in the ring-value distribution. [label] follows the
/// "M / X / 1~10" convention from the PRD.
class ScoreBucket {
const ScoreBucket({required this.label, required this.count});
final String label;
final int count;
}
final statsProvider = FutureProvider<StatsSummary>((ref) async {
// Re-compute whenever the records list changes.
ref.watch(pointRecordsProvider);
final isar = await ref.watch(isarProvider.future);
final records = await isar.pointRecords
.filter()
.statusEqualTo(PointRecordStatus.completed)
.sortByCreatedAtDesc()
.findAll();
return _compute(records);
});
StatsSummary _compute(List<PointRecord> records) {
if (records.isEmpty) return const StatsSummary();
final today = DateTime.now();
final todayDate = DateTime(today.year, today.month, today.day);
int todayArrows = 0;
int totalArrows = 0;
double scoreSum = 0;
int scoredArrows = 0;
final days = <DateTime>{};
final allHits = <HitPoint>[];
final counts = <String, int>{};
for (final r in records) {
final day = DateTime(r.createdAt.year, r.createdAt.month, r.createdAt.day);
days.add(day);
totalArrows += r.scores.length;
if (day == todayDate) {
todayArrows += r.scores.length;
}
for (final s in r.scores) {
scoreSum += s;
scoredArrows += 1;
final key = _bucketLabel(s);
counts[key] = (counts[key] ?? 0) + 1;
}
allHits.addAll(r.hitPoints);
}
final order = const [
'X',
'10',
'9',
'8',
'7',
'6',
'5',
'4',
'3',
'2',
'1',
'M',
];
final distribution = <ScoreBucket>[];
for (final label in order) {
final c = counts[label] ?? 0;
if (c > 0) distribution.add(ScoreBucket(label: label, count: c));
}
return StatsSummary(
todayArrowCount: todayArrows,
trainingDays: days.length,
totalArrowCount: totalArrows,
averageScore: scoredArrows == 0 ? null : scoreSum / scoredArrows,
scoreDistribution: distribution,
hitPoints: allHits,
);
}
String _bucketLabel(int score) {
if (score == 0) return 'M';
if (score == 10) return '10';
return '$score';
}
+5
View File
@@ -0,0 +1,5 @@
import 'bootstrap.dart';
Future<void> main() async {
await bootstrap();
}

Some files were not shown because too many files have changed in this diff Show More