人脸识别阈值输入配置实施计划

For agentic workers: REQUIRED SUB-SKILL: Use executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: 将基础设置中的人脸识别阈值滑块改为点击后输入,并支持 50~100 的整数范围、当前值回显、输入框下方提示和即时生效。

Architecture: 保留现有 FaceRecognitionThresholdConfig 作为阈值持久化和 SDK 分值转换边界,将范围调整为 50~100且默认值独立保持 70。新增职责单一的 InputFaceRecognitionThresholdPop 处理当前值回显、输入校验和结果回调;FragmentBasic 只负责打开弹窗、保存有效结果及刷新设置行,FaceSDKManager 继续在每次检索时读取最新配置。

Tech Stack: Android Java 8、AndroidX DialogFragment、SharedPreferences、XML Layout、JUnit 4、Gradle Android Plugin 4.2.1。

Git Boundary: 当前工作区包含用户的其他未提交修改。实施时只修改本计划列出的阈值相关文件,不暂存、不提交、不推送,也不覆盖 AlarmReceiverOfflineOrderUploadPolicyUploadOrderWorker 及其测试。


文件结构

Task 1:用测试锁定新阈值边界

Files:

@Test
public void clampThresholdLimitsValuesToSupportedRange() {
    assertEquals(50, FaceRecognitionThresholdConfig.clampThreshold(49));
    assertEquals(50, FaceRecognitionThresholdConfig.clampThreshold(50));
    assertEquals(70, FaceRecognitionThresholdConfig.clampThreshold(70));
    assertEquals(100, FaceRecognitionThresholdConfig.clampThreshold(100));
    assertEquals(100, FaceRecognitionThresholdConfig.clampThreshold(101));
}

@Test
public void supportedThresholdIncludesBothBoundaries() {
    assertFalse(FaceRecognitionThresholdConfig.isThresholdSupported(49));
    assertTrue(FaceRecognitionThresholdConfig.isThresholdSupported(50));
    assertTrue(FaceRecognitionThresholdConfig.isThresholdSupported(70));
    assertTrue(FaceRecognitionThresholdConfig.isThresholdSupported(100));
    assertFalse(FaceRecognitionThresholdConfig.isThresholdSupported(101));
}

@Test
public void toSdkThresholdConvertsIntegerScoreToDecimal() {
    assertEquals(0.50f, FaceRecognitionThresholdConfig.toSdkThreshold(50), 0.0001f);
    assertEquals(0.70f, FaceRecognitionThresholdConfig.toSdkThreshold(70), 0.0001f);
    assertEquals(1.00f, FaceRecognitionThresholdConfig.toSdkThreshold(100), 0.0001f);
}

同时增加:

import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
JAVA_HOME=/Users/liang/Library/Java/JavaVirtualMachines/corretto-1.8.0_482/Contents/Home bash gradlew :app:testDebugUnitTest --tests com.cpt.cusumption.utils.FaceRecognitionThresholdConfigTest

Expected: 测试编译或断言失败;当前实现仍限制为 70~99,且尚无 isThresholdSupported(int)

/**
 * 管理消费人脸识别匹配阈值的持久化、边界限制和 SDK 分值转换。
 *
 * <p>设置页使用 50~100 的整数分值,识别 SDK 使用 0.50~1.00 的浮点分值。
 * 默认值保持 70,以延续未配置设备的原识别行为。</p>
 */
public final class FaceRecognitionThresholdConfig {

    public static final int MIN_THRESHOLD = 50;
    public static final int MAX_THRESHOLD = 100;
    public static final int DEFAULT_THRESHOLD = 70;
    private static final float SDK_SCORE_SCALE = 100f;

    /**
     * 判断用户输入是否位于可保存范围内。
     */
    public static boolean isThresholdSupported(int thresholdLevel) {
        return thresholdLevel >= MIN_THRESHOLD && thresholdLevel <= MAX_THRESHOLD;
    }
}

现有读取、保存、clampThreshold(int)toSdkThreshold(int) 继续保留。clampThreshold(int) 用于兼容历史存储值;弹窗确认必须调用 isThresholdSupported(int),不能把越界输入静默截断后保存。

Run: 与 Step 2 相同。

Expected: FaceRecognitionThresholdConfigTest 共 4 个测试,失败 0、错误 0,BUILD SUCCESSFUL

Task 2:新增独立阈值输入弹窗

Files:

strings.xml

<string name="basic_set_face_recognition_threshold_value">%1$d</string>
<string name="face_recognition_threshold_input_title">人脸识别阈值</string>
<string name="face_recognition_threshold_input_hint">请输入阈值</string>
<string name="face_recognition_threshold_input_tip">输入范围是 50~100,推荐 70 以上,80 最优。</string>
<string name="face_recognition_threshold_input_error">请输入 50~100 之间的整数</string>

dimens.xml

<dimen name="face_threshold_pop_title_margin_top">32dp</dimen>
<dimen name="face_threshold_pop_content_margin">40dp</dimen>
<dimen name="face_threshold_pop_input_height">70dp</dimen>
<dimen name="face_threshold_pop_action_height">80dp</dimen>
<dimen name="face_threshold_pop_title_text_size">20sp</dimen>
<dimen name="face_threshold_pop_input_text_size">20sp</dimen>

创建 input_face_recognition_threshold_pop_layout.xml。输入提示必须位于输入框下方:

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:gravity="center">

    <LinearLayout
        android:layout_width="@dimen/common_pop_width"
        android:layout_height="wrap_content"
        android:layout_centerInParent="true"
        android:background="@color/white"
        android:orientation="vertical">

        <TextView
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_marginTop="@dimen/face_threshold_pop_title_margin_top"
            android:gravity="center"
            android:text="@string/face_recognition_threshold_input_title"
            android:textColor="@color/text_color_show"
            android:textSize="@dimen/face_threshold_pop_title_text_size"
            android:textStyle="bold" />

        <EditText
            android:id="@+id/input_face_recognition_threshold_view"
            android:layout_width="match_parent"
            android:layout_height="@dimen/face_threshold_pop_input_height"
            android:layout_marginStart="@dimen/face_threshold_pop_content_margin"
            android:layout_marginTop="@dimen/face_threshold_pop_content_margin"
            android:layout_marginEnd="@dimen/face_threshold_pop_content_margin"
            android:background="@drawable/input_price_input_view_bac"
            android:gravity="center"
            android:hint="@string/face_recognition_threshold_input_hint"
            android:imeOptions="flagNoExtractUi"
            android:inputType="number"
            android:maxLength="3"
            android:selectAllOnFocus="true"
            android:textColor="@color/text_color_input"
            android:textColorHint="@color/text_color_hint"
            android:textSize="@dimen/face_threshold_pop_input_text_size" />

        <TextView
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_marginStart="@dimen/face_threshold_pop_content_margin"
            android:layout_marginTop="@dimen/common_interval"
            android:layout_marginEnd="@dimen/face_threshold_pop_content_margin"
            android:layout_marginBottom="@dimen/face_threshold_pop_content_margin"
            android:text="@string/face_recognition_threshold_input_tip"
            android:textColor="@color/text_color_hint"
            android:textSize="@dimen/setting_item_text_size" />

        <LinearLayout
            android:layout_width="match_parent"
            android:layout_height="@dimen/face_threshold_pop_action_height"
            android:orientation="horizontal">

            <TextView
                android:id="@+id/input_face_recognition_threshold_cancel_view"
                android:layout_width="0dp"
                android:layout_height="match_parent"
                android:layout_weight="1"
                android:background="@drawable/input_price_pop_cancel_bac"
                android:gravity="center"
                android:text="@string/home_input_price_cancel"
                android:textColor="@color/text_color_input"
                android:textSize="@dimen/face_threshold_pop_input_text_size" />

            <TextView
                android:id="@+id/input_face_recognition_threshold_confirm_view"
                android:layout_width="0dp"
                android:layout_height="match_parent"
                android:layout_weight="1"
                android:background="@drawable/input_price_pop_confirm_bac"
                android:gravity="center"
                android:text="@string/home_input_price_confirm"
                android:textColor="@color/white"
                android:textSize="@dimen/face_threshold_pop_input_text_size" />
        </LinearLayout>
    </LinearLayout>
</RelativeLayout>

弹窗使用无参构造和 arguments 保存当前阈值,完整实现如下:

package com.cpt.cusumption.view;

import android.app.Dialog;
import android.graphics.Color;
import android.graphics.drawable.ColorDrawable;
import android.os.Bundle;
import android.view.Gravity;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.view.Window;
import android.widget.EditText;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.DialogFragment;

import com.cpt.cusumption.R;
import com.cpt.cusumption.utils.FaceRecognitionThresholdConfig;
import com.cpt.cusumption.utils.ResourcesUtils;
import com.cpt.cusumption.utils.ToastUtils;

/**
 * 消费人脸识别阈值输入弹窗。
 *
 * <p>只负责展示当前值、校验输入并返回有效阈值,不直接持久化配置。</p>
 */
public class InputFaceRecognitionThresholdPop extends DialogFragment
        implements View.OnClickListener {

    public static final String TAG =
            InputFaceRecognitionThresholdPop.class.getSimpleName();
    private static final String ARG_CURRENT_THRESHOLD = "arg_current_threshold";

    private EditText mInputView;
    private OnThresholdInputListener mListener;

    public static InputFaceRecognitionThresholdPop newInstance(int currentThreshold) {
        InputFaceRecognitionThresholdPop dialog =
                new InputFaceRecognitionThresholdPop();
        Bundle arguments = new Bundle();
        arguments.putInt(ARG_CURRENT_THRESHOLD, currentThreshold);
        dialog.setArguments(arguments);
        return dialog;
    }

    @Nullable
    @Override
    public View onCreateView(
            @NonNull LayoutInflater inflater,
            @Nullable ViewGroup container,
            @Nullable Bundle savedInstanceState
    ) {
        Dialog dialog = getDialog();
        if (dialog != null && dialog.getWindow() != null) {
            dialog.getWindow().requestFeature(Window.FEATURE_NO_TITLE);
        }
        return inflater.inflate(
                R.layout.input_face_recognition_threshold_pop_layout,
                container,
                false
        );
    }

    @Override
    public void onViewCreated(
            @NonNull View view,
            @Nullable Bundle savedInstanceState
    ) {
        super.onViewCreated(view, savedInstanceState);
        mInputView = view.findViewById(
                R.id.input_face_recognition_threshold_view
        );
        view.findViewById(
                R.id.input_face_recognition_threshold_cancel_view
        ).setOnClickListener(this);
        view.findViewById(
                R.id.input_face_recognition_threshold_confirm_view
        ).setOnClickListener(this);

        int currentThreshold = FaceRecognitionThresholdConfig.DEFAULT_THRESHOLD;
        Bundle arguments = getArguments();
        if (arguments != null) {
            int argumentValue = arguments.getInt(
                    ARG_CURRENT_THRESHOLD,
                    FaceRecognitionThresholdConfig.DEFAULT_THRESHOLD
            );
            if (FaceRecognitionThresholdConfig.isThresholdSupported(argumentValue)) {
                currentThreshold = argumentValue;
            }
        }
        mInputView.setText(String.valueOf(currentThreshold));
        mInputView.requestFocus();
        mInputView.selectAll();
    }

    @NonNull
    @Override
    public Dialog onCreateDialog(@Nullable Bundle savedInstanceState) {
        Dialog dialog = super.onCreateDialog(savedInstanceState);
        dialog.setCanceledOnTouchOutside(false);
        if (dialog.getWindow() != null) {
            dialog.getWindow().setBackgroundDrawable(
                    new ColorDrawable(Color.WHITE)
            );
        }
        return dialog;
    }

    @Override
    public void onStart() {
        super.onStart();
        Dialog dialog = getDialog();
        if (dialog == null || dialog.getWindow() == null) {
            return;
        }
        int width = (int) ResourcesUtils.getDimension(R.dimen.common_pop_width);
        dialog.getWindow().setLayout(
                width,
                ViewGroup.LayoutParams.WRAP_CONTENT
        );
        dialog.getWindow().setGravity(Gravity.CENTER);
    }

    @Override
    public void onClick(View view) {
        if (view.getId()
                == R.id.input_face_recognition_threshold_cancel_view) {
            if (mListener != null) {
                mListener.onCancel();
            }
            dismiss();
            return;
        }
        if (view.getId()
                == R.id.input_face_recognition_threshold_confirm_view) {
            confirmInput();
        }
    }

    private void confirmInput() {
        String input = mInputView.getText().toString().trim();
        int threshold;
        try {
            threshold = Integer.parseInt(input);
        } catch (NumberFormatException error) {
            showInputError();
            return;
        }

        if (!FaceRecognitionThresholdConfig.isThresholdSupported(threshold)) {
            showInputError();
            return;
        }

        if (mListener != null) {
            mListener.onConfirm(threshold);
        }
        dismiss();
    }

    private void showInputError() {
        ToastUtils.toast(
                requireContext(),
                getString(R.string.face_recognition_threshold_input_error)
        );
    }

    public void setOnThresholdInputListener(OnThresholdInputListener listener) {
        mListener = listener;
    }

    public interface OnThresholdInputListener {
        void onConfirm(int threshold);

        void onCancel();
    }
}

空字符串和非整数均进入 NumberFormatException 分支;越界值由 isThresholdSupported(int) 拒绝。三种情况都保持弹窗打开且不保存。

JAVA_HOME=/Users/liang/Library/Java/JavaVirtualMachines/corretto-1.8.0_482/Contents/Home bash gradlew :app:compileDebugJavaWithJavac

Expected: BUILD SUCCESSFUL;没有资源 ID、Java 8 或包可见性错误。

Task 3:把设置页滑块迁移为点击输入

Files:

<LinearLayout
    android:id="@+id/set_basic_face_recognition_threshold_layout"
    android:layout_width="match_parent"
    android:layout_height="@dimen/setting_item_height_size"
    android:clickable="true"
    android:focusable="true"
    android:gravity="center_vertical"
    android:orientation="horizontal">

    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="@string/basic_set_face_recognition_threshold"
        android:textColor="@color/text_color_show"
        android:textSize="@dimen/setting_item_text_size"
        android:textStyle="bold" />

    <TextView
        android:id="@+id/set_basic_face_recognition_threshold_value"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_weight="1"
        android:gravity="end"
        android:textColor="@color/text_color_input"
        android:textSize="@dimen/setting_item_text_size" />

    <ImageView
        android:layout_width="@dimen/setting_item_arrow_width"
        android:layout_height="@dimen/setting_item_arrow_height"
        android:layout_marginStart="@dimen/common_interval"
        android:src="@drawable/icon_arrow_right" />
</LinearLayout>

FragmentBasic 删除 SeekBar 导入,增加 InputFaceRecognitionThresholdPop 导入和字段:

private TextView mFaceRecognitionThresholdValueView;

用以下逻辑替换 bindFaceRecognitionThreshold(layout)

mFaceRecognitionThresholdValueView = layout.findViewById(
        R.id.set_basic_face_recognition_threshold_value
);
refreshFaceRecognitionThresholdValue();
layout.findViewById(
        R.id.set_basic_face_recognition_threshold_layout
).setOnClickListener(this);

删除原 bindFaceRecognitionThreshold(...) 方法,并改为:

private void refreshFaceRecognitionThresholdValue() {
    int threshold = FaceRecognitionThresholdConfig.getThresholdLevel();
    mFaceRecognitionThresholdValueView.setText(getString(
            R.string.basic_set_face_recognition_threshold_value,
            threshold
    ));
}

onClick 增加:

case R.id.set_basic_face_recognition_threshold_layout:
    showFaceRecognitionThresholdInputPop();
    break;

增加:

/**
 * 打开阈值输入弹窗;有效值由设置页统一保存并刷新显示。
 */
private void showFaceRecognitionThresholdInputPop() {
    Fragment existing = requireActivity()
            .getSupportFragmentManager()
            .findFragmentByTag(InputFaceRecognitionThresholdPop.TAG);
    if (existing != null) {
        Log.e(TAG, "人脸识别阈值输入弹窗已经显示");
        return;
    }

    InputFaceRecognitionThresholdPop dialog =
            InputFaceRecognitionThresholdPop.newInstance(
                    FaceRecognitionThresholdConfig.getThresholdLevel()
            );
    dialog.setOnThresholdInputListener(
            new InputFaceRecognitionThresholdPop.OnThresholdInputListener() {
                @Override
                public void onConfirm(int threshold) {
                    FaceRecognitionThresholdConfig.saveThresholdLevel(threshold);
                    refreshFaceRecognitionThresholdValue();
                }

                @Override
                public void onCancel() {
                    // 取消时保持当前阈值和页面显示不变。
                }
            }
    );
    dialog.showNow(
            requireActivity().getSupportFragmentManager(),
            InputFaceRecognitionThresholdPop.TAG
    );
}
JAVA_HOME=/Users/liang/Library/Java/JavaVirtualMachines/corretto-1.8.0_482/Contents/Home bash gradlew :app:compileDebugJavaWithJavac

Expected: BUILD SUCCESSFULFragmentBasic 不再导入或引用 SeekBar,布局中不再存在旧滑块 ID。

Task 4:验证识别链路与完整行为

Files:

rg -n "SETTING_FACE_RECOGNITION_THRESHOLD|getSdkThreshold|thresholdScore|set_basic_face_recognition_threshold_seek_bar|SeekBar" app/src/main/java app/src/main/res app/src/test

Expected: Constants 只有一个配置键;FaceSDKManager 每次检索调用 getSdkThreshold();判断仍为 topFeature.getScore() > thresholdScore;新增阈值功能不再包含 SeekBar 或旧滑块 ID。

JAVA_HOME=/Users/liang/Library/Java/JavaVirtualMachines/corretto-1.8.0_482/Contents/Home bash gradlew :app:testDebugUnitTest --tests com.cpt.cusumption.utils.FaceRecognitionThresholdConfigTest
JAVA_HOME=/Users/liang/Library/Java/JavaVirtualMachines/corretto-1.8.0_482/Contents/Home bash gradlew :app:testDebugUnitTest

Expected: 阈值测试 4 个全部通过;全部 Debug 单元测试失败 0、错误 0。

JAVA_HOME=/Users/liang/Library/Java/JavaVirtualMachines/corretto-1.8.0_482/Contents/Home bash gradlew :app:assembleDebug

Expected: BUILD SUCCESSFUL,生成 app/build/outputs/apk/debug/app-debug.apk

git diff --check
git status --short --branch
file -I app/src/main/java/com/cpt/cusumption/view/InputFaceRecognitionThresholdPop.java \
  app/src/main/res/layout/input_face_recognition_threshold_pop_layout.xml
xxd -l 3 app/src/main/java/com/cpt/cusumption/view/InputFaceRecognitionThresholdPop.java
xxd -l 3 app/src/main/res/layout/input_face_recognition_threshold_pop_layout.xml

Expected: git diff --check 无输出;分支仍为 fix-menu-dish-list-refresh-crash;新文件为 UTF-8 无 BOM;用户已有的离线订单相关修改仍存在且未被改写。

Task 5:更新交付记录

Files:

记录必须包含:

rg -n "50~100|回显当前值|输入框下方|默认值 70|BUILD SUCCESSFUL|SHA-256" \
  /Users/liang/AndroidStudioProjects/zhct/zhctprompt/work_android/CusumptionMachine/change_records/2026-08-31-face-recognition-threshold-input-config.md \
  /Users/liang/AndroidStudioProjects/zhct/zhctprompt/work_android/CusumptionMachine/change_records/2026-08-31-face-recognition-threshold-input-config.html
file -I \
  /Users/liang/AndroidStudioProjects/zhct/zhctprompt/work_android/CusumptionMachine/change_records/2026-08-31-face-recognition-threshold-input-config.md \
  /Users/liang/AndroidStudioProjects/zhct/zhctprompt/work_android/CusumptionMachine/change_records/2026-08-31-face-recognition-threshold-input-config.html

Expected: 两个文件关键事实一致,均为 UTF-8,无占位内容。

报告源码仓库和提示词仓库的工作区状态,列明本次修改文件、测试结果、APK 路径与哈希。除非用户明确要求,不运行 git addgit commitgit push 或其他会改写 Git 状态的命令。