commit 94f8c5e67b54a906de0d8edcb32abab378728f50 Author: dzc <2388224167@qq.com> Date: Thu Jul 23 19:57:15 2026 +0800 feat: 初始化新 UI 收银终端项目 diff --git a/.fvmrc b/.fvmrc new file mode 100644 index 0000000..c2783c6 --- /dev/null +++ b/.fvmrc @@ -0,0 +1,3 @@ +{ + "flutter": "3.27.4" +} \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b6323af --- /dev/null +++ b/.gitignore @@ -0,0 +1,48 @@ +# Miscellaneous +*.class +*.log +*.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 +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ + +# 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 + +# FVM Version Cache +.fvm/ \ No newline at end of file diff --git a/.metadata b/.metadata new file mode 100644 index 0000000..9c3a509 --- /dev/null +++ b/.metadata @@ -0,0 +1,30 @@ +# 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: "d8a9f9a52e5af486f80d932e838ee93861ffd863" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: d8a9f9a52e5af486f80d932e838ee93861ffd863 + base_revision: d8a9f9a52e5af486f80d932e838ee93861ffd863 + - platform: android + create_revision: d8a9f9a52e5af486f80d932e838ee93861ffd863 + base_revision: d8a9f9a52e5af486f80d932e838ee93861ffd863 + + # 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' diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..5aa1f6d --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,26 @@ +{ + // 使用 IntelliSense 了解相关属性。 + // 悬停以查看现有属性的描述。 + // 欲了解更多信息,请访问: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + + { + "name": "pj_supermarket_cloud", + "request": "launch", + "type": "dart" + }, + { + "name": "pj_supermarket_cloud (profile mode)", + "request": "launch", + "type": "dart", + "flutterMode": "profile" + }, + { + "name": "pj_supermarket_cloud (release mode)", + "request": "launch", + "type": "dart", + "flutterMode": "release" + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..c40807b --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "dart.flutterSdkPath": ".fvm/versions/3.27.4", + "commentTranslate.hover.enabled": true, + "commentTranslate.targetLanguage": "zh-CN" +} \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..9573240 --- /dev/null +++ b/README.md @@ -0,0 +1,15 @@ +## 生成代码命令 +``` +flutter pub run build_runner build --delete-conflicting-outputs~ +``` +## flutter打包 +``` +flutter build apk --build-name 1.3.0 --build-number 130 +--build-name为版本号(展示) +--build-number为版本号(内部比较) +``` + +## 生成log命令 +``` +dart run icons_launcher:create +``` \ No newline at end of file diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..2e349a8 --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1,31 @@ +# 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. +analyzer: + errors: + constant_identifier_names: ignore +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 diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 0000000..55afd91 --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,13 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java + +# Remember to never publicly share your keystore. +# See https://flutter.dev/to/reference-keystore +key.properties +**/*.keystore +**/*.jks diff --git a/android/app/build.gradle b/android/app/build.gradle new file mode 100644 index 0000000..13f204e --- /dev/null +++ b/android/app/build.gradle @@ -0,0 +1,79 @@ +plugins { + id "com.android.application" + id "kotlin-android" + id "dev.flutter.flutter-gradle-plugin" +} + +android { + namespace = "com.example.pj_supermarket_cloud" + compileSdk 35 + ndkVersion = flutter.ndkVersion + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_1_8 + } + + sourceSets { + main() { + jniLibs.srcDirs = ['src/main/jniLibs'] + } + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId = "com.example.pj_supermarket_cloud" + // You can update the following values to match your application needs. + // For more information, see: https://flutter.dev/to/review-gradle-config. + minSdk = 24 + targetSdk = flutter.targetSdkVersion + versionCode = flutter.versionCode + versionName = flutter.versionName + ndk { + abiFilters 'arm64-v8a' + } + } + + dataBinding { + enabled = true + } + + 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.debug + shrinkResources true + minifyEnabled true + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + } + } + buildToolsVersion '29.0.2' +} + +flutter { + source = "../.." +} + +dependencies { + implementation fileTree(dir: 'libs', include: ['*.jar']) + + // rxjava + implementation 'io.reactivex.rxjava2:rxjava:2.2.9' + implementation 'io.reactivex.rxjava2:rxandroid:2.1.0' + + implementation 'androidx.preference:preference:1.1.1' + + implementation 'com.google.code.gson:gson:2.10.1' + + implementation 'com.tencent.bugly:crashreport:4.1.9.3' + implementation 'com.google.code.gson:gson:2.8.6' + implementation 'androidx.appcompat:appcompat:1.6.1' + implementation 'com.google.android.material:material:1.10.0' + implementation 'androidx.activity:activity:1.8.0' + implementation 'androidx.constraintlayout:constraintlayout:2.1.4' +} diff --git a/android/app/libs/Msc.jar b/android/app/libs/Msc.jar new file mode 100644 index 0000000..84777a4 Binary files /dev/null and b/android/app/libs/Msc.jar differ diff --git a/android/app/libs/arcsoft_face.jar b/android/app/libs/arcsoft_face.jar new file mode 100644 index 0000000..faa72e6 Binary files /dev/null and b/android/app/libs/arcsoft_face.jar differ diff --git a/android/app/libs/arcsoft_image_util.jar b/android/app/libs/arcsoft_image_util.jar new file mode 100644 index 0000000..2260c13 Binary files /dev/null and b/android/app/libs/arcsoft_image_util.jar differ diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro new file mode 100644 index 0000000..c8455de --- /dev/null +++ b/android/app/proguard-rules.pro @@ -0,0 +1,32 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile + +# -keep class com.cash.cash_register_android.** { *; } +# -keep class io.flutter.plugins.** { *; } +-keep class com.arcsoft.face.** { *; } +-keep class com.iflytek.**{*;} +-keepattributes Signature +# 2026-01-31(添加后台下载报错添加) +-keepattributes Annotation +-keep class com.bbflight.background_downloader.** { *; } +-keep class com.google.gson.** { *; } +-keep class kotlin.** { *; } \ No newline at end of file diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..c7f930f --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,68 @@ + + + + + + ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/ic_launcher-playstore.png b/android/app/src/main/ic_launcher-playstore.png new file mode 100644 index 0000000..4de2d53 Binary files /dev/null and b/android/app/src/main/ic_launcher-playstore.png differ diff --git a/android/app/src/main/java/io/flutter/plugins/facediscern/database/DataBaseUtils.java b/android/app/src/main/java/io/flutter/plugins/facediscern/database/DataBaseUtils.java new file mode 100644 index 0000000..b30484c --- /dev/null +++ b/android/app/src/main/java/io/flutter/plugins/facediscern/database/DataBaseUtils.java @@ -0,0 +1,128 @@ +package io.flutter.plugins.facediscern.database; + +import android.content.Context; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; +import android.database.sqlite.SQLiteStatement; + +import com.google.gson.Gson; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +import io.flutter.Log; +import io.flutter.plugins.facediscern.tool.face.model.FaceEntity; + +public class DataBaseUtils { + + private static final String TAG = "android数据库"; + + static public List getUserFace(Context context, String queryUserId) { + List result = new ArrayList<>(); + int rowCount = 0; + DatabaseHelper databaseHelper = null; + SQLiteDatabase database = null; + Cursor cursor = null; + try { + databaseHelper = new DatabaseHelper(context); + database = databaseHelper.getWritableDatabase(); + String sql = "SELECT * FROM face"; + if (Objects.nonNull(queryUserId) && !Objects.equals(queryUserId, "")) { + sql = sql.concat(" WHERE userId = ?"); + cursor = database.rawQuery(sql, new String[]{queryUserId}); + } else { + cursor = database.rawQuery(sql, null); + } + Log.i(TAG, "getUserFace,sql:" + sql + ";queryUserId:" + queryUserId + ";"); + rowCount = cursor.getCount(); + while (cursor.moveToNext()) { + int userIdIndex = cursor.getColumnIndex("userId"); + int sampleIdIndex = cursor.getColumnIndex("sampleId"); + int userNameIndex = cursor.getColumnIndex("userName"); + int pathIndex = cursor.getColumnIndex("path"); + int faceModelIndex = cursor.getColumnIndex("faceModel"); + FaceEntity item = new FaceEntity(); + if (userIdIndex != -1) { + String userId = cursor.getString(userIdIndex); + item.setUserId(userId); + } + if (sampleIdIndex != -1) { + int sampleId = cursor.getInt(sampleIdIndex); + item.setSampleId(sampleId); + item.setFaceId(sampleId); + } + if (userNameIndex != -1) { + String userName = cursor.getString(userNameIndex); + item.setUserName(userName); + } + if (pathIndex != -1) { + String faceFilePath = cursor.getString(pathIndex); + item.setFaceFilePath(faceFilePath); + } + if (faceModelIndex != -1) { + String faceModel = cursor.getString(faceModelIndex); + if (faceModel != null && !Objects.equals(faceModel, "")) { + item.setFaceModel(toByteArray(faceModel)); + } + } + result.add(item); + } + Log.i(TAG, "查询人脸数据库成功人脸总数:" + rowCount + "list长度:" + result.size() + ";"); + return result; + } catch (Exception e) { + Log.e(TAG, "查询数据库人脸错误error:" + e.getMessage(), e); + return result; + } finally { + if (cursor != null) { + cursor.close(); + } + if (database != null) { + database.close(); + } + if (databaseHelper != null) { + databaseHelper.close(); + } + } + } + + static public Boolean updateFaceModel(Context context, FaceEntity faceEntity) { + DatabaseHelper databaseHelper = null; + SQLiteDatabase database = null; + SQLiteStatement statement = null; + try { + databaseHelper = new DatabaseHelper(context); + database = databaseHelper.getWritableDatabase(); + String sqlUpdate = "UPDATE face SET faceModel = ? WHERE sampleId = ?"; + statement = database.compileStatement(sqlUpdate); + statement.bindString(1, fromByteArray(faceEntity.getFaceModel())); + statement.bindLong(2, faceEntity.getSampleId()); + int rowsAffected = statement.executeUpdateDelete(); + Log.i(TAG, "getUserFaceupdateFaceModel,sql:" + sqlUpdate + ";sampleId:" + faceEntity.getSampleId() + ";faceModel长度:" + faceEntity.getFaceModel().length + ";res:" + rowsAffected + ";"); + return rowsAffected > 0; + } catch (Exception e) { + Log.e(TAG, "更新人脸模型失败username:" + faceEntity.getUserName() + ";id:" + faceEntity.getFaceId() + ";"); + Log.e(TAG, "更新人脸模型失败error:" + e.getMessage(), e); + return false; + } finally { + if (statement != null) { + statement.close(); + } + if (database != null) { + database.close(); + } + if (databaseHelper != null) { + databaseHelper.close(); + } + } + } + + public static byte[] toByteArray(String json) { + return new Gson().fromJson(json, byte[].class); + } + + public static String fromByteArray(byte[] byteArray) { + return new Gson().toJson(byteArray); + } + +} diff --git a/android/app/src/main/java/io/flutter/plugins/facediscern/database/DatabaseHelper.java b/android/app/src/main/java/io/flutter/plugins/facediscern/database/DatabaseHelper.java new file mode 100644 index 0000000..d27878d --- /dev/null +++ b/android/app/src/main/java/io/flutter/plugins/facediscern/database/DatabaseHelper.java @@ -0,0 +1,45 @@ +package io.flutter.plugins.facediscern.database; + +import android.content.Context; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; +import android.database.sqlite.SQLiteOpenHelper; +import android.os.Environment; +import android.util.Log; + +import java.io.File; + + +public class DatabaseHelper extends SQLiteOpenHelper { + private static final String TAG = "DatabaseHelper"; + + private static final String DATABASE_PATH = "pj_cashier/database"; + private static final String DATABASE_NAME = "cash_register.db"; + private static final int DATABASE_VERSION = 7; + + public DatabaseHelper(Context context) { + super(context, getCustomDatabasePath(context), null, DATABASE_VERSION); + } + + private static String getCustomDatabasePath(Context context) { + File documentsDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOCUMENTS); + File dbDir = new File(documentsDir, DATABASE_PATH); + if (!dbDir.exists()) dbDir.mkdirs(); + return new File(dbDir, DATABASE_NAME).getAbsolutePath(); + } + + @Override + public void onCreate(SQLiteDatabase db) { + Log.i(TAG, "onCreate"); + Cursor cursor = db.query("face", null, null, null, null, null, null); + while (cursor.moveToNext()) { + int id = cursor.getColumnIndex("id"); + Log.i(TAG, "查询到人脸数据库的数据id:" + id); + } + } + + @Override + public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { + onCreate(db); + } +} diff --git a/android/app/src/main/java/io/flutter/plugins/facediscern/flutter/FlutterFaceDiscernPlugin.java b/android/app/src/main/java/io/flutter/plugins/facediscern/flutter/FlutterFaceDiscernPlugin.java new file mode 100644 index 0000000..fe384db --- /dev/null +++ b/android/app/src/main/java/io/flutter/plugins/facediscern/flutter/FlutterFaceDiscernPlugin.java @@ -0,0 +1,153 @@ +package io.flutter.plugins.facediscern.flutter; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.os.Build; + +import androidx.annotation.NonNull; + +import com.google.gson.Gson; + +import java.util.List; +import java.util.Map; +import java.util.Objects; + +import io.flutter.Log; +import io.flutter.embedding.engine.plugins.FlutterPlugin; +import io.flutter.plugin.common.EventChannel; +import io.flutter.plugin.common.MethodCall; +import io.flutter.plugin.common.MethodChannel; +import io.flutter.plugins.facediscern.tool.face.constants.FaceBrodConstant; +import io.flutter.plugins.facediscern.ui.FaceDiscern; + +public class FlutterFaceDiscernPlugin implements FlutterPlugin, MethodChannel.MethodCallHandler, EventChannel.StreamHandler { + + private static final String TAG = "人脸识别插件"; + + private static final String CHANNEL_TAG = "local/face_discern"; + private static final String EVENT_TAG = "local/face_discern_event"; + + public static MethodChannel staticChannel; + + private Context context; + private MethodChannel channel; + + private EventChannel eventChannel; + + private MethodChannel.Result pendingResult; + + private FlutterFaceReceiver flutterFaceReceiver; + + private boolean isReceiverRegistered = false; + + private BroadcastReceiver actionsReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + List actions = intent.getIntegerArrayListExtra("actions"); + pendingResult.success(actions); + } + }; + + + @Override + public void onAttachedToEngine(@NonNull FlutterPluginBinding binding) { + context = binding.getApplicationContext(); + channel = new MethodChannel(binding.getBinaryMessenger(), CHANNEL_TAG); + staticChannel = channel; + channel.setMethodCallHandler(this); + eventChannel = new EventChannel(binding.getBinaryMessenger(), EVENT_TAG); + eventChannel.setStreamHandler(this); + } + + @Override + public void onDetachedFromEngine(@NonNull FlutterPluginBinding binding) { + channel.setMethodCallHandler(null); + eventChannel.setStreamHandler(null); + if (actionsReceiver != null && isReceiverRegistered) { + context.unregisterReceiver(actionsReceiver); + } + } + + // flutter 方法的回调 + @Override + public void onMethodCall(@NonNull MethodCall call, @NonNull MethodChannel.Result result) { + String method = call.method; + Log.i(TAG, "执行方法:" + method + ";参数:" + new Gson().toJson(call.arguments) + ";"); + switch (method) { + case "jumpFaceDiscern": + try { + Intent intent = new Intent(context, FaceDiscern.class); + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + intent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP | Intent.FLAG_ACTIVITY_REORDER_TO_FRONT); + context.startActivity(intent); + result.success(true); + } catch (Exception e) { + result.success(false); + Log.i(TAG, "jumpFaceDiscern error:" + e.getMessage(), e); + } + break; + case "active": + String appId = Objects.requireNonNull(call.argument("appId")).toString(); + String activeKey = Objects.requireNonNull(call.argument("activeKey")).toString(); + String sdkKey = Objects.requireNonNull(call.argument("sdkKey")).toString(); + Boolean active = FlutterTool.active(context, appId, activeKey, sdkKey); + result.success(active); + break; + case "getConfig": + result.success(FlutterTool.getConfig()); + break; + case "registerAllStorageFace": + String filePath = Objects.requireNonNull(call.argument("filePath")).toString(); + String fileName = Objects.requireNonNull(call.argument("fileName")).toString(); + Map res = FlutterTool.registerAllStorageFace(context, filePath, fileName); + result.success(res); + break; + case "registerFaceByDatabase": + String userId = call.argument("userId"); + List errorSampleIdList = FlutterTool.registerFaceByDatabase(context, userId); + result.success(errorSampleIdList); + break; + case "getFace": + String sampleId = Objects.requireNonNull(call.argument("sampleId")).toString(); + Map face = FlutterTool.getFace(context, Integer.parseInt(sampleId)); + result.success(face); + break; + case "getFaceCount": + int faceCount = FlutterTool.getFaceCount(context); + result.success(faceCount); + break; + case "removeFace": + String removeSampleId = Objects.requireNonNull(call.argument("sampleId")).toString(); + Boolean isRemove = FlutterTool.removeFace(context, Integer.parseInt(removeSampleId)); + result.success(isRemove); + break; + } + } + + // flutter回调监听 + @Override + public void onListen(Object arguments, EventChannel.EventSink events) { + Log.e(TAG, "flutter回调监听进入"); + try { + IntentFilter intentFilter = new IntentFilter(); + intentFilter.addAction(FaceBrodConstant.FACE_GLOBAL_INTENT); + flutterFaceReceiver = new FlutterFaceReceiver(events); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + context.registerReceiver(flutterFaceReceiver, intentFilter, Context.RECEIVER_EXPORTED); + } else { + context.registerReceiver(flutterFaceReceiver, intentFilter); + } + isReceiverRegistered = true; + } catch (Exception e) { + Log.e(TAG, "flutter回调监听错误error:" + e.getMessage(), e); + } + } + + @Override + public void onCancel(Object arguments) { + context.unregisterReceiver(flutterFaceReceiver); + flutterFaceReceiver = null; + } +} diff --git a/android/app/src/main/java/io/flutter/plugins/facediscern/flutter/FlutterFaceReceiver.java b/android/app/src/main/java/io/flutter/plugins/facediscern/flutter/FlutterFaceReceiver.java new file mode 100644 index 0000000..d2404c6 --- /dev/null +++ b/android/app/src/main/java/io/flutter/plugins/facediscern/flutter/FlutterFaceReceiver.java @@ -0,0 +1,30 @@ +package io.flutter.plugins.facediscern.flutter; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; + +import java.util.Objects; + +import io.flutter.plugin.common.EventChannel; + +public class FlutterFaceReceiver extends BroadcastReceiver { + + private static final String TAG = "flutter回调广播"; + + private EventChannel.EventSink eventSink; + + public FlutterFaceReceiver(EventChannel.EventSink eventSink) { + this.eventSink = eventSink; + } + + @Override + public void onReceive(Context context, Intent intent) { + String json = intent.getStringExtra("json"); + if (json == null || Objects.equals(json, "")) { + return; + } + eventSink.success(json); + } + +} diff --git a/android/app/src/main/java/io/flutter/plugins/facediscern/flutter/FlutterTool.java b/android/app/src/main/java/io/flutter/plugins/facediscern/flutter/FlutterTool.java new file mode 100644 index 0000000..534fc99 --- /dev/null +++ b/android/app/src/main/java/io/flutter/plugins/facediscern/flutter/FlutterTool.java @@ -0,0 +1,271 @@ +package io.flutter.plugins.facediscern.flutter; + +import static com.arcsoft.face.FaceEngine.activeOnline; + +import static io.flutter.plugins.facediscern.database.DataBaseUtils.getUserFace; +import static io.flutter.plugins.facediscern.database.DataBaseUtils.updateFaceModel; + +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; + +import io.flutter.Log; + +import com.arcsoft.face.ErrorInfo; +import com.arcsoft.face.FaceFeatureInfo; +import com.arcsoft.imageutil.ArcSoftImageFormat; +import com.arcsoft.imageutil.ArcSoftImageUtil; +import com.arcsoft.imageutil.ArcSoftImageUtilError; +import com.google.gson.Gson; + +import java.io.File; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.stream.Collectors; + +import io.flutter.plugins.facediscern.flutter.config.Constants; +import io.flutter.plugins.facediscern.tool.face.model.FaceEntity; +import io.flutter.plugins.facediscern.tool.face.servcie.FaceServer; +import io.flutter.plugins.facediscern.tool.file.FileUtil; + +public class FlutterTool { + + static private final String TAG = "人脸识别工具类"; + + static private final String FACE_JSON_FILE_NAME = "face.json"; + + // 在线激活 + static public Boolean active(Context context, String appId, String activeKey, String sdkKey) { + if (Objects.isNull(appId)) { + Log.e(TAG, "请传入appId"); + return false; + } + if (Objects.isNull(activeKey)) { + Log.e(TAG, "请传入activeKey"); + return false; + } + if (Objects.isNull(sdkKey)) { + Log.e(TAG, "请传入sdkKey"); + return false; + } + Constants.APP_ID = appId; + Constants.ACTIVE_KEY = activeKey; + Constants.SDK_KEY = sdkKey; + int code = activeOnline(context, Constants.ACTIVE_KEY, Constants.APP_ID, Constants.SDK_KEY); + switch (code) { + case ErrorInfo.MOK: + Log.i(TAG, "激活成功!"); + break; + case ErrorInfo.MERR_ASF_ALREADY_ACTIVATED: + Log.i(TAG, "已激活!"); + break; + default: + Log.e(TAG, "激活失败!code:" + code); + } + boolean flag = Objects.equals(code, ErrorInfo.MOK) || Objects.equals(code, ErrorInfo.MERR_ASF_ALREADY_ACTIVATED); + Constants.IS_ACTIVE = flag; + return flag; + } + + // 获取配置 + static Map getConfig() { + HashMap config = new HashMap<>(); + config.put("appId", Constants.APP_ID); + config.put("sdkKey", Constants.SDK_KEY); + config.put("activeKey", Constants.ACTIVE_KEY); + config.put("isActive", Constants.IS_ACTIVE); + return config; + } + + + static Integer parseId(String id) { + try { + int number = Integer.parseInt(id); + return number - 10000000; + } catch (Exception e) { + return null; + } + } + + // 注册所有的本地人脸 + static public Map registerAllStorageFace(Context context, String filePath, String fileName) { + Map result = new HashMap<>(); + result.put("successCount", 0); + result.put("failedCount", 0); + result.put("registerCount", 0); + result.put("fileCount", 0); + File file = new File(filePath); + if (!file.exists()) { + return null; + } + File[] files = file.listFiles((dir1, name) -> { + String nameLowerCase = name.toLowerCase(); + return nameLowerCase.endsWith(".jpg") || nameLowerCase.endsWith(".jpeg") || nameLowerCase.endsWith(".png"); + }); + if (Objects.nonNull(fileName) && !Objects.equals(fileName, "")) { + files = Arrays.stream(files).filter(v -> Objects.equals(v.getName(), fileName)).toArray(File[]::new); + Log.i(TAG, "根据fileName查询本地人脸图片fileName:" + fileName + ";数量:" + files.length + ";"); + } + if (files == null || files.length == 0) { + return null; + } + int total = files.length; + int failed = 0; + int success = 0; + for (File v : files) { + byte[] bytes = FileUtil.fileToData(v); + if (bytes == null) { + continue; + } + String name = v.getName(); + List list = Arrays.asList(name.split("\\.")); + if (list.size() == 2) { + name = list.get(0); + } + list = Arrays.asList(name.split("_")); + if (list.size() != 3) { + continue; + } + String _userId = list.get(0); + Integer _sampleId = parseId(list.get(1)); + String _username = list.get(2); + Log.i(TAG, "注册所有的本地人脸,本地人脸解析userId:" + _userId + ";sampleId:" + _sampleId + ";username:" + _username + ";"); + if (_userId == null || _sampleId == null || _username == null || _username.isEmpty()) { + continue; + } + try { + FaceEntity entity = new FaceEntity(); + entity.setFaceId(_sampleId); + entity.setUserId(_userId); + entity.setUserName(_username); + entity.setSampleId(_sampleId); + Boolean isRegister = registerFace(context, bytes, entity); + if (isRegister) { + success++; + } else { + failed++; + } + } catch (Exception e) { + failed++; + Log.e(TAG, "注册所有的本地人脸,把人脸图片注册到内存错误图片名:" + name); + } + } + int registerCount = success + failed; + Log.i(TAG, "注册所有的本地人脸,注册成功,成功的数量:" + success + ";失败的数量:" + failed + ";尝试注册数量:" + registerCount + ";文件数量:" + total + ";"); + result.put("successCount", success); + result.put("failedCount", failed); + result.put("registerCount", registerCount); + result.put("fileCount", total); + return result; + } + + + // 注册人脸 + static public Boolean registerFace(Context context, byte[] bytes, FaceEntity entity) { + FaceServer faceServer = FaceServer.getInstance(); + faceServer.init(context); + // 类型转换 + Bitmap bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.length); + Bitmap alignedBitmap = ArcSoftImageUtil.getAlignedBitmap(bitmap, true); + byte[] bgr24Data = ArcSoftImageUtil.createImageData(alignedBitmap.getWidth(), alignedBitmap.getHeight(), ArcSoftImageFormat.BGR24); + int transformCode = ArcSoftImageUtil.bitmapToImageData(alignedBitmap, bgr24Data, ArcSoftImageFormat.BGR24); + if (transformCode != ArcSoftImageUtilError.CODE_SUCCESS) { + Log.e(TAG, "注册人脸,bitmapToImageData错误code:" + transformCode); + return false; + } + byte[] faceBytes = faceServer.registerBgr24( + context, + bgr24Data, + alignedBitmap.getWidth(), + alignedBitmap.getHeight(), + String.valueOf(System.currentTimeMillis()) + ); + if (faceBytes == null) { + Log.e(TAG, "注册人脸,registerBgr24错误"); + return false; + } + entity.setFaceModel(faceBytes); + int code = faceServer.registerFaceFeatureInfoFromDb(entity); + return code == ErrorInfo.MOK; + } + + + // 获取人脸 + static public Map getFace(Context context, int sampleId) { + FaceServer faceServer = FaceServer.getInstance(); + faceServer.init(context); + FaceFeatureInfo faceFeature = faceServer.getFaceFeature(sampleId); + if (Objects.isNull(faceFeature)) return null; + Map result = new HashMap<>(); + result.put("searchId", faceFeature.getSearchId()); + result.put("faceTag", faceFeature.getFaceTag()); + result.put("featureData", faceFeature.getFeatureData()); + return result; + } + + // 获取人脸总数量 + static public int getFaceCount(Context context) { + FaceServer faceServer = FaceServer.getInstance(); + faceServer.init(context); + return faceServer.getTotalFaceCount(); + } + + // 删除人脸 -1 删除全部 + static public Boolean removeFace(Context context, int sampleId) { + FaceServer faceServer = FaceServer.getInstance(); + faceServer.init(context); + int i = faceServer.removeFace(sampleId); + return i == 0; + } + + + // 批量注册全部人脸 + static public List registerFaceByDatabase(Context context, String userId) { + List faceList = getUserFace(context, userId); + List errorSampleIdList = new ArrayList<>(); + if (faceList.isEmpty()) return errorSampleIdList; + long startTime = System.currentTimeMillis(); + // 批量查询人脸模型 + for (FaceEntity v : faceList) { + if (v.getFaceModel() != null && v.getFaceModel().length > 0) continue; + byte[] bytes = null; + try { + bytes = FileUtil.fileToData(new File(v.getFaceFilePath())); + } catch (Exception e) { + errorSampleIdList.add(v.getSampleId()); + Log.e(TAG, "registerFaceByDatabase,方法错误,file读取错误error:" + e.getMessage() + "json:" + new Gson().toJson(v), e); + continue; + } + if (bytes == null || bytes.length == 0) { + errorSampleIdList.add(v.getSampleId()); + Log.e(TAG, "registerFaceByDatabase,方法错误" + v.getUserName() + "bytes is null," + "json:" + new Gson().toJson(v)); + continue; + } + Boolean isRegister = registerFace(context, bytes, v); + if (isRegister) { + updateFaceModel(context, v); + } + } + FaceServer faceServer = FaceServer.getInstance(); + faceServer.init(context); + if (errorSampleIdList.isEmpty()) { + try { + faceServer.registerFaceFeatureInfoListFromDb(faceList); + Log.d(TAG, "registerFaceByDatabase,注册全部人脸耗时:" + (System.currentTimeMillis() - startTime) + "毫秒"); + } catch (Exception e) { + faceServer.removeFace(-1); + Log.e(TAG, "registerFaceByDatabase,人脸引擎批量注册失败使用单个注册"); + } + } else { + for (FaceEntity v : faceList) { + faceServer.registerFaceFeatureInfoFromDb(v); + } + Log.d(TAG, "registerFaceByDatabase,注册单个人脸耗时:" + (System.currentTimeMillis() - startTime) + "毫秒"); + } + return errorSampleIdList; + } +} diff --git a/android/app/src/main/java/io/flutter/plugins/facediscern/flutter/config/Constants.java b/android/app/src/main/java/io/flutter/plugins/facediscern/flutter/config/Constants.java new file mode 100644 index 0000000..1c234e7 --- /dev/null +++ b/android/app/src/main/java/io/flutter/plugins/facediscern/flutter/config/Constants.java @@ -0,0 +1,9 @@ +package io.flutter.plugins.facediscern.flutter.config; + + +public class Constants { + public static String APP_ID = "FWKhBhtgnUnVrUJXZD3SWqnGYV99AiXP4SS8aNSR6bk3"; + public static String SDK_KEY = "5SAziGjCHYPY4GdjGY8dTiBABBmuLbiTp7oWsKTcWzMb"; + public static String ACTIVE_KEY = "U571-11UF-F13U-F43A"; + public static boolean IS_ACTIVE = false; +} diff --git a/android/app/src/main/java/io/flutter/plugins/facediscern/tool/camera/DualCameraHelper.java b/android/app/src/main/java/io/flutter/plugins/facediscern/tool/camera/DualCameraHelper.java new file mode 100644 index 0000000..12cc662 --- /dev/null +++ b/android/app/src/main/java/io/flutter/plugins/facediscern/tool/camera/DualCameraHelper.java @@ -0,0 +1,583 @@ +package io.flutter.plugins.facediscern.tool.camera; + +import android.graphics.ImageFormat; +import android.graphics.Point; +import android.graphics.SurfaceTexture; +import android.hardware.Camera; +import android.util.Log; +import android.view.Surface; +import android.view.SurfaceHolder; +import android.view.SurfaceView; +import android.view.TextureView; +import android.view.View; + +import io.flutter.plugins.facediscern.tool.camera.callback.CameraListener; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Comparator; +import java.util.List; + +/** + * 打开两个相机的辅助类,和{@link CameraListener}共同使用,获取nv21数据等操作 + *

+ * 由于IR摄像头和RGB摄像头的默认分辨率可能不同,为了让两者相同,该类做了以下操作: + * 1. 获取两者支持的分辨率列表到到静态变量{@link DualCameraHelper#rgbSupportedPreviewSizes}及{@link DualCameraHelper#irSupportedPreviewSizes}中, + * 2. 使用{@link DualCameraHelper#getCommonSupportedPreviewSize()}方法取分辨率的交集, + * 3. 使用{@link DualCameraHelper#getBestSupportedSize(List, Point)}取最佳分辨率使两个摄像头分辨率尽可能相同 + */ +public class DualCameraHelper implements Camera.PreviewCallback { + private static List rgbSupportedPreviewSizes; + private static List irSupportedPreviewSizes; + private static final String TAG = "CameraHelper"; + private Camera mCamera; + private int mCameraId; + private Point previewViewSize; + private View previewDisplayView; + private Camera.Size previewSize; + private Point specificPreviewSize; + private int displayOrientation = 0; + private int rotation; + private int additionalRotation; + private boolean isMirror = false; + + private Integer specificCameraId = null; + private CameraListener cameraListener; + private static final int MIN_PREVIEW_WIDTH = 720; + private static final int MIN_PREVIEW_HEIGHT = 720; + + private DualCameraHelper(Builder builder) { + previewDisplayView = builder.previewDisplayView; + specificCameraId = builder.specificCameraId; + cameraListener = builder.cameraListener; + rotation = builder.rotation; + additionalRotation = builder.additionalRotation; + previewViewSize = builder.previewViewSize; + specificPreviewSize = builder.previewSize; + if (builder.previewDisplayView instanceof TextureView) { + isMirror = builder.isMirror; + } else if (isMirror) { + throw new RuntimeException("mirror is effective only when the preview is on a textureView"); + } + } + + public void init() { + if (previewDisplayView instanceof TextureView) { + ((TextureView) this.previewDisplayView).setSurfaceTextureListener(textureListener); + } else if (previewDisplayView instanceof SurfaceView) { + ((SurfaceView) previewDisplayView).getHolder().addCallback(surfaceCallback); + } + + if (isMirror) { + previewDisplayView.setScaleX(-1); + } + } + + public List getCommonSupportedPreviewSize() { + /** + * irSupportedPreviewSizes 和 rgbSupportedPreviewSizes 为null才去获取, + * 不为null就没必要获取了,而且此时有可能该camera已处于打开状态,无法打开camera + */ + if (rgbSupportedPreviewSizes == null) { + Camera rgbCamera = Camera.open(Camera.CameraInfo.CAMERA_FACING_BACK); + rgbSupportedPreviewSizes = rgbCamera.getParameters().getSupportedPreviewSizes(); + rgbCamera.release(); + } + try { + if (irSupportedPreviewSizes == null) { + Camera irCamera = Camera.open(Camera.CameraInfo.CAMERA_FACING_FRONT); + irSupportedPreviewSizes = irCamera.getParameters().getSupportedPreviewSizes(); + irCamera.release(); + } + } catch (RuntimeException e) { + e.printStackTrace(); + irSupportedPreviewSizes = rgbSupportedPreviewSizes; + } + List commonPreviewSizes = new ArrayList<>(); + for (Camera.Size rgbPreviewSize : rgbSupportedPreviewSizes) { + if (rgbPreviewSize.width < MIN_PREVIEW_WIDTH || rgbPreviewSize.height < MIN_PREVIEW_HEIGHT) { + continue; + } + for (Camera.Size irPreviewSize : irSupportedPreviewSizes) { + if (irPreviewSize.width == rgbPreviewSize.width && irPreviewSize.height == rgbPreviewSize.height) { + commonPreviewSizes.add(rgbPreviewSize); + } + } + } + return commonPreviewSizes; + } + + /** + * 回传当前使用的cameraID,若当前没打开相机,回传-1 + * + * @return cameraId,失败回传-1 + */ + public int getCurrentOpenedCameraId() { + if (mCamera == null) { + return -1; + } + return mCameraId; + } + + public void start() { + synchronized (this) { + if (mCamera != null) { + return; + } + List supportedPreviewSize = getCommonSupportedPreviewSize(); + //相机数量为2则打开1,1则打开0,相机ID 1为前置,0为后置 + mCameraId = Camera.getNumberOfCameras() - 1; + //若指定了相机ID且该相机存在,则打开指定的相机 + if (specificCameraId != null && specificCameraId <= mCameraId) { + mCameraId = specificCameraId; + } + //没有相机 + if (mCameraId == -1) { + if (cameraListener != null) { + cameraListener.onCameraError(new Exception("camera not found")); + } + return; + } + if (mCamera == null) { + mCamera = Camera.open(mCameraId); + } + displayOrientation = getCameraOri(rotation); + mCamera.setDisplayOrientation(displayOrientation); + try { + Camera.Parameters parameters = mCamera.getParameters(); + parameters.setPreviewFormat(ImageFormat.NV21); + //预览大小设置 + previewSize = parameters.getPreviewSize(); + if (supportedPreviewSize != null && supportedPreviewSize.size() > 0) { + previewSize = getBestSupportedSize(supportedPreviewSize, previewViewSize); + } + Log.i(TAG, "start: " + previewSize.width + "x" + previewSize.height); + parameters.setPreviewSize(previewSize.width, previewSize.height); + //对焦模式设置 + List supportedFocusModes = parameters.getSupportedFocusModes(); + if (supportedFocusModes != null && supportedFocusModes.size() > 0) { + if (supportedFocusModes.contains(Camera.Parameters.FOCUS_MODE_CONTINUOUS_PICTURE)) { + parameters.setFocusMode(Camera.Parameters.FOCUS_MODE_CONTINUOUS_PICTURE); + } else if (supportedFocusModes.contains(Camera.Parameters.FOCUS_MODE_CONTINUOUS_VIDEO)) { + parameters.setFocusMode(Camera.Parameters.FOCUS_MODE_CONTINUOUS_VIDEO); + } else if (supportedFocusModes.contains(Camera.Parameters.FOCUS_MODE_AUTO)) { + parameters.setFocusMode(Camera.Parameters.FOCUS_MODE_AUTO); + } + } + mCamera.setParameters(parameters); + if (previewDisplayView instanceof TextureView) { + mCamera.setPreviewTexture(((TextureView) previewDisplayView).getSurfaceTexture()); + } else { + mCamera.setPreviewDisplay(((SurfaceView) previewDisplayView).getHolder()); + } + mCamera.setPreviewCallback(this); + mCamera.startPreview(); + if (cameraListener != null) { + cameraListener.onCameraOpened(mCamera, mCameraId, displayOrientation, isMirror); + } + } catch (Exception e) { + if (cameraListener != null) { + cameraListener.onCameraError(e); + } + } + io.flutter.Log.e(TAG, "相机已打卡!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!"); + } + } + + public void switchCameraId() { + mCameraId = 1 - mCameraId; + if (specificCameraId != null) { + specificCameraId = 1 - specificCameraId; + } + } + + private int getCameraOri(int rotation) { + int degrees = rotation * 90; + switch (rotation) { + case Surface.ROTATION_0: + degrees = 0; + break; + case Surface.ROTATION_90: + degrees = 90; + break; + case Surface.ROTATION_180: + degrees = 180; + break; + case Surface.ROTATION_270: + degrees = 270; + break; + default: + break; + } + additionalRotation /= 90; + additionalRotation *= 90; + degrees += additionalRotation; + int result; + Camera.CameraInfo info = new Camera.CameraInfo(); + Camera.getCameraInfo(mCameraId, info); + if (info.facing == Camera.CameraInfo.CAMERA_FACING_FRONT) { + result = (info.orientation + degrees) % 360; + result = (360 - result) % 360; + } else { + result = (info.orientation - degrees + 360) % 360; + } + return result; + } + + /** + * 停止预览 + */ + public void stop() { + synchronized (this) { + if (mCamera == null) return; + mCamera.setPreviewCallback(null); + mCamera.stopPreview(); + } + } +// public void stop() { +// synchronized (this) { +// if (mCamera == null) return; +// mCamera.setPreviewCallback(null); +// mCamera.stopPreview(); +// mCamera.release(); +// mCamera = null; +// if (cameraListener != null) { +// cameraListener.onCameraClosed(); +// } +// } +// } + + // 继续预览 + public void resumePreview() { + synchronized (this) { + if (mCamera != null) { + try { + mCamera.startPreview(); + if (cameraListener != null) { + cameraListener.onCameraOpened(mCamera, mCameraId, displayOrientation, isMirror); + mCamera.setPreviewCallback(this); + } + } catch (Exception e) { + if (cameraListener != null) { + cameraListener.onCameraError(e); + } + } + } + } + } + + /** + * 释放操作 + */ + public void release() { + synchronized (this) { + stop(); + if (cameraListener != null) { + cameraListener.onCameraClosed(); + } + mCamera.release(); + mCamera = null; + previewDisplayView = null; + specificCameraId = null; + cameraListener = null; + previewViewSize = null; + specificPreviewSize = null; + previewSize = null; + } + } + + public boolean isStopped() { + synchronized (this) { + return mCamera == null; + } + } + + + /** + * 获取候选分辨率列表中最接近预览view大小的分辨率 + * + * @param sizes 支持的分辨率 + * @param previewViewSize 预览view的大小 + * @return 最接近预览view大小的分辨率 + */ + private Camera.Size getBestSupportedSize(List sizes, Point previewViewSize) { + if (sizes == null || sizes.size() == 0) { + return mCamera.getParameters().getPreviewSize(); + } + Camera.Size[] tempSizes = sizes.toArray(new Camera.Size[0]); + Arrays.sort(tempSizes, new Comparator() { + @Override + public int compare(Camera.Size o1, Camera.Size o2) { + if (o1.width > o2.width) { + return -1; + } else if (o1.width == o2.width) { + return o1.height > o2.height ? -1 : 1; + } else { + return 1; + } + } + }); + sizes = Arrays.asList(tempSizes); + + Camera.Size bestSize = sizes.get(0); + float previewViewRatio; + if (previewViewSize != null) { + previewViewRatio = (float) previewViewSize.x / (float) previewViewSize.y; + } else { + previewViewRatio = (float) bestSize.width / (float) bestSize.height; + } + + if (previewViewRatio > 1) { + previewViewRatio = 1 / previewViewRatio; + } + boolean isNormalRotate = (additionalRotation % 180 == 0); + + for (Camera.Size s : sizes) { + if (specificPreviewSize != null && specificPreviewSize.x == s.width && specificPreviewSize.y == s.height) { + return s; + } + if (isNormalRotate) { + if (Math.abs((s.height / (float) s.width) - previewViewRatio) < Math.abs(bestSize.height / (float) bestSize.width - previewViewRatio)) { + bestSize = s; + } + } else { + if (Math.abs((s.width / (float) s.height) - previewViewRatio) < Math.abs(bestSize.width / (float) bestSize.height - previewViewRatio)) { + bestSize = s; + } + } + } + return bestSize; + } + + public List getSupportedPreviewSizes() { + if (mCamera == null) { + return null; + } + return mCamera.getParameters().getSupportedPreviewSizes(); + } + + public List getSupportedPictureSizes() { + if (mCamera == null) { + return null; + } + return mCamera.getParameters().getSupportedPictureSizes(); + } + + + @Override + public void onPreviewFrame(byte[] nv21, Camera camera) { + if (cameraListener != null) { + cameraListener.onPreview(nv21, camera); + } + } + + private TextureView.SurfaceTextureListener textureListener = new TextureView.SurfaceTextureListener() { + @Override + public void onSurfaceTextureAvailable(SurfaceTexture surfaceTexture, int width, int height) { +// start(); + if (mCamera != null) { + try { + mCamera.setPreviewTexture(surfaceTexture); + } catch (IOException e) { + e.printStackTrace(); + } + } + } + + @Override + public void onSurfaceTextureSizeChanged(SurfaceTexture surfaceTexture, int width, int height) { + Log.i(TAG, "onSurfaceTextureSizeChanged: " + width + " " + height); + } + + @Override + public boolean onSurfaceTextureDestroyed(SurfaceTexture surfaceTexture) { + stop(); + return false; + } + + @Override + public void onSurfaceTextureUpdated(SurfaceTexture surfaceTexture) { + + } + }; + private SurfaceHolder.Callback surfaceCallback = new SurfaceHolder.Callback() { + @Override + public void surfaceCreated(SurfaceHolder holder) { +// start(); + if (mCamera != null) { + try { + mCamera.setPreviewDisplay(holder); + } catch (IOException e) { + e.printStackTrace(); + } + } + } + + @Override + public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) { + + } + + @Override + public void surfaceDestroyed(SurfaceHolder holder) { + stop(); + } + }; + + public void changeDisplayOrientation(int rotation) { + if (mCamera != null) { + this.rotation = rotation; + displayOrientation = getCameraOri(rotation); + mCamera.setDisplayOrientation(displayOrientation); + if (cameraListener != null) { + cameraListener.onCameraConfigurationChanged(mCameraId, displayOrientation); + } + } + } + + public static final class Builder { + + /** + * 预览显示的view,目前仅支持surfaceView和textureView + */ + private View previewDisplayView; + + /** + * 是否镜像显示,只支持textureView + */ + private boolean isMirror; + /** + * 指定的相机ID + */ + private Integer specificCameraId; + /** + * 事件回调 + */ + private CameraListener cameraListener; + /** + * 屏幕的长宽,在选择最佳相机比例时用到 + */ + private Point previewViewSize; + /** + * 传入getWindowManager().getDefaultDisplay().getRotation()的值即可 + */ + private int rotation; + /** + * 指定的预览宽高,若系统支持则会以这个预览宽高进行预览 + */ + private Point previewSize; + + /** + * 额外的旋转角度(用于适配一些定制设备) + */ + private int additionalRotation; + + public Builder() { + } + + + public Builder previewOn(View val) { + if (val instanceof SurfaceView || val instanceof TextureView) { + previewDisplayView = val; + return this; + } else { + throw new RuntimeException("you must preview on a textureView or a surfaceView"); + } + } + + + public Builder isMirror(boolean val) { + isMirror = val; + return this; + } + + public Builder previewSize(Point val) { + previewSize = val; + return this; + } + + public Builder previewViewSize(Point val) { + previewViewSize = val; + return this; + } + + public Builder rotation(int val) { + rotation = val; + return this; + } + + public Builder additionalRotation(int val) { + additionalRotation = val; + return this; + } + + public Builder specificCameraId(Integer val) { + specificCameraId = val; + return this; + } + + public Builder cameraListener(CameraListener val) { + cameraListener = val; + return this; + } + + public DualCameraHelper build() { + if (previewViewSize == null) { + Log.e(TAG, "previewViewSize is null, now use default previewSize"); + } + if (cameraListener == null) { + Log.e(TAG, "cameraListener is null, callback will not be called"); + } + if (previewDisplayView == null) { + throw new RuntimeException("you must preview on a textureView or a surfaceView"); + } + return new DualCameraHelper(this); + } + } + + /** + * 根据设置的额外旋转角度旋转 + * + * @param additionalRotation 额外旋转角度 + * @return 当前显示旋转角度 + */ + public int rotateAdditional(int additionalRotation) { + this.additionalRotation = additionalRotation; + int cameraOri = getCameraOri(rotation); + if (mCamera == null) { + start(); + return cameraOri; + } + mCamera.setDisplayOrientation(cameraOri); + return cameraOri; + } + + public void setSpecificPreviewSize(Point specificPreviewSize) { + this.specificPreviewSize = specificPreviewSize; + } + + public static boolean hasDualCamera() { + return Camera.getNumberOfCameras() > 1; + } + + public static boolean canOpenDualCamera() { + Camera camera0 = null; + Camera camera1 = null; + boolean can = true; + try { + camera0 = Camera.open(Camera.CameraInfo.CAMERA_FACING_BACK); + camera1 = Camera.open(Camera.CameraInfo.CAMERA_FACING_FRONT); + } catch (Exception e) { + can = false; + } + if (camera0 != null) { + camera0.release(); + } + if (camera1 != null) { + camera1.release(); + } + return can; + } + +} diff --git a/android/app/src/main/java/io/flutter/plugins/facediscern/tool/camera/callback/CameraListener.java b/android/app/src/main/java/io/flutter/plugins/facediscern/tool/camera/callback/CameraListener.java new file mode 100644 index 0000000..ccc2ea3 --- /dev/null +++ b/android/app/src/main/java/io/flutter/plugins/facediscern/tool/camera/callback/CameraListener.java @@ -0,0 +1,40 @@ +package io.flutter.plugins.facediscern.tool.camera.callback; + +import android.hardware.Camera; + + +public interface CameraListener { + /** + * 当打开时执行 + * @param camera 相机实例 + * @param cameraId 相机ID + * @param displayOrientation 相机预览旋转角度 + * @param isMirror 是否镜像显示 + */ + void onCameraOpened(Camera camera, int cameraId, int displayOrientation, boolean isMirror); + + /** + * 预览数据回调 + * @param data 预览数据 + * @param camera 相机实例 + */ + void onPreview(byte[] data, Camera camera); + + /** + * 当相机关闭时执行 + */ + void onCameraClosed(); + + /** + * 当出现异常时执行 + * @param e 相机相关异常 + */ + void onCameraError(Exception e); + + /** + * 属性变化时调用 + * @param cameraID 相机ID + * @param displayOrientation 相机旋转方向 + */ + void onCameraConfigurationChanged(int cameraID, int displayOrientation); +} diff --git a/android/app/src/main/java/io/flutter/plugins/facediscern/tool/camera/config/PreviewConfig.java b/android/app/src/main/java/io/flutter/plugins/facediscern/tool/camera/config/PreviewConfig.java new file mode 100644 index 0000000..66041c1 --- /dev/null +++ b/android/app/src/main/java/io/flutter/plugins/facediscern/tool/camera/config/PreviewConfig.java @@ -0,0 +1,58 @@ +package io.flutter.plugins.facediscern.tool.camera.config; + +import android.hardware.Camera; + +public class PreviewConfig { + /** + * 默认的可见光相机ID + */ + public static final int DEFAULT_RGB_CAMERA_ID = Camera.CameraInfo.CAMERA_FACING_BACK; + /** + * 默认的红外相机ID + */ + public static final int DEFAULT_IR_CAMERA_ID = Camera.CameraInfo.CAMERA_FACING_FRONT; + + private int rgbCameraId; + private int irCameraId; + private int rgbAdditionalDisplayOrientation; + private int irAdditionalDisplayOrientation; + + public PreviewConfig(int rgbCameraId, int irCameraId, int rgbAdditionalDisplayOrientation, int irAdditionalDisplayOrientation) { + this.rgbCameraId = rgbCameraId; + this.irCameraId = irCameraId; + this.rgbAdditionalDisplayOrientation = rgbAdditionalDisplayOrientation; + this.irAdditionalDisplayOrientation = irAdditionalDisplayOrientation; + } + + public int getRgbCameraId() { + return rgbCameraId; + } + + public int getIrCameraId() { + return irCameraId; + } + + public int getRgbAdditionalDisplayOrientation() { + return rgbAdditionalDisplayOrientation; + } + + public int getIrAdditionalDisplayOrientation() { + return irAdditionalDisplayOrientation; + } + + public void setRgbCameraId(int rgbCameraId) { + this.rgbCameraId = rgbCameraId; + } + + public void setIrCameraId(int irCameraId) { + this.irCameraId = irCameraId; + } + + public void setRgbAdditionalDisplayOrientation(int rgbAdditionalDisplayOrientation) { + this.rgbAdditionalDisplayOrientation = rgbAdditionalDisplayOrientation; + } + + public void setIrAdditionalDisplayOrientation(int irAdditionalDisplayOrientation) { + this.irAdditionalDisplayOrientation = irAdditionalDisplayOrientation; + } +} \ No newline at end of file diff --git a/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/constants/FaceBrodConstant.java b/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/constants/FaceBrodConstant.java new file mode 100644 index 0000000..92012e0 --- /dev/null +++ b/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/constants/FaceBrodConstant.java @@ -0,0 +1,7 @@ +package io.flutter.plugins.facediscern.tool.face.constants; + +public class FaceBrodConstant { + public static String FACE_GLOBAL_INTENT = "accessibility_event"; + public static final String FACE_GLOBAL_ACTIONS = "face_global_actions"; + // public static final String FACE_GLOBAL_BROD = "face_global_brod"; +} diff --git a/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/constants/LivenessType.java b/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/constants/LivenessType.java new file mode 100644 index 0000000..90e7348 --- /dev/null +++ b/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/constants/LivenessType.java @@ -0,0 +1,15 @@ +package io.flutter.plugins.facediscern.tool.face.constants; + +/** + * 活体检测类型 + */ +public enum LivenessType { + /** + * RGB活体检测 + */ + RGB, + /** + * 红外活体检测 + */ + IR +} diff --git a/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/constants/RecognizeColor.java b/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/constants/RecognizeColor.java new file mode 100644 index 0000000..b363e6d --- /dev/null +++ b/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/constants/RecognizeColor.java @@ -0,0 +1,22 @@ +package io.flutter.plugins.facediscern.tool.face.constants; + +import android.graphics.Color; + +/** + * 识别过程中人脸框的颜色 + */ +public class RecognizeColor { + /** + * 未知情况的颜色 + */ + public static final int COLOR_UNKNOWN = Color.YELLOW; + /** + * 成功的颜色 + */ + public static final int COLOR_SUCCESS = Color.GREEN; + /** + * 失败的颜色 + */ + public static final int COLOR_FAILED = Color.YELLOW; + +} diff --git a/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/constants/RequestFeatureStatus.java b/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/constants/RequestFeatureStatus.java new file mode 100644 index 0000000..554061e --- /dev/null +++ b/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/constants/RequestFeatureStatus.java @@ -0,0 +1,28 @@ +package io.flutter.plugins.facediscern.tool.face.constants; + +/** + * 人脸识别中可能出现的状态 + * @author + */ +public @interface RequestFeatureStatus { + /** + * 默认状态 + */ + int DEFAULT = -1; + /** + * 处理中 + */ + int SEARCHING = 0; + /** + * 识别成功 + */ + int SUCCEED = 1; + /** + * 待重试 + */ + int TO_RETRY = 2; + /** + * 识别失败 + */ + int FAILED = 3; +} diff --git a/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/constants/RequestLivenessStatus.java b/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/constants/RequestLivenessStatus.java new file mode 100644 index 0000000..e4aaad0 --- /dev/null +++ b/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/constants/RequestLivenessStatus.java @@ -0,0 +1,5 @@ +package io.flutter.plugins.facediscern.tool.face.constants; + +public class RequestLivenessStatus { + public static final int ANALYZING = 10; +} diff --git a/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/filter/FaceMoveFilter.java b/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/filter/FaceMoveFilter.java new file mode 100644 index 0000000..81408ef --- /dev/null +++ b/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/filter/FaceMoveFilter.java @@ -0,0 +1,95 @@ +package io.flutter.plugins.facediscern.tool.face.filter; + +import android.graphics.Rect; + + +import io.flutter.plugins.facediscern.tool.face.filter.callback.FaceRecognizeFilter; +import io.flutter.plugins.facediscern.tool.face.model.FacePreviewInfo; + +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.LinkedBlockingDeque; + +/** + * 人脸移动过滤器: + * 仅保留在{@link FaceMoveFilter#CHECK_QUEUE_SIZE}帧数内,每一帧人脸的移动大小都小于{@link FaceMoveFilter#movePixels}的人脸 + */ +public class FaceMoveFilter implements FaceRecognizeFilter { + private static final String TAG = "FaceMoveFilter"; + private Map> facePositionQueueMap = new ConcurrentHashMap<>(); + private static final int CHECK_QUEUE_SIZE = 5; + private double movePixels; + + public FaceMoveFilter(double movePixels) { + this.movePixels = movePixels; + } + + @Override + public void filter(List facePreviewInfoList) { + clearFacesNotInPreview(facePreviewInfoList); + for (FacePreviewInfo facePreviewInfo : facePreviewInfoList) { + LinkedBlockingDeque rectDeque = facePositionQueueMap.get(facePreviewInfo.getTrackId()); + if (rectDeque == null) { + rectDeque = new LinkedBlockingDeque<>(CHECK_QUEUE_SIZE); + facePositionQueueMap.put(facePreviewInfo.getTrackId(), rectDeque); + } + if (rectDeque.remainingCapacity() == 0) { + rectDeque.removeLast(); + } + rectDeque.push(facePreviewInfo.getFaceInfoRgb().getRect()); + + if (!facePreviewInfo.isQualityPass()) { + continue; + } + + boolean qualityPass = false; + if (rectDeque.size() == CHECK_QUEUE_SIZE) { + qualityPass = true; + Iterator iterator = rectDeque.iterator(); + Rect previous = iterator.next(); + while (iterator.hasNext()) { + Rect current = iterator.next(); + double distance = getDistance(current, previous); + previous = current; + if (distance > movePixels) { + qualityPass = false; + break; + } + } + } + facePreviewInfo.setQualityPass(qualityPass); + } + } + + private void clearFacesNotInPreview(List facePreviewInfo) { + Set trackIdSet = facePositionQueueMap.keySet(); + for (Integer trackId : trackIdSet) { + boolean contains = false; + for (FacePreviewInfo previewInfo : facePreviewInfo) { + if (previewInfo.getTrackId() == trackId) { + contains = true; + break; + } + } + if (!contains) { + facePositionQueueMap.remove(trackId); + } + } + } + + public static double getDistance(Rect first, Rect second) { + int firstX = first.centerX(); + int firstY = first.centerY(); + + int secondX = second.centerX(); + int secondY = second.centerY(); + + int distanceX = secondX - firstX; + int distanceY = secondY - firstY; + + return Math.sqrt(distanceX * distanceX + distanceY * distanceY); + } +} diff --git a/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/filter/FaceRecognizeAreaFilter.java b/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/filter/FaceRecognizeAreaFilter.java new file mode 100644 index 0000000..62163f0 --- /dev/null +++ b/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/filter/FaceRecognizeAreaFilter.java @@ -0,0 +1,32 @@ +package io.flutter.plugins.facediscern.tool.face.filter; + + +import android.graphics.Rect; + +import io.flutter.plugins.facediscern.tool.face.filter.callback.FaceRecognizeFilter; +import io.flutter.plugins.facediscern.tool.face.model.FacePreviewInfo; + +import java.util.List; + +/** + * 人脸识别区域过滤器: + * 仅保留人脸区域在{@link FaceRecognizeAreaFilter#validArea}中的人脸。(基于View位置判断) + */ +public class FaceRecognizeAreaFilter implements FaceRecognizeFilter { + private static final String TAG = "FaceRecognizeAreaFilter"; + private Rect validArea; + + public FaceRecognizeAreaFilter(Rect validArea) { + this.validArea = validArea; + } + + @Override + public void filter(List facePreviewInfoList) { + for (FacePreviewInfo facePreviewInfo : facePreviewInfoList) { + if (!facePreviewInfo.isQualityPass()) { + continue; + } + facePreviewInfo.setQualityPass(validArea.contains(facePreviewInfo.getRgbTransformedRect())); + } + } +} diff --git a/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/filter/FaceSizeFilter.java b/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/filter/FaceSizeFilter.java new file mode 100644 index 0000000..adceafa --- /dev/null +++ b/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/filter/FaceSizeFilter.java @@ -0,0 +1,41 @@ +package io.flutter.plugins.facediscern.tool.face.filter; + +import android.graphics.Rect; + + +import io.flutter.plugins.facediscern.tool.face.filter.callback.FaceRecognizeFilter; +import io.flutter.plugins.facediscern.tool.face.model.FacePreviewInfo; + +import java.util.List; + +/** + * 人脸尺寸过滤器: + * 仅保留人脸宽度大于{@link FaceSizeFilter#horizontalSize},且人脸高度大于{@link FaceSizeFilter#verticalSize}的人脸。 + */ +public class FaceSizeFilter implements FaceRecognizeFilter { + private int horizontalSize; + private int verticalSize; + + private static final String TAG = "FaceSizeFilter"; + + public FaceSizeFilter(int horizontalSize, int verticalSize) { + this.horizontalSize = horizontalSize; + this.verticalSize = verticalSize; + } + + @Override + public void filter(List facePreviewInfoList) { + for (FacePreviewInfo facePreviewInfo : facePreviewInfoList) { + if (!facePreviewInfo.isQualityPass()) { + continue; + } + if (facePreviewInfo.getFaceInfoRgb() != null) { + Rect rgbRect = facePreviewInfo.getFaceInfoRgb().getRect(); + Rect irRect = facePreviewInfo.getFaceInfoIr() == null ? null : facePreviewInfo.getFaceInfoIr().getRect(); + boolean rgbRectValid = rgbRect == null || (rgbRect.width() > horizontalSize && rgbRect.height() > verticalSize); + boolean irRectValid = irRect == null || (irRect.width() > horizontalSize && irRect.height() > verticalSize); + facePreviewInfo.setQualityPass(rgbRectValid && irRectValid); + } + } + } +} diff --git a/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/filter/callback/FaceRecognizeFilter.java b/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/filter/callback/FaceRecognizeFilter.java new file mode 100644 index 0000000..778e1e4 --- /dev/null +++ b/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/filter/callback/FaceRecognizeFilter.java @@ -0,0 +1,14 @@ +package io.flutter.plugins.facediscern.tool.face.filter.callback; + + + +import io.flutter.plugins.facediscern.tool.face.model.FacePreviewInfo; + +import java.util.List; + +/** + * 人脸识别过滤器,仅保留满足条件的人脸,(只有满足条件的人脸才进行后续的活体检测、人脸识别操作) + */ +public interface FaceRecognizeFilter { + void filter(List facePreviewInfoList); +} diff --git a/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/help/FaceHelper.java b/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/help/FaceHelper.java new file mode 100644 index 0000000..bf33e23 --- /dev/null +++ b/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/help/FaceHelper.java @@ -0,0 +1,1183 @@ +package io.flutter.plugins.facediscern.tool.face.help; + +import android.graphics.Rect; +import android.hardware.Camera; +import android.util.Log; + +import androidx.annotation.IntDef; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.arcsoft.face.ErrorInfo; +import com.arcsoft.face.FaceEngine; +import com.arcsoft.face.FaceFeature; +import com.arcsoft.face.FaceInfo; +import com.arcsoft.face.ImageQualitySimilar; +import com.arcsoft.face.LivenessInfo; +import com.arcsoft.face.MaskInfo; +import com.arcsoft.face.enums.ExtractType; +import com.google.gson.Gson; + +import io.flutter.plugins.facediscern.tool.face.constants.LivenessType; +import io.flutter.plugins.facediscern.tool.face.constants.RequestFeatureStatus; +import io.flutter.plugins.facediscern.tool.face.constants.RequestLivenessStatus; +import io.flutter.plugins.facediscern.tool.face.filter.FaceMoveFilter; +import io.flutter.plugins.facediscern.tool.face.filter.FaceRecognizeAreaFilter; +import io.flutter.plugins.facediscern.tool.face.filter.FaceSizeFilter; +import io.flutter.plugins.facediscern.tool.face.filter.callback.FaceRecognizeFilter; +import io.flutter.plugins.facediscern.tool.face.help.callback.FaceListener; +import io.flutter.plugins.facediscern.tool.face.help.callback.IDualCameraFaceInfoTransformer; +import io.flutter.plugins.facediscern.tool.face.help.callback.RecognizeCallback; +import io.flutter.plugins.facediscern.tool.face.model.CompareResult; +import io.flutter.plugins.facediscern.tool.face.model.FaceEntity; +import io.flutter.plugins.facediscern.tool.face.model.FacePreviewInfo; +import io.flutter.plugins.facediscern.tool.face.model.RecognizeConfiguration; +import io.flutter.plugins.facediscern.tool.face.model.RecognizeInfo; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.text.DecimalFormat; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Enumeration; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; + +import io.flutter.plugins.facediscern.tool.face.servcie.FaceServer; +import io.reactivex.Observable; +import io.reactivex.Observer; +import io.reactivex.android.schedulers.AndroidSchedulers; +import io.reactivex.disposables.CompositeDisposable; +import io.reactivex.disposables.Disposable; + + +/** + * 人脸操作辅助类 + */ +public class FaceHelper implements FaceListener { + + private static final String TAG = "FaceHelper"; + + /** + * 识别结果的回调 + */ + private RecognizeCallback recognizeCallback; + + + /** + * 用于记录人脸识别过程信息 + */ + private ConcurrentHashMap recognizeInfoMap = new ConcurrentHashMap<>(); + + private CompositeDisposable getFeatureDelayedDisposables = new CompositeDisposable(); + private CompositeDisposable delayFaceTaskCompositeDisposable = new CompositeDisposable(); + /** + * 转换方式,用于IR活体检测 + */ + private IDualCameraFaceInfoTransformer dualCameraFaceInfoTransformer; + + /** + * 线程池正在处理任务 + */ + private static final int ERROR_BUSY = -1; + /** + * 特征提取引擎为空 + */ + private static final int ERROR_FR_ENGINE_IS_NULL = -2; + /** + * 活体检测引擎为空 + */ + private static final int ERROR_FL_ENGINE_IS_NULL = -3; + /** + * 人脸追踪引擎 + */ + private FaceEngine ftEngine; + /** + * 口罩检测引擎 + */ + private FaceEngine maskEngine; + /** + * 特征提取引擎 + */ + private FaceEngine frEngine; + /** + * 活体检测引擎 + */ + private FaceEngine flEngine; + + private Camera.Size previewSize; + + private List faceInfoList = new CopyOnWriteArrayList<>(); + private List maskInfoList = new CopyOnWriteArrayList<>(); + /** + * 特征提取线程池 + */ + private ExecutorService frExecutor; + /** + * 活体检测线程池 + */ + private ExecutorService flExecutor; + /** + * 特征提取线程队列 + */ + private LinkedBlockingQueue frThreadQueue; + /** + * 活体检测线程队列 + */ + private LinkedBlockingQueue flThreadQueue; + + private FaceRectTransformer rgbFaceRectTransformer; + private FaceRectTransformer irFaceRectTransformer; + /** + * 控制可识别区域(相对于View),若未设置,则是全部区域 + */ + private Rect recognizeArea = new Rect(Integer.MIN_VALUE, Integer.MIN_VALUE, Integer.MAX_VALUE, Integer.MAX_VALUE); + + private List faceRecognizeFilterList = new ArrayList<>(); + /** + * 上次应用退出时,记录的该App检测过的人脸数了 + */ + private int trackedFaceCount = 0; + /** + * 本次打开引擎后的最大faceId + */ + private int currentMaxFaceId = 0; + + /** + * 是否只检测活体 + */ + private boolean onlyDetectLiveness; + + /** + * 识别的配置项 + */ + private RecognizeConfiguration recognizeConfiguration; + private List currentTrackIdList = new ArrayList<>(); + private List facePreviewInfoList = new ArrayList<>(); + + private Disposable timerDisposable; + + private FaceHelper(Builder builder) { + onlyDetectLiveness = builder.onlyDetectLiveness; + ftEngine = builder.ftEngine; + maskEngine = builder.maskEngine; + trackedFaceCount = builder.trackedFaceCount; + previewSize = builder.previewSize; + frEngine = builder.frEngine; + flEngine = builder.flEngine; + recognizeCallback = builder.recognizeCallback; + recognizeConfiguration = builder.recognizeConfiguration; + dualCameraFaceInfoTransformer = builder.dualCameraFaceInfoTransformer; + /* + * fr 线程队列大小 + */ + int frQueueSize = recognizeConfiguration.getMaxDetectFaces(); + if (builder.frQueueSize > 0) { + frQueueSize = builder.frQueueSize; + } else { + Log.e(TAG, "frThread num must > 0, now using default value:" + frQueueSize); + } + frThreadQueue = new LinkedBlockingQueue<>(frQueueSize); + frExecutor = new ThreadPoolExecutor(1, frQueueSize, 0, TimeUnit.MILLISECONDS, frThreadQueue, r -> { + Thread t = new Thread(r); + t.setName("frThread-" + t.getId()); + return t; + }); + + /* + * fl 线程队列大小 + */ + int flQueueSize = recognizeConfiguration.getMaxDetectFaces(); + if (builder.flQueueSize > 0) { + flQueueSize = builder.flQueueSize; + } else { + Log.e(TAG, "flThread num must > 0, now using default value:" + flQueueSize); + } + flThreadQueue = new LinkedBlockingQueue(flQueueSize); + flExecutor = new ThreadPoolExecutor(1, flQueueSize, 0, TimeUnit.MILLISECONDS, flThreadQueue, r -> { + Thread t = new Thread(r); + t.setName("flThread-" + t.getId()); + return t; + }); + if (previewSize == null) { + throw new RuntimeException("previewSize must be specified!"); + } + if (recognizeConfiguration.isEnableFaceSizeLimit()) { + // 由于目前人脸框的宽高接近一致,所以在使用时horizontalSize和verticalSize的值设置成一样 + faceRecognizeFilterList.add(new FaceSizeFilter(recognizeConfiguration.getFaceSizeLimit(), recognizeConfiguration.getFaceSizeLimit())); + } + if (recognizeConfiguration.isEnableFaceMoveLimit()) { + faceRecognizeFilterList.add(new FaceMoveFilter(recognizeConfiguration.getFaceMoveLimit())); + } + if (recognizeConfiguration.isEnableFaceAreaLimit()) { + faceRecognizeFilterList.add(new FaceRecognizeAreaFilter(recognizeArea)); + } + } + + /** + * 请求获取人脸特征数据 + * + * @param nv21 图像数据 + * @param facePreviewInfo 人脸信息 + * @param width 图像宽度 + * @param height 图像高度 + * @param format 图像格式 + */ + public void requestFaceFeature(byte[] nv21, FacePreviewInfo facePreviewInfo, int width, int height, int format) { + if (frEngine != null && frThreadQueue.remainingCapacity() > 0) { + frExecutor.execute(new FaceRecognizeRunnable(nv21, facePreviewInfo, width, height, format)); + } else { + onFaceFeatureInfoGet(null, facePreviewInfo.getTrackId(), ERROR_BUSY); + } + } + + /** + * 请求获取活体检测结果,需要传入活体的参数,以下参数同 + * + * @param nv21 NV21格式的图像数据 + * @param faceInfo 人脸信息 + * @param width 图像宽度 + * @param height 图像高度 + * @param format 图像格式 + * @param livenessType 活体检测类型 + * @param waitLock + */ + public void requestFaceLiveness(byte[] nv21, FacePreviewInfo faceInfo, int width, int height, int format, LivenessType livenessType, Object waitLock) { + if (flEngine != null && flThreadQueue.remainingCapacity() > 0) { + flExecutor.execute(new FaceLivenessDetectRunnable(nv21, faceInfo, width, height, format, livenessType, waitLock)); + } else { + onFaceLivenessInfoGet(null, faceInfo.getTrackId(), ERROR_BUSY); + } + + } + + /** + * 释放对象 + */ + public void release() { + if (getFeatureDelayedDisposables != null) { + getFeatureDelayedDisposables.clear(); + } + if (!frExecutor.isShutdown()) { + frExecutor.shutdownNow(); + frThreadQueue.clear(); + } + if (!flExecutor.isShutdown()) { + flExecutor.shutdownNow(); + flThreadQueue.clear(); + } + if (faceInfoList != null) { + faceInfoList.clear(); + } + if (frThreadQueue != null) { + frThreadQueue.clear(); + frThreadQueue = null; + } + if (flThreadQueue != null) { + flThreadQueue.clear(); + flThreadQueue = null; + } + faceInfoList = null; + stop(); + } + + public void stop() { + if (faceInfoList != null) { + faceInfoList.clear(); + } + if (maskInfoList != null) { + maskInfoList.clear(); + } + if (recognizeInfoMap != null) { + recognizeInfoMap.clear(); + } + if (facePreviewInfoList != null) { + facePreviewInfoList.clear(); + } + } + + /** + * 处理帧数据 + * + * @param rgbNv21 可见光相机预览回传的NV21数据 + * @param irNv21 红外相机预览回传的NV21数据 + * @param doRecognize 是否进行识别 + * @return 实时人脸处理结果,封装添加了一个trackId,trackId的获取依赖于faceId,用于记录人脸序号并保存 + */ + public List onPreviewFrame(@NonNull byte[] rgbNv21, @Nullable byte[] irNv21, boolean doRecognize) { + if (ftEngine != null) { + faceInfoList.clear(); + maskInfoList.clear(); + facePreviewInfoList.clear(); + int code = ftEngine.detectFaces(rgbNv21, previewSize.width, previewSize.height, FaceEngine.CP_PAF_NV21, faceInfoList); + if (code != ErrorInfo.MOK) { + onFail(new Exception("detectFaces failed,code is " + code)); + return facePreviewInfoList; + } + if (recognizeConfiguration.isKeepMaxFace()) { + keepMaxFace(faceInfoList); + } + refreshTrackId(faceInfoList); + if (faceInfoList.isEmpty()) { + return facePreviewInfoList; + } + if (!onlyDetectLiveness) { + code = maskEngine.process(rgbNv21, previewSize.width, previewSize.height, FaceEngine.CP_PAF_NV21, faceInfoList, + FaceEngine.ASF_MASK_DETECT); + if (code == ErrorInfo.MOK) { + code = maskEngine.getMask(maskInfoList); + if (code != ErrorInfo.MOK) { + onFail(new Exception("process getMask failed,code is " + code)); + return facePreviewInfoList; + } + } else { + onFail(new Exception("process mask failed,code is " + code)); + return facePreviewInfoList; + } + } + + for (int i = 0; i < faceInfoList.size(); i++) { + FacePreviewInfo facePreviewInfo = new FacePreviewInfo(faceInfoList.get(i), currentTrackIdList.get(i)); + if (!maskInfoList.isEmpty()) { + MaskInfo maskInfo = maskInfoList.get(i); + facePreviewInfo.setMask(maskInfo.getMask()); + } + if (rgbFaceRectTransformer != null && recognizeArea != null) { + Rect rect = rgbFaceRectTransformer.adjustRect(faceInfoList.get(i).getRect()); + Rect foreRect = rgbFaceRectTransformer.adjustRect(faceInfoList.get(i).getForeheadRect()); + facePreviewInfo.setRgbTransformedRect(rect); + facePreviewInfo.setForeRect(foreRect); + } + if (irFaceRectTransformer != null) { + FaceInfo faceInfo = faceInfoList.get(i); + if (dualCameraFaceInfoTransformer != null) { + faceInfo = dualCameraFaceInfoTransformer.transformFaceInfo(faceInfo); + } + facePreviewInfo.setFaceInfoIr(faceInfo); + facePreviewInfo.setIrTransformedRect(irFaceRectTransformer.adjustRect(faceInfo.getRect())); + } + facePreviewInfoList.add(facePreviewInfo); + } + clearLeftFace(facePreviewInfoList); + if (doRecognize) { + doRecognize(rgbNv21, irNv21, facePreviewInfoList); + } + } else { + facePreviewInfoList.clear(); + } + return facePreviewInfoList; + } + + public void setRgbFaceRectTransformer(FaceRectTransformer rgbFaceRectTransformer) { + this.rgbFaceRectTransformer = rgbFaceRectTransformer; + } + + public void setIrFaceRectTransformer(FaceRectTransformer irFaceRectTransformer) { + this.irFaceRectTransformer = irFaceRectTransformer; + } + + /** + * 删除已经离开的人脸 + * + * @param facePreviewInfoList 人脸和trackId列表 + */ + private void clearLeftFace(List facePreviewInfoList) { + if (facePreviewInfoList == null || facePreviewInfoList.size() == 0) { + if (getFeatureDelayedDisposables != null) { + getFeatureDelayedDisposables.clear(); + } + } + Enumeration keys = recognizeInfoMap.keys(); + while (keys.hasMoreElements()) { + int key = keys.nextElement(); + boolean contained = false; + for (FacePreviewInfo facePreviewInfo : facePreviewInfoList) { + if (facePreviewInfo.getTrackId() == key) { + contained = true; + break; + } + } + if (!contained) { + RecognizeInfo recognizeInfo = recognizeInfoMap.remove(key); + if (recognizeInfo != null) { + recognizeCallback.onNoticeChanged(""); + // 人脸离开时,通知特征提取线程,避免一直等待活体结果 + synchronized (recognizeInfo.getWaitLock()) { + recognizeInfo.getWaitLock().notifyAll(); + } + } + } + } + } + + private void doRecognize(byte[] rgbNv21, byte[] irNv21, List facePreviewInfoList) { + if (facePreviewInfoList != null && !facePreviewInfoList.isEmpty() && previewSize != null) { + for (FaceRecognizeFilter faceRecognizeFilter : faceRecognizeFilterList) { + faceRecognizeFilter.filter(facePreviewInfoList); + } + for (int i = 0; i < facePreviewInfoList.size(); i++) { + FacePreviewInfo facePreviewInfo = facePreviewInfoList.get(i); + if (!facePreviewInfo.isQualityPass()) { + continue; + } + //跳过mask值为MaskInfo.UNKNOWN的人脸 + if (!onlyDetectLiveness && facePreviewInfo.getMask() == MaskInfo.UNKNOWN) { + continue; + } + RecognizeInfo recognizeInfo = getRecognizeInfo(recognizeInfoMap, facePreviewInfo.getTrackId()); + int status = recognizeInfo.getRecognizeStatus(); + /* + * 在活体检测开启,在人脸识别状态不为成功或人脸活体状态不为处理中(ANALYZING)且不为处理完成(ALIVE、NOT_ALIVE)时重新进行活体检测 + */ + if (recognizeConfiguration.isEnableLiveness() && status != RequestFeatureStatus.SUCCEED) { + int liveness = recognizeInfo.getLiveness(); + if (liveness != LivenessInfo.ALIVE && liveness != LivenessInfo.NOT_ALIVE && liveness != RequestLivenessStatus.ANALYZING + || status == RequestFeatureStatus.FAILED) { + changeLiveness(facePreviewInfo.getTrackId(), RequestLivenessStatus.ANALYZING); + requestFaceLiveness( + irNv21 == null ? rgbNv21 : irNv21, + facePreviewInfo, + previewSize.width, + previewSize.height, + FaceEngine.CP_PAF_NV21, + irNv21 == null ? LivenessType.RGB : LivenessType.IR, + recognizeInfo.getWaitLock() + ); + } + } + /* + * 对于每个人脸,若状态为空或者为失败,则请求特征提取(可根据需要添加其他判断以限制特征提取次数), + * 特征提取回传的人脸特征结果在{@link FaceListener#onFaceFeatureInfoGet(FaceFeature, Integer, Integer)}中回传 + */ + if (status == RequestFeatureStatus.TO_RETRY) { + changeRecognizeStatus(facePreviewInfo.getTrackId(), RequestFeatureStatus.SEARCHING); + requestFaceFeature( + rgbNv21, facePreviewInfo, + previewSize.width, + previewSize.height, + FaceEngine.CP_PAF_NV21 + ); + } + } + } + } + + @Override + public void onFail(Exception e) { + Log.e(TAG, "onFail:" + e.getMessage()); + } + + /** + * 获取识别信息,识别信息为空则创建一个新的 + * + * @param recognizeInfoMap 存放识别信息的map + * @param trackId 人脸唯一标识 + * @return 识别信息 + */ + public RecognizeInfo getRecognizeInfo(Map recognizeInfoMap, int trackId) { + RecognizeInfo recognizeInfo = recognizeInfoMap.get(trackId); + if (recognizeInfo == null) { + recognizeInfo = new RecognizeInfo(); + recognizeInfoMap.put(trackId, recognizeInfo); + } + return recognizeInfo; + } + + @Override + public void onFaceFeatureInfoGet(@Nullable FaceFeature faceFeature, Integer trackId, Integer errorCode) { + //FR成功 + RecognizeInfo recognizeInfo = getRecognizeInfo(recognizeInfoMap, trackId); + if (faceFeature != null) { + // 人脸已离开,不用处理 + if (recognizeInfo == null) { + return; + } + //不做活体检测的情况,直接搜索 + if (!recognizeConfiguration.isEnableLiveness()) { + searchFace(faceFeature, trackId); + } + //活体检测通过,搜索特征 + else if (recognizeInfo.getLiveness() == LivenessInfo.ALIVE) { + searchFace(faceFeature, trackId); + } + //活体检测未出结果,或者非活体,等待 + else { + synchronized (recognizeInfo.getWaitLock()) { + try { + recognizeInfo.getWaitLock().wait(); + if (recognizeInfoMap.containsKey(trackId)) { + onFaceFeatureInfoGet(faceFeature, trackId, errorCode); + } + } catch (InterruptedException e) { + Log.e(TAG, "onFaceFeatureInfoGet: 等待活体结果时退出界面会执行,正常现象,可注释异常代码块"); + e.printStackTrace(); + } + } + } + + } + //特征提取失败时,为了及时提示做个UI反馈,将name修改为"ExtractCode:${errorCode}",再重置状态 + else { + if (recognizeInfo.increaseAndGetExtractErrorRetryCount() > recognizeConfiguration.getExtractRetryCount()) { + // 在尝试最大次数后,特征提取仍然失败,则认为识别未通过 + recognizeInfo.setExtractErrorRetryCount(0); + retryRecognizeDelayed(trackId); + } else { + changeRecognizeStatus(trackId, RequestFeatureStatus.TO_RETRY); + } + } + } + + /** + * 延迟 {@link RecognizeConfiguration#getLivenessFailedRetryInterval()}后,重新进行活体检测 + * + * @param trackId 人脸ID + */ + private void retryLivenessDetectDelayed(final Integer trackId) { + Observable.timer(recognizeConfiguration.getLivenessFailedRetryInterval(), TimeUnit.MILLISECONDS) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(new Observer() { + Disposable disposable; + + @Override + public void onSubscribe(Disposable d) { + disposable = d; + delayFaceTaskCompositeDisposable.add(disposable); + } + + @Override + public void onNext(Long aLong) { + + } + + @Override + public void onError(Throwable e) { + e.printStackTrace(); + } + + @Override + public void onComplete() { + // 将该人脸状态置为UNKNOWN,帧回调处理时会重新进行活体检测 + changeLiveness(trackId, LivenessInfo.UNKNOWN); + delayFaceTaskCompositeDisposable.remove(disposable); + } + }); + } + + /** + * 延迟 {@link RecognizeConfiguration#getRecognizeFailedRetryInterval()}后,重新进行人脸识别 + * + * @param trackId 人脸ID + */ + private void retryRecognizeDelayed(final Integer trackId) { + changeRecognizeStatus(trackId, RequestFeatureStatus.FAILED); + Observable.timer(recognizeConfiguration.getRecognizeFailedRetryInterval(), TimeUnit.MILLISECONDS) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(new Observer() { + Disposable disposable; + + @Override + public void onSubscribe(Disposable d) { + disposable = d; + delayFaceTaskCompositeDisposable.add(disposable); + } + + @Override + public void onNext(Long aLong) { + + } + + @Override + public void onError(Throwable e) { + e.printStackTrace(); + } + + @Override + public void onComplete() { + // 将该人脸特征提取状态置为FAILED,帧回调处理时会重新进行活体检测 + changeRecognizeStatus(trackId, RequestFeatureStatus.TO_RETRY); + delayFaceTaskCompositeDisposable.remove(disposable); + } + }); + } + + @Override + public void onFaceLivenessInfoGet(@Nullable LivenessInfo livenessInfo, Integer trackId, Integer errorCode) { + if (livenessInfo != null) { + int liveness = livenessInfo.getLiveness(); + Log.i(TAG, "onFaceLivenessInfoGet liveness:" + liveness); + changeLiveness(trackId, liveness); + // 非活体,重试 + if (liveness != LivenessInfo.ALIVE) { + noticeCurrentStatus("活体检测未通过"); + // 延迟 FAIL_RETRY_INTERVAL 后,将该人脸状态置为UNKNOWN,帧回调处理时会重新进行活体检测 + retryLivenessDetectDelayed(trackId); + } + } else { + RecognizeInfo recognizeInfo = getRecognizeInfo(recognizeInfoMap, trackId); + // 连续多次活体检测失败(接口调用回传值非0),将活体检测值重置为未知,会在帧回调中重新进行活体检测 + if (recognizeInfo.increaseAndGetLivenessErrorRetryCount() > recognizeConfiguration.getLivenessRetryCount()) { + recognizeInfo.setLivenessErrorRetryCount(0); + retryLivenessDetectDelayed(trackId); + } else { + changeLiveness(trackId, LivenessInfo.UNKNOWN); + } + } + } + + private void noticeCurrentStatus(String notice) { + if (recognizeCallback != null) { + recognizeCallback.onNoticeChanged(notice); + } + if (timerDisposable != null && !timerDisposable.isDisposed()) { + timerDisposable.dispose(); + } + timerDisposable = Observable.timer(1500, TimeUnit.MILLISECONDS) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(aLong -> { + if (recognizeCallback != null) { + recognizeCallback.onNoticeChanged(""); + } + }); + } + + private void searchFace(final FaceFeature faceFeature, final Integer trackId) { + CompareResult compareResult = FaceServer.getInstance().searchFaceFeature(faceFeature); + if (compareResult == null || compareResult.getFaceEntity() == null) { + retryRecognizeDelayed(trackId); + return; + } + compareResult.setTrackId(trackId); + boolean pass = compareResult.getSimilar() > recognizeConfiguration.getSimilarThreshold(); + recognizeCallback.onRecognized(compareResult, getRecognizeInfo(recognizeInfoMap, trackId).getLiveness(), pass); + if (pass) { + String msg = "识别通过"; + Log.i(TAG, "用户名字:" + compareResult.getFaceEntity().getUserName() + ";人脸id:" + compareResult.getFaceEntity().getFaceId() + ";"); + if (Objects.nonNull(compareResult.getFaceEntity())) { + FaceEntity face = compareResult.getFaceEntity(); + if (Objects.nonNull(face.getUserName()) && Objects.nonNull(face.getFaceId())) { + DecimalFormat df = new DecimalFormat("0.00"); + String formattedNumber = df.format(compareResult.getSimilar() * 100); + msg = face.getUserName() + "相似度" + formattedNumber + "%"; + } + } + setName(trackId, msg); + noticeCurrentStatus(msg); + changeRecognizeStatus(trackId, RequestFeatureStatus.SUCCEED); + } else { + noticeCurrentStatus("未通过:NOT_REGISTERED"); + retryRecognizeDelayed(trackId); + } + } + + /** + * 人脸特征提取线程 + */ + public class FaceRecognizeRunnable implements Runnable { + private FaceInfo faceInfo; + private int width; + private int height; + private int format; + private Integer trackId; + private byte[] nv21Data; + private int isMask; + + /** + * 异步特征提取任务的构造函数 + * + * @param nv21Data 可见光图像数据 + * @param facePreviewInfo 人脸信息 + * @param width 图像宽度 + * @param height 图像高度 + * @param format 图像格式 + */ + private FaceRecognizeRunnable(byte[] nv21Data, FacePreviewInfo facePreviewInfo, int width, int height, int format) { + if (nv21Data == null) { + return; + } + this.nv21Data = nv21Data; + this.faceInfo = new FaceInfo(facePreviewInfo.getFaceInfoRgb()); + this.width = width; + this.height = height; + this.format = format; + this.trackId = facePreviewInfo.getTrackId(); + this.isMask = facePreviewInfo.getMask(); + } + + @Override + public void run() { + if (nv21Data != null) { + if (frEngine != null) { + if (recognizeConfiguration.isEnableImageQuality()) { + /* + * 开启人脸质量检测 + */ + ImageQualitySimilar qualitySimilar = new ImageQualitySimilar(); + int iqCode; + long iqStartTime = System.currentTimeMillis(); + synchronized (frEngine) { + iqCode = frEngine.imageQualityDetect(nv21Data, width, height, format, faceInfo, isMask, qualitySimilar); + } + Log.i(TAG, "fr iqTime:" + (System.currentTimeMillis() - iqStartTime) + "ms" + ", mask=" + isMask); + if (iqCode == ErrorInfo.MOK) { + float quality = qualitySimilar.getScore(); + float destQuality = isMask == MaskInfo.WORN ? recognizeConfiguration.getImageQualityMaskRecognizeThreshold() : + recognizeConfiguration.getImageQualityNoMaskRecognizeThreshold(); + if (quality >= destQuality) { + extractFace(); + } else { + onFaceFail(iqCode, "fr imageQualityDetect score invalid"); + } + } else { + onFaceFail(iqCode, "fr imageQuality failed errorCode is " + iqCode); + } + } else { + extractFace(); + } + } else { + onFaceFail(ERROR_FR_ENGINE_IS_NULL, "fr failed ,frEngine is null"); + } + } + nv21Data = null; + } + + /** + * 对人脸图像进行特征提取 + */ + private void extractFace() { + long irStartTime = System.currentTimeMillis(); + FaceFeature faceFeature = new FaceFeature(); + int frCode; + synchronized (frEngine) { + /* + * 该场景为识别场景,所以参数“ExtractType”值为ExtractType.RECOGNIZE,且参数“mask”值为实际检测到的值,即isMask + */ + frCode = frEngine.extractFaceFeature(nv21Data, width, height, format, faceInfo, ExtractType.RECOGNIZE, isMask, faceFeature); + } + Log.i(TAG, "frTime:" + (System.currentTimeMillis() - irStartTime) + "ms"); + if (frCode == ErrorInfo.MOK) { + onFaceFeatureInfoGet(faceFeature, trackId, frCode); + } else { + onFaceFail(frCode, "fr failed errorCode is " + frCode); + } + } + + private void onFaceFail(int code, String errorMsg) { + onFaceFeatureInfoGet(null, trackId, code); + onFail(new Exception(errorMsg)); + } + } + + /** + * 活体检测的线程 + */ + public class FaceLivenessDetectRunnable implements Runnable { + private FaceInfo faceInfo; + private int width; + private int height; + private int format; + private Integer trackId; + private byte[] nv21Data; + private LivenessType livenessType; + private Object waitLock; + + /** + * 异步活体任务的构造函数 + * + * @param nv21Data 可见光或红外图像数据 + * @param faceInfo 可见光人脸检测得到的人脸信息 + * @param width 图像宽度 + * @param height 图像高度 + * @param format 图像格式 + * @param livenessType 活体检测类型,可以是可见光活体检测{@link LivenessType#RGB}或红外活体检测{@link LivenessType#IR} + * @param waitLock 活体检测通过后,调用该对象的notifyAll函数,通知识别线程活体已通过 + */ + private FaceLivenessDetectRunnable(byte[] nv21Data, FacePreviewInfo faceInfo, int width, int height, int format, LivenessType livenessType, Object waitLock) { + if (nv21Data == null) { + return; + } + this.nv21Data = nv21Data; + this.faceInfo = new FaceInfo(faceInfo.getFaceInfoRgb()); + this.width = width; + this.height = height; + this.format = format; + this.trackId = faceInfo.getTrackId(); + this.livenessType = livenessType; + this.waitLock = waitLock; + } + + @Override + public void run() { + if (nv21Data != null) { + if (flEngine != null) { + processLiveness(); + } else { + onProcessFail(ERROR_FL_ENGINE_IS_NULL, "fl failed ,frEngine is null"); + } + } + nv21Data = null; + } + + /** + * 执行活体检测 + */ + private void processLiveness() { + List livenessInfoList = new ArrayList<>(); + int flCode = -1; + synchronized (flEngine) { + long flStartTime = System.currentTimeMillis(); + if (livenessType == LivenessType.RGB) { + // RGB活体检测 + flCode = flEngine.process(nv21Data, width, height, format, Arrays.asList(faceInfo), FaceEngine.ASF_LIVENESS); + } else { + // IR活体检测 + FaceInfo rgbFaceInfo = faceInfo; + List irFaceInfoList = new ArrayList<>(); + int fdCode = flEngine.detectFaces(nv21Data, width, height, format, irFaceInfoList); + boolean isFaceOverlap = false; + if (fdCode == ErrorInfo.MOK && irFaceInfoList.size() > 0) { + keepMaxFace(irFaceInfoList); + isFaceOverlap = isFaceSame(rgbFaceInfo, irFaceInfoList.get(0), 0.3); + Log.i(TAG, "isFaceOverlap:" + isFaceOverlap); + } + if (fdCode == ErrorInfo.MOK && isFaceOverlap) { + flCode = flEngine.processIr(nv21Data, width, height, format, irFaceInfoList, FaceEngine.ASF_IR_LIVENESS); + } else { + onFail(new Exception("ir detectFaces failed fdCode:" + fdCode + ", flCode:" + flCode + ",isFaceOverlap:" + isFaceOverlap)); + } + } + Log.i(TAG, "flTime:" + (System.currentTimeMillis() - flStartTime) + "ms"); + } + if (flCode == ErrorInfo.MOK) { + if (livenessType == LivenessType.RGB) { + flCode = flEngine.getLiveness(livenessInfoList); + } else { + flCode = flEngine.getIrLiveness(livenessInfoList); + } + } + + if (flCode == ErrorInfo.MOK && !livenessInfoList.isEmpty()) { + onFaceLivenessInfoGet(livenessInfoList.get(0), trackId, flCode); + if (livenessInfoList.get(0).getLiveness() == LivenessInfo.ALIVE) { + synchronized (waitLock) { + waitLock.notifyAll(); + } + } + } else { + onProcessFail(flCode, "fl failed errorCode is " + flCode); + } + } + + private void onProcessFail(int code, String msg) { + onFaceLivenessInfoGet(null, trackId, code); + onFail(new Exception(msg)); + } + } + + /** + * 判断IR图片中的人脸FaceInfo和RGB图片中的人脸FaceInfo是否是同一个人脸 + * 此方案原理:判断两个FaceInfo是否有相交区域,如果有相交区域,则说明是同一个人脸 + * + * @param faceInfoList 人脸信息列表 + * @param faceInfo 人脸信息 + * @return 人脸信息列表中是否有人脸和传入的人脸信息相交 + */ + public static boolean isSameFace(List faceInfoList, FaceInfo faceInfo) { + if (faceInfoList == null || faceInfoList.isEmpty() || faceInfo == null) { + return false; + } + for (FaceInfo info : faceInfoList) { + if (Rect.intersects(faceInfo.getRect(), info.getRect())) { + return true; + } + } + return false; + } + + /** + * 计算两个人脸框的重叠面积,以判断它们的重叠程度 + * + * @param rect1 人脸框 + * @param rect2 人脸框 + * @return + */ + + public static Rect getOverlapArea(Rect rect1, Rect rect2) { + // 计算两个矩形的重叠区域 + int left = Math.max(rect1.left, rect2.left); + int right = Math.min(rect1.right, rect2.right); + int top = Math.max(rect1.top, rect2.top); + int bottom = Math.min(rect1.bottom, rect2.bottom); + + // 如果没有重叠区域,则返回空的矩形 + if (left < right && top < bottom) { + return new Rect(left, top, right, bottom); + } + return null; // 无重叠 + } + + /** + * 设置重叠阈值,当两个矩形的重叠面积超过这个阈值时,认为是同一张脸 + * + * @param rgbFaceInfo rgb人脸信息 + * @param irFaceInfo ir人脸信息 + * @param threshold 重叠阈值 + * @return Boolean + */ + + public static boolean isFaceSame(FaceInfo rgbFaceInfo, FaceInfo irFaceInfo, double threshold) { + Rect rect1 = rgbFaceInfo.getRect(); + Rect rect2 = irFaceInfo.getRect(); + Rect overlapRect = getOverlapArea(rect1, rect2); + if (overlapRect != null) { + int overlapArea = overlapRect.width() * overlapRect.height(); + int face1Area = rect1.width() * rect1.height(); + int face2Area = rect2.width() * rect2.height(); + // 计算重叠面积占两个矩形面积的比例 + double overlapRatio = (double) overlapArea / Math.min(face1Area, face2Area); + Log.d(TAG, "overlapRatio:" + overlapRatio + "," + threshold); + + return overlapRatio > threshold; + } + return false; + } + + /** + * 刷新trackId + * + * @param ftFaceList 传入的人脸列表 + */ + private void refreshTrackId(List ftFaceList) { + currentTrackIdList.clear(); + for (FaceInfo faceInfo : ftFaceList) { + currentTrackIdList.add(faceInfo.getFaceId() + trackedFaceCount); + } + if (!ftFaceList.isEmpty()) { + currentMaxFaceId = ftFaceList.get(ftFaceList.size() - 1).getFaceId(); + } + } + + /** + * 获取当前的最大trackID,可用于退出时保存 + * + * @return 当前trackId + */ + public int getTrackedFaceCount() { + // 引擎的人脸下标从0开始,因此需要+1 + return trackedFaceCount + currentMaxFaceId + 1; + } + + /** + * 新增搜索成功的人脸 + * + * @param trackId 指定的trackId + * @param name trackId对应的人脸 + */ + public void setName(int trackId, String name) { + RecognizeInfo recognizeInfo = recognizeInfoMap.get(trackId); + if (recognizeInfo != null) { + recognizeInfo.setName(name); + } + } + + + /** + * 设置转换方式,用于IR活体检测 + * + * @param transformer 转换方式 + */ + public void setDualCameraFaceInfoTransformer(IDualCameraFaceInfoTransformer transformer) { + this.dualCameraFaceInfoTransformer = transformer; + } + + + public String getName(int trackId) { + RecognizeInfo recognizeInfo = recognizeInfoMap.get(trackId); + return recognizeInfo == null ? null : recognizeInfo.getName(); + } + + + /** + * 设置可识别区域(相对于View) + * + * @param recognizeArea 可识别区域 + */ + public void setRecognizeArea(Rect recognizeArea) { + if (recognizeArea != null) { + this.recognizeArea.set(recognizeArea); + } + } + + @IntDef(value = { + RequestFeatureStatus.FAILED, + RequestFeatureStatus.SEARCHING, + RequestFeatureStatus.SUCCEED, + RequestFeatureStatus.TO_RETRY + }) + @Retention(RetentionPolicy.SOURCE) + @interface RequestFaceFeatureStatus { + } + + @IntDef(value = { + LivenessInfo.ALIVE, + LivenessInfo.NOT_ALIVE, + LivenessInfo.UNKNOWN, + LivenessInfo.FACE_NUM_MORE_THAN_ONE, + LivenessInfo.FACE_TOO_SMALL, + LivenessInfo.FACE_ANGLE_TOO_LARGE, + LivenessInfo.FACE_BEYOND_BOUNDARY, + RequestLivenessStatus.ANALYZING + }) + @Retention(RetentionPolicy.SOURCE) + @interface RequestFaceLivenessStatus { + } + + /** + * 修改人脸识别的状态 + * + * @param trackId 根据VIDEO模式人脸检测获取的人脸的唯一标识 + * @param newStatus 新的识别状态,详见{@link RequestFeatureStatus}中的定义 + */ + public void changeRecognizeStatus(int trackId, @RequestFaceFeatureStatus int newStatus) { + getRecognizeInfo(recognizeInfoMap, trackId).setRecognizeStatus(newStatus); + } + + /** + * 修改活体活体值或活体检测状态 + * + * @param trackId 根据VIDEO模式人脸检测获取的人脸的唯一标识 + * @param newLiveness 新的活体值或活体检测状态 + */ + public void changeLiveness(int trackId, @RequestFaceLivenessStatus int newLiveness) { + getRecognizeInfo(recognizeInfoMap, trackId).setLiveness(newLiveness); + } + + /** + * 获取活体值或活体检测状态 + * + * @param trackId 根据VIDEO模式人脸检测获取的人脸的唯一标识 + * @return 活体值或活体检测状态 + */ + public Integer getLiveness(int trackId) { + return getRecognizeInfo(recognizeInfoMap, trackId).getLiveness(); + } + + /** + * 获取人脸识别状态 + * + * @param trackId 根据VIDEO模式人脸检测获取的人脸的唯一标识 + * @return 人脸识别状态 + */ + public Integer getRecognizeStatus(int trackId) { + return getRecognizeInfo(recognizeInfoMap, trackId).getRecognizeStatus(); + } + + /** + * 保留ftFaceList中最大的人脸 + * + * @param ftFaceList 人脸追踪时,一帧数据的人脸信息 + */ + private static void keepMaxFace(List ftFaceList) { + if (ftFaceList == null || ftFaceList.size() <= 1) { + return; + } + FaceInfo maxFaceInfo = ftFaceList.get(0); + for (FaceInfo faceInfo : ftFaceList) { + if (faceInfo.getRect().width() > maxFaceInfo.getRect().width()) { + maxFaceInfo = faceInfo; + } + } + ftFaceList.clear(); + ftFaceList.add(maxFaceInfo); + } + + + public static final class Builder { + private FaceEngine ftEngine; + private FaceEngine maskEngine; + private FaceEngine frEngine; + private FaceEngine flEngine; + private Camera.Size previewSize; + private boolean onlyDetectLiveness; + private RecognizeConfiguration recognizeConfiguration; + private RecognizeCallback recognizeCallback; + private IDualCameraFaceInfoTransformer dualCameraFaceInfoTransformer; + private int frQueueSize; + private int flQueueSize; + private int trackedFaceCount; + + public Builder() { + } + + public Builder recognizeConfiguration(RecognizeConfiguration val) { + recognizeConfiguration = val; + return this; + } + + public Builder dualCameraFaceInfoTransformer(IDualCameraFaceInfoTransformer val) { + dualCameraFaceInfoTransformer = val; + return this; + } + + public Builder recognizeCallback(RecognizeCallback val) { + recognizeCallback = val; + return this; + } + + public Builder ftEngine(FaceEngine val) { + ftEngine = val; + return this; + } + + public Builder maskEngine(FaceEngine val) { + maskEngine = val; + return this; + } + + public Builder frEngine(FaceEngine val) { + frEngine = val; + return this; + } + + public Builder flEngine(FaceEngine val) { + flEngine = val; + return this; + } + + public Builder previewSize(Camera.Size val) { + previewSize = val; + return this; + } + + public Builder frQueueSize(int val) { + frQueueSize = val; + return this; + } + + public Builder flQueueSize(int val) { + flQueueSize = val; + return this; + } + + public Builder trackedFaceCount(int val) { + trackedFaceCount = val; + return this; + } + + public Builder onlyDetectLiveness(boolean val) { + onlyDetectLiveness = val; + return this; + } + + public FaceHelper build() { + return new FaceHelper(this); + } + } +} diff --git a/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/help/FaceRectTransformer.java b/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/help/FaceRectTransformer.java new file mode 100644 index 0000000..55bf32d --- /dev/null +++ b/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/help/FaceRectTransformer.java @@ -0,0 +1,222 @@ +package io.flutter.plugins.facediscern.tool.face.help; + +import android.graphics.Rect; +import android.hardware.Camera; + +/** + * 将检测回传的人脸框(基于NV21数据)转换为View绘制(基于View)所需的人脸框 + */ +public class FaceRectTransformer { + private int previewWidth, previewHeight, canvasWidth, canvasHeight, cameraDisplayOrientation, cameraId; + private boolean isMirror; + private boolean mirrorHorizontal = false, mirrorVertical = false; + + /** + * 创建一个绘制辅助类对象,并且设置绘制相关的参数 + * + * @param previewWidth 预览宽度 + * @param previewHeight 预览高度 + * @param canvasWidth 绘制控件的宽度 + * @param canvasHeight 绘制控件的高度 + * @param cameraDisplayOrientation 旋转角度 + * @param cameraId 相机ID + * @param isMirror 是否水平镜像显示(若相机是镜像显示的,设为true,用于纠正) + * @param mirrorHorizontal 为兼容部分设备使用,水平再次镜像 + * @param mirrorVertical 为兼容部分设备使用,垂直再次镜像 + */ + public FaceRectTransformer(int previewWidth, int previewHeight, int canvasWidth, + int canvasHeight, int cameraDisplayOrientation, int cameraId, + boolean isMirror, boolean mirrorHorizontal, boolean mirrorVertical) { + this.previewWidth = previewWidth; + this.previewHeight = previewHeight; + this.canvasWidth = canvasWidth; + this.canvasHeight = canvasHeight; + this.cameraDisplayOrientation = cameraDisplayOrientation; + this.cameraId = cameraId; + this.isMirror = isMirror; + this.mirrorHorizontal = mirrorHorizontal; + this.mirrorVertical = mirrorVertical; + } + + /** + * 调整人脸框用来绘制 + * + * @param ftRect FT人脸框 + * @return 调整后的需要被绘制到View上的rect + */ + public Rect adjustRect(Rect ftRect) { + int previewWidth = this.previewWidth; + int previewHeight = this.previewHeight; + int canvasWidth = this.canvasWidth; + int canvasHeight = this.canvasHeight; + int cameraDisplayOrientation = this.cameraDisplayOrientation; + int cameraId = this.cameraId; + boolean isMirror = this.isMirror; + boolean mirrorHorizontal = this.mirrorHorizontal; + boolean mirrorVertical = this.mirrorVertical; + + if (ftRect == null) { + return null; + } + + Rect rect = new Rect(ftRect); + float horizontalRatio; + float verticalRatio; + if (cameraDisplayOrientation % 180 == 0) { + horizontalRatio = (float) canvasWidth / (float) previewWidth; + verticalRatio = (float) canvasHeight / (float) previewHeight; + } else { + horizontalRatio = (float) canvasHeight / (float) previewWidth; + verticalRatio = (float) canvasWidth / (float) previewHeight; + } + rect.left *= horizontalRatio; + rect.right *= horizontalRatio; + rect.top *= verticalRatio; + rect.bottom *= verticalRatio; + + Rect newRect = new Rect(); + switch (cameraDisplayOrientation) { + case 0: + if (cameraId == Camera.CameraInfo.CAMERA_FACING_FRONT) { + newRect.left = canvasWidth - rect.right; + newRect.right = canvasWidth - rect.left; + } else { + newRect.left = rect.left; + newRect.right = rect.right; + } + newRect.top = rect.top; + newRect.bottom = rect.bottom; + break; + case 90: + newRect.right = canvasWidth - rect.top; + newRect.left = canvasWidth - rect.bottom; + if (cameraId == Camera.CameraInfo.CAMERA_FACING_FRONT) { + newRect.top = canvasHeight - rect.right; + newRect.bottom = canvasHeight - rect.left; + } else { + newRect.top = rect.left; + newRect.bottom = rect.right; + } + break; + case 180: + newRect.top = canvasHeight - rect.bottom; + newRect.bottom = canvasHeight - rect.top; + if (cameraId == Camera.CameraInfo.CAMERA_FACING_FRONT) { + newRect.left = rect.left; + newRect.right = rect.right; + } else { + newRect.left = canvasWidth - rect.right; + newRect.right = canvasWidth - rect.left; + } + break; + case 270: + newRect.left = rect.top; + newRect.right = rect.bottom; + if (cameraId == Camera.CameraInfo.CAMERA_FACING_FRONT) { + newRect.top = rect.left; + newRect.bottom = rect.right; + } else { + newRect.top = canvasHeight - rect.right; + newRect.bottom = canvasHeight - rect.left; + } + break; + default: + break; + } + + /** + * isMirror mirrorHorizontal finalIsMirrorHorizontal + * true true false + * false false false + * true false true + * false true true + * + * XOR + */ + if (isMirror ^ mirrorHorizontal) { + int left = newRect.left; + int right = newRect.right; + newRect.left = canvasWidth - right; + newRect.right = canvasWidth - left; + } + if (mirrorVertical) { + int top = newRect.top; + int bottom = newRect.bottom; + newRect.top = canvasHeight - bottom; + newRect.bottom = canvasHeight - top; + } + return newRect; + } + + public void setPreviewWidth(int previewWidth) { + this.previewWidth = previewWidth; + } + + public void setPreviewHeight(int previewHeight) { + this.previewHeight = previewHeight; + } + + public void setCanvasWidth(int canvasWidth) { + this.canvasWidth = canvasWidth; + } + + public void setCanvasHeight(int canvasHeight) { + this.canvasHeight = canvasHeight; + } + + public void setCameraDisplayOrientation(int cameraDisplayOrientation) { + this.cameraDisplayOrientation = cameraDisplayOrientation; + } + + public void setCameraId(int cameraId) { + this.cameraId = cameraId; + } + + public void setMirror(boolean mirror) { + isMirror = mirror; + } + + public int getPreviewWidth() { + return previewWidth; + } + + public int getPreviewHeight() { + return previewHeight; + } + + public int getCanvasWidth() { + return canvasWidth; + } + + public int getCanvasHeight() { + return canvasHeight; + } + + public int getCameraDisplayOrientation() { + return cameraDisplayOrientation; + } + + public int getCameraId() { + return cameraId; + } + + public boolean isMirror() { + return isMirror; + } + + public boolean isMirrorHorizontal() { + return mirrorHorizontal; + } + + public void setMirrorHorizontal(boolean mirrorHorizontal) { + this.mirrorHorizontal = mirrorHorizontal; + } + + public boolean isMirrorVertical() { + return mirrorVertical; + } + + public void setMirrorVertical(boolean mirrorVertical) { + this.mirrorVertical = mirrorVertical; + } +} diff --git a/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/help/callback/FaceListener.java b/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/help/callback/FaceListener.java new file mode 100644 index 0000000..56d3cd6 --- /dev/null +++ b/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/help/callback/FaceListener.java @@ -0,0 +1,37 @@ +package io.flutter.plugins.facediscern.tool.face.help.callback; + +import androidx.annotation.Nullable; + +import com.arcsoft.face.FaceFeature; +import com.arcsoft.face.LivenessInfo; + +/** + * 人脸处理回调 + */ +public interface FaceListener { + /** + * 当出现异常时执行 + * + * @param e 异常信息 + */ + void onFail(Exception e); + + + /** + * 请求人脸特征后的回调 + * + * @param faceFeature 人脸特征数据 + * @param trackId 人脸Id(相当于请求码) + * @param errorCode 错误码 + */ + void onFaceFeatureInfoGet(@Nullable FaceFeature faceFeature, Integer trackId, Integer errorCode); + + /** + * 请求活体检测后的回调 + * + * @param livenessInfo 活体检测结果 + * @param trackId 人脸Id(相当于请求码) + * @param errorCode 错误码 + */ + void onFaceLivenessInfoGet(@Nullable LivenessInfo livenessInfo, Integer trackId, Integer errorCode); +} diff --git a/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/help/callback/IDualCameraFaceInfoTransformer.java b/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/help/callback/IDualCameraFaceInfoTransformer.java new file mode 100644 index 0000000..6a98cae --- /dev/null +++ b/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/help/callback/IDualCameraFaceInfoTransformer.java @@ -0,0 +1,16 @@ +package io.flutter.plugins.facediscern.tool.face.help.callback; + +import com.arcsoft.face.FaceInfo; + +/** + * 设置双目识别时,将RGB Camera帧数据检测到的人脸信息用于IR Camera帧数据活体检测时的转换方式 + */ +public interface IDualCameraFaceInfoTransformer { + /** + * 将RGB Camera帧数据检测到的人脸信息用于IR Camera帧数据活体检测时的转换方式 + * + * @param faceInfo RGB Camera帧数据检测到的人脸信息 + * @return 转换后,用于IR活体检测的FaceInfo + */ + FaceInfo transformFaceInfo(FaceInfo faceInfo); +} diff --git a/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/help/callback/RecognizeCallback.java b/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/help/callback/RecognizeCallback.java new file mode 100644 index 0000000..ebda709 --- /dev/null +++ b/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/help/callback/RecognizeCallback.java @@ -0,0 +1,20 @@ +package io.flutter.plugins.facediscern.tool.face.help.callback; + + +import io.flutter.plugins.facediscern.tool.face.model.CompareResult; + +public interface RecognizeCallback { + /** + * 识别结果回调 + * + * @param compareResult 比对结果 + * @param liveness 活体值 + * @param similarPass 是否通过(依据设置的阈值) + */ + void onRecognized(CompareResult compareResult, Integer liveness, boolean similarPass); + + /** + * 提示文字变更的回调 + */ + void onNoticeChanged(String notice); +} diff --git a/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/image/ImageUtil.java b/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/image/ImageUtil.java new file mode 100644 index 0000000..8ac8a88 --- /dev/null +++ b/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/image/ImageUtil.java @@ -0,0 +1,229 @@ +package io.flutter.plugins.facediscern.tool.face.image; + +import android.content.ContentResolver; +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.Rect; +import android.net.Uri; + +import java.io.IOException; +import java.io.InputStream; + +public class ImageUtil { + public static final int DEFAULT_MAX_WIDTH = 1920; + public static final int DEFAULT_MAX_HEIGHT = 1080; + + + private static final int MASK_A = 0xFF000000; + private static final int MASK_R = 0x00FF0000; + private static final int MASK_G = 0x0000FF00; + private static final int MASK_B = 0x000000FF; + + public static int rgbToY(int r, int g, int b) { + return (((66 * r + 129 * g + 25 * b + 128) >> 8) + 16); + } + + public static int rgbToU(int r, int g, int b) { + return (((-38 * r - 74 * g + 112 * b + 128) >> 8) + 128); + } + + public static int rgbToV(int r, int g, int b) { + return (((112 * r - 94 * g - 18 * b + 128) >> 8) + 128); + } + + public static void drawRectOnNv21(byte[] nv21, int width, int height, int color, int strokeWidth, Rect rect) { + if (rect == null || rect.isEmpty()) { + return; + } + drawRectOnNv21(nv21, width, height, color, strokeWidth, rect.left, rect.top, rect.right, rect.bottom); + } + + public static void drawRectOnNv21(byte[] nv21, int width, int height, int color, int strokeWidth, int left, int top, + int right, int bottom) { + if ((strokeWidth & 1) == 1) { + strokeWidth += 1; + } + // 确保边界是4的倍数 + left &= ~0b11; + top &= ~0b11; + right &= ~0b11; + bottom &= ~0b11; + // 对于溢出图像的边,不绘制 + boolean drawLeft = true, drawTop = true, drawRight = true, drawBottom = true; + if (left <= 0) { + left = 0; + drawLeft = false; + } + if (top <= 0) { + top = 0; + drawTop = false; + } + if (right >= width) { + right = width; + drawRight = false; + } + if (bottom >= height) { + bottom = height; + drawBottom = false; + } + + // 取出R G B的值,并转换为Y U V + int r = (color & MASK_R) >> 16; + int g = (color & MASK_G) >> 8; + int b = color & MASK_B; + int y = rgbToY(r, g, b); + int u = rgbToU(r, g, b); + int v = rgbToV(r, g, b); + + // 根据边框的strokeWidth确定内边界 + int innerTop = top + strokeWidth; + int innerBottom = bottom - strokeWidth; + int innerRight = right - strokeWidth; + + int horizontalPixels = right - left; + int yStartIndex; + int uvStartIndex; + boolean drawUV; + if (drawTop) { + yStartIndex = top * width + left; + uvStartIndex = width * height + ((top / 2 * width) + left); + drawUV = false; + for (int i = top; i < innerTop; i++) { + for (int j = 0; j < horizontalPixels; j++) { + nv21[yStartIndex + j] = (byte) y; + } + yStartIndex += width; + if (drawUV = !drawUV) { + for (int j = 0; j < horizontalPixels; j += 2) { + nv21[uvStartIndex + j] = (byte) v; + nv21[uvStartIndex + j + 1] = (byte) u; + } + uvStartIndex += width; + } + } + } + + if (drawLeft) { + //左边 + yStartIndex = innerTop * width + left; + uvStartIndex = width * height + (innerTop / 2 * width + left); + drawUV = false; + for (int i = innerTop; i < innerBottom; i++) { + for (int j = 0; j < strokeWidth; j++) { + nv21[yStartIndex + j] = (byte) y; + } + yStartIndex += width; + if (drawUV = !drawUV) { + for (int j = 0; j < strokeWidth; j += 2) { + nv21[uvStartIndex + j] = (byte) v; + nv21[uvStartIndex + j + 1] = (byte) u; + } + uvStartIndex += width; + } + } + } + if (drawRight) { + //右边 + yStartIndex = innerTop * width + innerRight; + uvStartIndex = width * height + (innerTop / 2 * width + innerRight); + drawUV = false; + for (int i = innerTop; i < innerBottom; i++) { + for (int j = 0; j < strokeWidth; j++) { + nv21[yStartIndex + j] = (byte) y; + } + yStartIndex += width; + if (drawUV = !drawUV) { + for (int j = 0; j < strokeWidth; j += 2) { + nv21[uvStartIndex + j] = (byte) v; + nv21[uvStartIndex + j + 1] = (byte) u; + } + uvStartIndex += width; + } + } + } + + if (drawBottom) { + //下边 + yStartIndex = innerBottom * width + left; + uvStartIndex = width * height + ((innerBottom / 2 * width) + left); + drawUV = false; + for (int i = innerBottom; i < bottom; i++) { + for (int j = 0; j < horizontalPixels; j++) { + nv21[yStartIndex + j] = (byte) y; + } + yStartIndex += width; + if (drawUV = !drawUV) { + for (int j = 0; j < horizontalPixels; j += 2) { + nv21[uvStartIndex + j] = (byte) v; + nv21[uvStartIndex + j + 1] = (byte) u; + } + uvStartIndex += width; + } + } + } + } + + /** + * 缩放图像,如果需要缩放,就顺便把宽高对齐给做了 + * + * @param bitmap 原图 + * @param maxWidth 最大目标宽度 + * @param maxHeight 最大目标高度 + * @return 缩放后的图像 + */ + public static Bitmap scaleBitmap(Bitmap bitmap, int maxWidth, int maxHeight) { + float horizontalScale = ((float) bitmap.getWidth()) / maxWidth; + float verticalScale = ((float) bitmap.getHeight()) / maxHeight; + if (horizontalScale < 1 || verticalScale < 1) { + return bitmap; + } + float maxScale = Math.max(horizontalScale, verticalScale); + // 确保为4的倍数 + int newWidth = (int) (bitmap.getWidth() / maxScale) & ~0b11; + int newHeight = (int) (bitmap.getHeight() / maxScale) & ~0b11; + + return Bitmap.createScaledBitmap(bitmap, newWidth, newHeight, true); + } + + /** + * 将Uri转换为Bitmap,并限制最大宽高 + */ + public static Bitmap uriToScaledBitmap(Context context, Uri uri, int maxWidth, int maxHeight) { + ContentResolver contentResolver = context.getContentResolver(); + byte[] data; + try { + InputStream input = null; + input = contentResolver.openInputStream(uri); + data = new byte[input.available()]; + input.read(data); + input.close(); + } catch (IOException e) { + e.printStackTrace(); + return null; + } + return jpegToScaledBitmap(data, maxWidth, maxHeight); + } + + /** + * 将jpeg形式的压缩图像转换为Bitmap,并限制最大宽高 + * + * @param jpeg jpeg图像数据 + * @param maxWidth 限制的最大宽度 + * @param maxHeight 限制的最大高度 + * @return 宽高小于限制值的Bitmap对象 + */ + public static Bitmap jpegToScaledBitmap(byte[] jpeg, int maxWidth, int maxHeight) { + BitmapFactory.Options options = new BitmapFactory.Options(); + options.inJustDecodeBounds = true; + BitmapFactory.decodeByteArray(jpeg, 0, jpeg.length, options); + + int inSampleSize = 1; + while (options.outWidth / inSampleSize > maxWidth || options.outHeight / inSampleSize > maxHeight) { + inSampleSize++; + } + options.inSampleSize = inSampleSize; + options.inJustDecodeBounds = false; + return BitmapFactory.decodeByteArray(jpeg, 0, jpeg.length, options); + } +} diff --git a/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/model/CompareResult.java b/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/model/CompareResult.java new file mode 100644 index 0000000..82df7ff --- /dev/null +++ b/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/model/CompareResult.java @@ -0,0 +1,58 @@ +package io.flutter.plugins.facediscern.tool.face.model; + + + +public class CompareResult { + private FaceEntity faceEntity; + private float similar; + private int trackId; + private int compareCode; + private long cost; + + public CompareResult(FaceEntity faceEntity, float similar, int compareCode,long cost) { + this.faceEntity = faceEntity; + this.similar = similar; + this.compareCode = compareCode; + this.cost = cost; + } + + public FaceEntity getFaceEntity() { + return faceEntity; + } + + public void setFaceEntity(FaceEntity faceEntity) { + this.faceEntity = faceEntity; + } + + public float getSimilar() { + return similar; + } + + public void setSimilar(float similar) { + this.similar = similar; + } + + public int getTrackId() { + return trackId; + } + + public void setTrackId(int trackId) { + this.trackId = trackId; + } + + public int getCompareCode() { + return compareCode; + } + + public void setCompareCode(int compareCode) { + this.compareCode = compareCode; + } + + public long getCost() { + return cost; + } + + public void setCost(long cost) { + this.cost = cost; + } +} diff --git a/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/model/FaceEntity.java b/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/model/FaceEntity.java new file mode 100644 index 0000000..cce18d4 --- /dev/null +++ b/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/model/FaceEntity.java @@ -0,0 +1,78 @@ +package io.flutter.plugins.facediscern.tool.face.model; + + +public class FaceEntity { + // 人脸模型id + Integer faceId; + // 用户id + String userId; + // 用户人脸样本id + Integer sampleId; + // 用户名 + String userName; + // 人脸图片文件路径 + String faceFilePath; + // 人脸模型 + byte[] faceModel; + + public FaceEntity() { + + } + + public FaceEntity(byte[] faceModel) { + this.faceModel = faceModel; + } + + public FaceEntity(Integer faceId, byte[] faceModel) { + this.faceId = faceId; + this.faceModel = faceModel; + } + + public Integer getFaceId() { + return faceId; + } + + public void setFaceId(Integer faceId) { + this.faceId = faceId; + } + + public byte[] getFaceModel() { + return faceModel; + } + + public void setFaceModel(byte[] faceModel) { + this.faceModel = faceModel; + } + + public String getUserId() { + return userId; + } + + public void setUserId(String userId) { + this.userId = userId; + } + + public Integer getSampleId() { + return sampleId; + } + + public void setSampleId(Integer sampleId) { + this.sampleId = sampleId; + } + + public String getUserName() { + return userName; + } + + public void setUserName(String userName) { + this.userName = userName; + } + + public String getFaceFilePath() { + return faceFilePath; + } + + public void setFaceFilePath(String faceFilePath) { + this.faceFilePath = faceFilePath; + } +} \ No newline at end of file diff --git a/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/model/FacePreviewInfo.java b/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/model/FacePreviewInfo.java new file mode 100644 index 0000000..41e85f3 --- /dev/null +++ b/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/model/FacePreviewInfo.java @@ -0,0 +1,156 @@ +package io.flutter.plugins.facediscern.tool.face.model; + +import android.graphics.Rect; + +import com.arcsoft.face.FaceInfo; +import com.arcsoft.face.LivenessInfo; + +/** + * 人脸追踪时的信息 + */ +public class FacePreviewInfo { + /** + * RGB人脸信息,包括人脸框和人脸角度 + */ + private FaceInfo faceInfoRgb; + /** + * IR人脸信息,包括人脸框和人脸角度 + */ + private FaceInfo faceInfoIr; + /** + * 可见光成像对应的用于FaceRectView绘制的Rect + */ + private Rect rgbTransformedRect; + /** + * 红外成像对应的用于FaceRectView绘制的Rect + */ + private Rect irTransformedRect; + private int rgbLiveness = LivenessInfo.UNKNOWN; + private int irLiveness = LivenessInfo.UNKNOWN; + private float imageQuality = 0f; + /** + * 识别区域是否合法 + */ + private boolean recognizeAreaValid; + /** + * 基于{@link FaceInfo#getFaceId()}的一个偏移值,可理解为SDK截至目前检测到的人次,唯一性同faceId + */ + private int trackId; + /** + * 整体质量是否通过,包括人脸大小、角度、移动速度等 + */ + private boolean qualityPass = true; + + /** + * 是否戴口罩 + */ + private int mask; + + private Rect foreRect; + + public Rect getForeRect() { + return foreRect; + } + + public void setForeRect(Rect foreRect) { + this.foreRect = foreRect; + } + + + public FacePreviewInfo(FaceInfo faceInfoRgb) { + this.faceInfoRgb = faceInfoRgb; + } + public FacePreviewInfo(FaceInfo faceInfoRgb, int trackId) { + this.faceInfoRgb = faceInfoRgb; + this.trackId = trackId; + } + + public FaceInfo getFaceInfoRgb() { + return faceInfoRgb; + } + + public void setFaceInfoRgb(FaceInfo faceInfoRgb) { + this.faceInfoRgb = faceInfoRgb; + } + + + public int getTrackId() { + return trackId; + } + + public void setTrackId(int trackId) { + this.trackId = trackId; + } + + public void setRgbTransformedRect(Rect rgbTransformedRect) { + this.rgbTransformedRect = rgbTransformedRect; + } + + public void setIrTransformedRect(Rect irTransformedRect) { + this.irTransformedRect = irTransformedRect; + } + + public Rect getRgbTransformedRect() { + return rgbTransformedRect; + } + + public Rect getIrTransformedRect() { + return irTransformedRect; + } + + public boolean isRecognizeAreaValid() { + return recognizeAreaValid; + } + + public void setRecognizeAreaValid(boolean recognizeAreaValid) { + this.recognizeAreaValid = recognizeAreaValid; + } + + public void setFaceInfoIr(FaceInfo faceInfoIr) { + this.faceInfoIr = faceInfoIr; + } + + public FaceInfo getFaceInfoIr() { + return faceInfoIr; + } + + public int getRgbLiveness() { + return rgbLiveness; + } + + public void setRgbLiveness(int rgbLiveness) { + this.rgbLiveness = rgbLiveness; + } + + public int getIrLiveness() { + return irLiveness; + } + + public void setIrLiveness(int irLiveness) { + this.irLiveness = irLiveness; + } + + public void setImageQuality(float imageQuality) { + this.imageQuality = imageQuality; + } + + public float getImageQuality() { + return imageQuality; + } + + public boolean isQualityPass() { + return qualityPass; + } + + public void setQualityPass(boolean qualityPass) { + this.qualityPass = qualityPass; + } + + public int getMask() { + return mask; + } + + public void setMask(int mask) { + this.mask = mask; + } +} diff --git a/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/model/RecognizeConfiguration.java b/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/model/RecognizeConfiguration.java new file mode 100644 index 0000000..e67f62e --- /dev/null +++ b/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/model/RecognizeConfiguration.java @@ -0,0 +1,300 @@ +package io.flutter.plugins.facediscern.tool.face.model; + +import com.arcsoft.face.LivenessParam; + +/** + * 识别相关的配置项 + */ +public class RecognizeConfiguration { + /** + * 产生特征提取失败示语的特征提取次数(小于该值不提示) + */ + private int extractRetryCount; + /** + * 产生活体检测失败示语的活体检测次数(小于该值不提示) + */ + private int livenessRetryCount; + /** + * 最大人脸检测数量 + */ + private int maxDetectFaces; + /** + * 识别阈值 + */ + private float similarThreshold; + /** + * 图像质量检测阈值:适用于不戴口罩且人脸识别场景 + */ + private float imageQualityNoMaskRecognizeThreshold; + /** + * 图像质量检测阈值:适用于戴口罩且人脸识别场景 + */ + private float imageQualityMaskRecognizeThreshold; + /** + * 识别失败重试间隔 + */ + private int recognizeFailedRetryInterval; + /** + * 活体检测未通过重试间隔 + */ + private int livenessFailedRetryInterval; + /** + * 启用活体 + */ + private boolean enableLiveness; + /** + * 启用图像质量检测 + */ + private boolean enableImageQuality; + /** + * 识别区域限制 + */ + private boolean enableFaceAreaLimit; + /** + * 仅识别最大人脸 + */ + private boolean keepMaxFace; + /** + * 活体阈值设置 + */ + private LivenessParam livenessParam; + + + /** + * 启用人脸边长限制 + */ + private boolean enableFaceSizeLimit = false; + /** + * 启用人脸移动限制 + */ + private boolean enableFaceMoveLimit = false; + /** + * 人脸边长限制值 + */ + private int faceSizeLimit = 0; + /** + * 人脸上下针移动限制值 + */ + private int faceMoveLimit = 0; + + + public RecognizeConfiguration(Builder builder) { + this.extractRetryCount = builder.extractRetryCount; + this.livenessRetryCount = builder.livenessRetryCount; + this.livenessFailedRetryInterval = builder.livenessFailedRetryInterval; + this.maxDetectFaces = builder.maxDetectFaces; + this.similarThreshold = builder.similarThreshold; + this.imageQualityNoMaskRecognizeThreshold = builder.imageQualityNoMaskRecognizeThreshold; + this.imageQualityMaskRecognizeThreshold = builder.imageQualityMaskRecognizeThreshold; + this.enableLiveness = builder.enableLiveness; + this.enableImageQuality = builder.enableImageQuality; + this.enableFaceAreaLimit = builder.enableFaceAreaLimit; + this.keepMaxFace = builder.keepMaxFace; + this.recognizeFailedRetryInterval = builder.recognizeFailedRetryInterval; + this.livenessParam = builder.livenessParam; + this.enableFaceSizeLimit = builder.enableFaceSizeLimit; + this.enableFaceMoveLimit = builder.enableFaceMoveLimit; + this.faceSizeLimit = builder.faceSizeLimit; + this.faceMoveLimit = builder.faceMoveLimit; + } + + //TODO: demo不实现所有配置,若以下项也需要进行自定义配置,可参考其他配置项实现 + public static class Builder { + private int extractRetryCount = 3; + private int livenessRetryCount = 3; + private int maxDetectFaces = 3; + private int recognizeFailedRetryInterval = 0; + private int livenessFailedRetryInterval = 0; + private float similarThreshold = 0.8f; + private float imageQualityNoMaskRecognizeThreshold = 0.49f; + private float imageQualityMaskRecognizeThreshold = 0.29f; + private boolean enableLiveness = false; + private boolean enableFaceAreaLimit = false; + private boolean enableImageQuality = false; + private boolean enableFaceSizeLimit = false; + private boolean enableFaceMoveLimit = false; + private int faceSizeLimit = 0; + private int faceMoveLimit = 0; + private boolean keepMaxFace = false; + private LivenessParam livenessParam; + + public Builder recognizeFailedRetryInterval(int val) { + this.recognizeFailedRetryInterval = val; + return this; + } + + public Builder livenessFailedRetryInterval(int val) { + this.livenessFailedRetryInterval = val; + return this; + } + + public Builder extractRetryCount(int val) { + this.extractRetryCount = val; + return this; + } + + public Builder livenessRetryCount(int val) { + this.livenessRetryCount = val; + return this; + } + + public Builder maxDetectFaces(int val) { + this.maxDetectFaces = val; + return this; + } + + public Builder similarThreshold(float val) { + this.similarThreshold = val; + return this; + } + + public Builder imageQualityNoMaskRecognizeThreshold(float val) { + this.imageQualityNoMaskRecognizeThreshold = val; + return this; + } + + public Builder imageQualityMaskRecognizeThreshold(float val) { + this.imageQualityMaskRecognizeThreshold = val; + return this; + } + + public Builder enableLiveness(boolean val) { + this.enableLiveness = val; + return this; + } + + + public Builder enableImageQuality(boolean val) { + this.enableImageQuality = val; + return this; + } + public Builder enableFaceAreaLimit(boolean val) { + this.enableFaceAreaLimit = val; + return this; + } + + public Builder enableFaceSizeLimit(boolean val) { + this.enableFaceSizeLimit = val; + return this; + } + + public Builder enableFaceMoveLimit(boolean val) { + this.enableFaceMoveLimit = val; + return this; + } + + public Builder faceSizeLimit(int val) { + this.faceSizeLimit = val; + return this; + } + + public Builder faceMoveLimit(int val) { + this.faceMoveLimit = val; + return this; + } + + public Builder keepMaxFace(boolean val) { + this.keepMaxFace = val; + return this; + } + + public Builder livenessParam(LivenessParam val) { + this.livenessParam = val; + return this; + } + + + public RecognizeConfiguration build() { + return new RecognizeConfiguration(this); + } + } + + public float getImageQualityNoMaskRecognizeThreshold() { + return imageQualityNoMaskRecognizeThreshold; + } + + public float getImageQualityMaskRecognizeThreshold() { + return imageQualityMaskRecognizeThreshold; + } + + public boolean isEnableImageQuality() { + return enableImageQuality; + } + + public boolean isEnableFaceAreaLimit() { + return enableFaceAreaLimit; + } + + public LivenessParam getLivenessParam() { + return livenessParam; + } + + public int getExtractRetryCount() { + return extractRetryCount; + } + + public int getLivenessRetryCount() { + return livenessRetryCount; + } + + public int getMaxDetectFaces() { + return maxDetectFaces; + } + + public float getSimilarThreshold() { + return similarThreshold; + } + + public boolean isEnableLiveness() { + return enableLiveness; + } + + + public int getRecognizeFailedRetryInterval() { + return recognizeFailedRetryInterval; + } + + public int getLivenessFailedRetryInterval() { + return livenessFailedRetryInterval; + } + + public boolean isKeepMaxFace() { + return keepMaxFace; + } + + public boolean isEnableFaceSizeLimit() { + return enableFaceSizeLimit; + } + + public boolean isEnableFaceMoveLimit() { + return enableFaceMoveLimit; + } + + public int getFaceSizeLimit() { + return faceSizeLimit; + } + + public int getFaceMoveLimit() { + return faceMoveLimit; + } + + @Override + public String toString() { + return + "extractRetryCount: " + extractRetryCount + "\r\n" + + "similarThreshold: " + similarThreshold + "\r\n" + + "recognizeFailedRetryInterval: " + recognizeFailedRetryInterval + "\r\n" + + + "keepMaxFace: " + keepMaxFace + "\r\n" + + "maxDetectFaces: " + maxDetectFaces + "\r\n" + + + "enableImageQuality: " + enableImageQuality + "\r\n" + + "imageQualityNoMaskRecognizeThreshold: " + imageQualityNoMaskRecognizeThreshold + "\r\n" + + "imageQualityMaskRecognizeThreshold: " + imageQualityMaskRecognizeThreshold + "\r\n" + + + "enableLiveness: " + enableLiveness + "\r\n" + + "livenessRetryCount: " + livenessRetryCount + "\r\n" + + "livenessParams: " + (livenessParam == null ? null : (livenessParam.getRgbThreshold() + "," + livenessParam.getIrThreshold())); + + } +} diff --git a/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/model/RecognizeInfo.java b/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/model/RecognizeInfo.java new file mode 100644 index 0000000..9350b37 --- /dev/null +++ b/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/model/RecognizeInfo.java @@ -0,0 +1,86 @@ +package io.flutter.plugins.facediscern.tool.face.model; + +import com.arcsoft.face.LivenessInfo; +import io.flutter.plugins.facediscern.tool.face.constants.RequestFeatureStatus; + +/** + * 单个人脸(faceId)识别过程中的信息 + */ +public class RecognizeInfo { + /** + * 用于记录人脸识别相关状态 + */ + private int recognizeStatus = RequestFeatureStatus.TO_RETRY; + /** + * 用于记录人脸特征提取出错重试次数 + */ + private int extractErrorRetryCount; + /** + * 用于存储活体值 + */ + private int liveness = LivenessInfo.UNKNOWN; + /** + * 用于存储活体检测出错重试次数 + */ + private int livenessErrorRetryCount; + /** + * 用户姓名,用于显示 + */ + private String name; + /** + * 特征等活体的lock + */ + private Object waitLock = new Object(); + + public int getRecognizeStatus() { + return recognizeStatus; + } + + public void setRecognizeStatus(int recognizeStatus) { + this.recognizeStatus = recognizeStatus; + } + + public void setLiveness(int liveness) { + this.liveness = liveness; + } + + public int increaseAndGetExtractErrorRetryCount() { + return ++extractErrorRetryCount; + } + + public int getLiveness() { + return liveness; + } + + public int increaseAndGetLivenessErrorRetryCount() { + return ++livenessErrorRetryCount; + } + + public void setExtractErrorRetryCount(int extractErrorRetryCount) { + this.extractErrorRetryCount = extractErrorRetryCount; + } + + public void setLivenessErrorRetryCount(int livenessErrorRetryCount) { + this.livenessErrorRetryCount = livenessErrorRetryCount; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public Object getWaitLock() { + return waitLock; + } + + public int getExtractErrorRetryCount() { + return extractErrorRetryCount; + } + + public int getLivenessErrorRetryCount() { + return livenessErrorRetryCount; + } +} \ No newline at end of file diff --git a/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/servcie/FaceServer.java b/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/servcie/FaceServer.java new file mode 100644 index 0000000..04b092d --- /dev/null +++ b/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/servcie/FaceServer.java @@ -0,0 +1,359 @@ +package io.flutter.plugins.facediscern.tool.face.servcie; + +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.Rect; +import android.os.Environment; +import android.util.Log; + +import com.arcsoft.face.ErrorInfo; +import com.arcsoft.face.FaceEngine; +import com.arcsoft.face.FaceFeature; +import com.arcsoft.face.FaceFeatureInfo; +import com.arcsoft.face.FaceInfo; +import com.arcsoft.face.MaskInfo; +import com.arcsoft.face.SearchResult; +import com.arcsoft.face.enums.DetectFaceOrientPriority; +import com.arcsoft.face.enums.DetectMode; +import com.arcsoft.face.enums.ExtractType; +import com.arcsoft.imageutil.ArcSoftImageFormat; +import com.arcsoft.imageutil.ArcSoftImageUtil; +import com.arcsoft.imageutil.ArcSoftImageUtilError; +import com.arcsoft.imageutil.ArcSoftRotateDegree; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +import io.flutter.plugins.facediscern.tool.face.model.CompareResult; +import io.flutter.plugins.facediscern.tool.face.model.FaceEntity; + +/** + * 人脸库操作类,包含注册和搜索 + */ +public class FaceServer { + private static final String TAG = "FaceServer"; + private static FaceEngine faceEngine = null; + private static volatile FaceServer faceServer = null; + private List faceRegisterInfoList = new ArrayList<>(); + private String imageRootPath; + /** + * 最大注册人脸数 + */ + private static final int MAX_REGISTER_FACE_COUNT = 30000; + + public synchronized void init(Context context) { + if (faceEngine == null && context != null) { + faceEngine = new FaceEngine(); + int engineCode = faceEngine.init( + context, + DetectMode.ASF_DETECT_MODE_IMAGE, + DetectFaceOrientPriority.ASF_OP_ALL_OUT, + 1, + FaceEngine.ASF_FACE_RECOGNITION | FaceEngine.ASF_FACE_DETECT | FaceEngine.ASF_MASK_DETECT + ); + if (engineCode != ErrorInfo.MOK) { + Log.e(TAG, "人脸引擎初始化失败code" + engineCode); + } + } + } + + public static FaceServer getInstance() { + if (faceServer == null) { + synchronized (FaceServer.class) { + if (faceServer == null) { + faceServer = new FaceServer(); + } + } + } + return faceServer; + } + + /** + * 销毁 + */ + public synchronized void release() { + if (faceRegisterInfoList != null) { + faceRegisterInfoList.clear(); + faceRegisterInfoList = null; + } + if (faceEngine != null) { + synchronized (faceEngine) { + faceEngine.unInit(); + } + faceEngine = null; + } + faceServer = null; + } + + public int registerFaceFeatureInfoListFromDb(List faceEntityList) { + if (faceEngine == null) return -99; + List faceFeatureInfoList = new ArrayList<>(); + for (FaceEntity faceEntity : faceEntityList) { + FaceFeatureInfo faceFeatureInfo = new FaceFeatureInfo((int) faceEntity.getFaceId(), faceEntity.getFaceModel()); + faceFeatureInfoList.add(faceFeatureInfo); + } + //首先清除FaceEngine中所有人脸数据,再添加新的人脸数据 + faceEngine.removeFaceFeature(-1); + int code = faceEngine.registerFaceFeature(faceFeatureInfoList); + if (code == ErrorInfo.MOK) { + faceRegisterInfoList.addAll(faceEntityList); + } + return code; + } + + // 注册单个人脸数据 + public int registerFaceFeatureInfoFromDb(FaceEntity faceEntity) { + if (faceEntity != null && faceEngine != null) { + FaceFeatureInfo faceFeatureInfo = new FaceFeatureInfo(faceEntity.getFaceId(), faceEntity.getFaceModel()); + int code = faceEngine.registerFaceFeature(faceFeatureInfo); + if (code == ErrorInfo.MOK) { + faceEntity.setFaceModel(faceFeatureInfo.getFeatureData()); + // 查询一下确保入库成功 + FaceFeatureInfo afterDetail = getFaceFeature(faceEntity.getFaceId()); + if (Objects.nonNull(afterDetail)) { + long count = faceRegisterInfoList.stream().filter(v -> Objects.equals(v.getFaceId(), faceEntity.getFaceId())).count(); + if (count == 0) { + faceRegisterInfoList.add(faceEntity); + } + return code; + } + } + } + return -99; + } + + // 从人脸数据库中搜索人脸 + public FaceFeatureInfo getFaceFeature(int searchId) { + FaceFeatureInfo faceFeatureInfo = null; + try { + faceFeatureInfo = faceEngine.getFaceFeature(searchId); + } catch (IllegalArgumentException e) { + Log.e(TAG, "从人脸数据库中搜索人脸error:" + e.getMessage(), e); + } + return faceFeatureInfo; + } + + // 获取人脸数量 + public int getTotalFaceCount() { + if (faceEngine == null) return -99; + return faceEngine.getFaceCount(); + } + + // 删除人脸-1删除全部 + public int removeFace(int searchId) { + if (faceEngine == null) return -99; + return faceEngine.removeFaceFeature(searchId); + } + + // 在特征库中搜索 + public CompareResult searchFaceFeature(FaceFeature faceFeature) { + if (faceFeature == null) return null; + if (faceEngine == null) return null; + SearchResult searchResult = null; + try { + long searchStart = System.currentTimeMillis(); + searchResult = faceEngine.searchFaceFeature(faceFeature); + long searchCost = System.currentTimeMillis() - searchStart; + if (searchResult != null) { + FaceFeatureInfo faceFeatureInfo = searchResult.getFaceFeatureInfo(); + FaceEntity entity = faceRegisterInfoList.stream().filter(v -> Objects.equals(v.getFaceId(), faceFeatureInfo.getSearchId())).findFirst().get(); + if (Objects.isNull(entity)) { + entity = new FaceEntity(faceFeatureInfo.getSearchId(), faceFeatureInfo.getFeatureData()); + } + Log.i(TAG, "查询人脸总耗时:" + (System.currentTimeMillis() - searchStart) + "ms;username:" + entity.getUserName() + ";faceId:" + entity.getFaceId() + ";"); + return new CompareResult(entity, searchResult.getMaxSimilar(), ErrorInfo.MOK, searchCost); + } + } catch (IllegalArgumentException exception) { + Log.i(TAG, "exception:" + exception.getMessage()); + } + return null; + } + + + public byte[] registerBgr24(Context context, byte[] bgr24, int width, int height, String name) { + if (faceEngine == null || context == null || bgr24 == null || width % 4 != 0 || bgr24.length != width * height * 3) { + Log.e(TAG, "registerBgr24: invalid params"); + return null; + } + //人脸检测 + List faceInfoList = new ArrayList<>(); + int code; + synchronized (faceEngine) { + code = faceEngine.detectFaces(bgr24, width, height, FaceEngine.CP_PAF_BGR24, faceInfoList); + } + if (code == ErrorInfo.MOK && !faceInfoList.isEmpty()) { + code = faceEngine.process(bgr24, width, height, FaceEngine.CP_PAF_BGR24, faceInfoList, + FaceEngine.ASF_MASK_DETECT); + if (code == ErrorInfo.MOK) { + List maskInfoList = new ArrayList<>(); + faceEngine.getMask(maskInfoList); + if (!maskInfoList.isEmpty()) { + int isMask = maskInfoList.get(0).getMask(); + if (isMask == MaskInfo.WORN) { + /* + * 注册照要求不戴口罩 + */ + Log.e(TAG, "registerBgr24: maskInfo is worn"); + return null; + } + if (isMask == MaskInfo.UNKNOWN) { + Log.e(TAG, "registerBgr24: maskInfo is unknown"); + return null; + } + } + } else { + Log.e(TAG, "registerBgr24 mask process fail res=" + code); + return null; + } + FaceFeature faceFeature = new FaceFeature(); + /* + * 特征提取,注册人脸时参数extractType值为ExtractType.REGISTER,参数mask的值为MaskInfo.NOT_WORN + */ + synchronized (faceEngine) { + code = faceEngine.extractFaceFeature(bgr24, width, height, FaceEngine.CP_PAF_BGR24, faceInfoList.get(0), + ExtractType.REGISTER, MaskInfo.NOT_WORN, faceFeature); + } + String userName = name == null ? String.valueOf(System.currentTimeMillis()) : name; + //保存注册结果(注册图、特征数据) + if (code == ErrorInfo.MOK) { + //为了美观,扩大rect截取注册图 + Rect cropRect = getBestRect(width, height, faceInfoList.get(0).getRect()); + if (cropRect == null) { + Log.e(TAG, "registerBgr24: cropRect is null"); + return null; + } + cropRect.left &= ~3; + cropRect.top &= ~3; + cropRect.right &= ~3; + cropRect.bottom &= ~3; + String imgPath = getImagePath(userName, context); + // 创建一个头像的Bitmap,存放旋转结果图 + Bitmap headBmp = getHeadImage(bgr24, width, height, faceInfoList.get(0).getOrient(), cropRect, ArcSoftImageFormat.BGR24); + try { + FileOutputStream fos = new FileOutputStream(imgPath); + headBmp.compress(Bitmap.CompressFormat.JPEG, 100, fos); + fos.close(); + } catch (IOException e) { + e.printStackTrace(); + return null; + } + return faceFeature.getFeatureData(); + } else { + Log.e(TAG, "registerBgr24: extract face feature failed, code is " + code); + return null; + } + } else { + Log.e(TAG, "registerBgr24: no face detected, code is " + code); + return null; + } + } + + private String getImagePath(String name, Context context) { + if (imageRootPath == null) { + imageRootPath = getImageDir(context); + File dir = new File(imageRootPath); + if (!dir.exists() && !dir.mkdirs()) { + return null; + } + } + return imageRootPath + File.separator + name + "_" + System.currentTimeMillis() + ".jpg"; + } + + private String getImageDir(Context context) { + String path = context + .getApplicationContext() + .getExternalFilesDir(Environment.DIRECTORY_PICTURES) + + File.separator + "faceDB" + File.separator + "registerFaces"; + Log.i(TAG, "存储图片的地址为path:" + path); + return path; + } + + /** + * 将图像中需要截取的Rect向外扩张一倍,若扩张一倍会溢出,则扩张到边界,若Rect已溢出,则收缩到边界 + * + * @param width 图像宽度 + * @param height 图像高度 + * @param srcRect 原Rect + * @return 调整后的Rect + */ + private static Rect getBestRect(int width, int height, Rect srcRect) { + if (srcRect == null) { + return null; + } + Rect rect = new Rect(srcRect); + + // 原rect边界已溢出宽高的情况 + int maxOverFlow = Math.max(-rect.left, Math.max(-rect.top, Math.max(rect.right - width, rect.bottom - height))); + if (maxOverFlow >= 0) { + rect.inset(maxOverFlow, maxOverFlow); + return rect; + } + + // 原rect边界未溢出宽高的情况 + int padding = rect.height() / 2; + + // 若以此padding扩张rect会溢出,取最大padding为四个边距的最小值 + if (!(rect.left - padding > 0 && rect.right + padding < width && rect.top - padding > 0 && rect.bottom + padding < height)) { + padding = Math.min(Math.min(Math.min(rect.left, width - rect.right), height - rect.bottom), rect.top); + } + rect.inset(-padding, -padding); + return rect; + } + + private Bitmap getHeadImage(byte[] originImageData, int width, int height, int orient, Rect cropRect, ArcSoftImageFormat imageFormat) { + byte[] headImageData = ArcSoftImageUtil.createImageData(cropRect.width(), cropRect.height(), imageFormat); + int cropCode = ArcSoftImageUtil.cropImage(originImageData, headImageData, width, height, cropRect, imageFormat); + if (cropCode != ArcSoftImageUtilError.CODE_SUCCESS) { + throw new RuntimeException("crop image failed, code is " + cropCode); + } + + //判断人脸旋转角度,若不为0度则旋转注册图 + byte[] rotateHeadImageData = null; + int cropImageWidth; + int cropImageHeight; + // 90度或270度的情况,需要宽高互换 + if (orient == FaceEngine.ASF_OC_90 || orient == FaceEngine.ASF_OC_270) { + cropImageWidth = cropRect.height(); + cropImageHeight = cropRect.width(); + } else { + cropImageWidth = cropRect.width(); + cropImageHeight = cropRect.height(); + } + ArcSoftRotateDegree rotateDegree = null; + switch (orient) { + case FaceEngine.ASF_OC_90: + rotateDegree = ArcSoftRotateDegree.DEGREE_270; + break; + case FaceEngine.ASF_OC_180: + rotateDegree = ArcSoftRotateDegree.DEGREE_180; + break; + case FaceEngine.ASF_OC_270: + rotateDegree = ArcSoftRotateDegree.DEGREE_90; + break; + case FaceEngine.ASF_OC_0: + default: + rotateHeadImageData = headImageData; + break; + } + // 非0度的情况,旋转图像 + if (rotateDegree != null) { + rotateHeadImageData = new byte[headImageData.length]; + int rotateCode = ArcSoftImageUtil.rotateImage(headImageData, rotateHeadImageData, cropRect.width(), cropRect.height(), rotateDegree, imageFormat); + if (rotateCode != ArcSoftImageUtilError.CODE_SUCCESS) { + throw new RuntimeException("rotate image failed, code is : " + rotateCode + ", code description is : "); + } + } + // 将创建一个Bitmap,并将图像数据存放到Bitmap中 + Bitmap headBmp = Bitmap.createBitmap(cropImageWidth, cropImageHeight, Bitmap.Config.RGB_565); + int imageDataToBitmapCode = ArcSoftImageUtil.imageDataToBitmap(rotateHeadImageData, headBmp, imageFormat); + if (imageDataToBitmapCode != ArcSoftImageUtilError.CODE_SUCCESS) { + throw new RuntimeException("failed to transform image data to bitmap, code is : " + imageDataToBitmapCode + + ", code description is : "); + } + return headBmp; + } +} diff --git a/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/servcie/RegisterFailedException.java b/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/servcie/RegisterFailedException.java new file mode 100644 index 0000000..7d2d7c8 --- /dev/null +++ b/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/servcie/RegisterFailedException.java @@ -0,0 +1,7 @@ +package io.flutter.plugins.facediscern.tool.face.servcie; + +public class RegisterFailedException extends Exception { + public RegisterFailedException(String message) { + super(message); + } +} diff --git a/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/widget/FaceRectView.java b/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/widget/FaceRectView.java new file mode 100644 index 0000000..d438d88 --- /dev/null +++ b/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/widget/FaceRectView.java @@ -0,0 +1,295 @@ +package io.flutter.plugins.facediscern.tool.face.widget; + +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.Path; +import android.graphics.Rect; +import android.util.AttributeSet; +import android.view.View; + +import androidx.annotation.Nullable; + +import com.arcsoft.face.AgeInfo; +import com.arcsoft.face.FaceAttributeInfo; +import com.arcsoft.face.GenderInfo; +import com.arcsoft.face.LivenessInfo; + +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; + +/** + * 用于显示人脸信息的控件 + */ +public class FaceRectView extends View { + private CopyOnWriteArrayList drawInfoList = new CopyOnWriteArrayList<>(); + + // 画笔,复用 + private Paint paint; + + // 默认人脸框厚度 + private static final int DEFAULT_FACE_RECT_THICKNESS = 6; + + public FaceRectView(Context context) { + this(context, null); + } + + public FaceRectView(Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + paint = new Paint(); + } + + @Override + protected void onDraw(Canvas canvas) { + super.onDraw(canvas); + if (drawInfoList != null && drawInfoList.size() > 0) { + for (int i = 0; i < drawInfoList.size(); i++) { + drawFaceRect(canvas, drawInfoList.get(i), DEFAULT_FACE_RECT_THICKNESS, paint); + } + } + } + + public void clearFaceInfo() { + drawInfoList.clear(); + postInvalidate(); + } + + public void addFaceInfo(DrawInfo faceInfo) { + drawInfoList.add(faceInfo); + postInvalidate(); + } + + public void addFaceInfo(List faceInfoList) { + drawInfoList.addAll(faceInfoList); + postInvalidate(); + } + + public void drawRealtimeFaceInfo(List drawInfoList) { + clearFaceInfo(); + if (drawInfoList == null || drawInfoList.size() == 0) { + return; + } + addFaceInfo(drawInfoList); + } + + public static class DrawInfo { + private Rect rect; + private int sex; + private int age; + private int liveness; + private int color; + private int isWithinBoundary; + private String name = null; + private boolean drawRectInfo; + private Rect foreheadRect; + private FaceAttributeInfo faceAttributeInfo; + private boolean rgbRect; + + public DrawInfo(Rect rect, int sex, int age, int liveness, int color, String name) { + this.rect = rect; + this.sex = sex; + this.age = age; + this.liveness = liveness; + this.color = color; + this.name = name; + } + + public DrawInfo(Rect rect, int sex, int age, int liveness, int color, String name, int isWithinBoundary, Rect foreheadRect, + FaceAttributeInfo faceAttributeInfo, boolean drawRectInfo, boolean rgbRect) { + this.rect = rect; + this.sex = sex; + this.age = age; + this.liveness = liveness; + this.color = color; + this.name = name; + this.isWithinBoundary = isWithinBoundary; + this.drawRectInfo = drawRectInfo; + this.foreheadRect = foreheadRect; + this.faceAttributeInfo = faceAttributeInfo; + this.rgbRect = rgbRect; + } + + public DrawInfo(DrawInfo drawInfo) { + if (drawInfo == null) { + return; + } + this.rect = drawInfo.rect; + this.sex = drawInfo.sex; + this.age = drawInfo.age; + this.liveness = drawInfo.liveness; + this.color = drawInfo.color; + this.name = drawInfo.name; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public Rect getRect() { + return rect; + } + + public void setRect(Rect rect) { + this.rect = rect; + } + + public int getSex() { + return sex; + } + + public void setSex(int sex) { + this.sex = sex; + } + + public int getAge() { + return age; + } + + public void setAge(int age) { + this.age = age; + } + + public int getLiveness() { + return liveness; + } + + public void setLiveness(int liveness) { + this.liveness = liveness; + } + + public int getColor() { + return color; + } + + public void setColor(int color) { + this.color = color; + } + + public boolean isDrawRectInfo() { + return drawRectInfo; + } + + public void setDrawRectInfo(boolean drawRectInfo) { + this.drawRectInfo = drawRectInfo; + } + + public Rect getForeheadRect() { + return foreheadRect; + } + + public void setForeheadRect(Rect foreheadRect) { + this.foreheadRect = foreheadRect; + } + + public FaceAttributeInfo getFaceAttributeInfo() { + return faceAttributeInfo; + } + + public void setFaceAttributeInfo(FaceAttributeInfo faceAttributeInfo) { + this.faceAttributeInfo = faceAttributeInfo; + } + + public int getIsWithinBoundary() { + return isWithinBoundary; + } + + public void setIsWithinBoundary(int isWithinBoundary) { + this.isWithinBoundary = isWithinBoundary; + } + } + + + /** + * 绘制数据信息到view上,若 {@link DrawInfo#getName()} 不为null则绘制 {@link DrawInfo#getName()} + * + * @param canvas 需要被绘制的view的canvas + * @param drawInfo 绘制信息 + * @param faceRectThickness 人脸框厚度 + * @param paint 画笔 + */ + private static void drawFaceRect(Canvas canvas, DrawInfo drawInfo, int faceRectThickness, Paint paint) { + if (canvas == null || drawInfo == null) { + return; + } + paint.setStyle(Paint.Style.STROKE); + paint.setStrokeWidth(faceRectThickness); + paint.setColor(drawInfo.getColor()); + paint.setAntiAlias(true); + + Path mPath = new Path(); + // 左上 + Rect rect = drawInfo.getRect(); + mPath.moveTo(rect.left, rect.top + rect.height() / 4); + mPath.lineTo(rect.left, rect.top); + mPath.lineTo(rect.left + rect.width() / 4, rect.top); + // 右上 + mPath.moveTo(rect.right - rect.width() / 4, rect.top); + mPath.lineTo(rect.right, rect.top); + mPath.lineTo(rect.right, rect.top + rect.height() / 4); + // 右下 + mPath.moveTo(rect.right, rect.bottom - rect.height() / 4); + mPath.lineTo(rect.right, rect.bottom); + mPath.lineTo(rect.right - rect.width() / 4, rect.bottom); + // 左下 + mPath.moveTo(rect.left + rect.width() / 4, rect.bottom); + mPath.lineTo(rect.left, rect.bottom); + mPath.lineTo(rect.left, rect.bottom - rect.height() / 4); + canvas.drawPath(mPath, paint); + + // 绘制文字,用最细的即可,避免在某些低像素设备上文字模糊 + paint.setStrokeWidth(1); + + if (drawInfo.getName() == null) { + paint.setStyle(Paint.Style.FILL_AND_STROKE); + paint.setTextSize(rect.width() / 12); + String str = (drawInfo.getSex() == GenderInfo.MALE ? "MALE" : (drawInfo.getSex() == GenderInfo.FEMALE ? "FEMALE" : "UNKNOWN")) + + "," + + (drawInfo.getAge() == AgeInfo.UNKNOWN_AGE ? "UNKNOWN" : drawInfo.getAge()) + + "," + + (drawInfo.getLiveness() == LivenessInfo.ALIVE ? "ALIVE" : (drawInfo.getLiveness() == LivenessInfo.NOT_ALIVE ? "NOT_ALIVE" : "UNKNOWN")); + canvas.drawText(str, rect.left, rect.top - 10, paint); + } else { + paint.setStyle(Paint.Style.FILL_AND_STROKE); + paint.setTextSize(rect.width() / 12); + canvas.drawText(drawInfo.getName(), rect.left, rect.top - 10, paint); + } + + if (drawInfo.drawRectInfo && drawInfo.rgbRect) { + Rect foreRect = drawInfo.foreheadRect; + if (foreRect != null) { + Path forePath = new Path(); + forePath.moveTo(foreRect.left, foreRect.top); + forePath.lineTo(foreRect.right, foreRect.top); + forePath.lineTo(foreRect.right, foreRect.bottom); + forePath.lineTo(foreRect.left, foreRect.bottom); + forePath.lineTo(foreRect.left, foreRect.top); + paint.setStyle(Paint.Style.STROKE); + paint.setStrokeWidth(3); + canvas.drawPath(forePath, paint); + } + + FaceAttributeInfo attributeInfo = drawInfo.getFaceAttributeInfo(); + if (attributeInfo != null) { + paint.setStyle(Paint.Style.FILL_AND_STROKE); + int textSize = rect.width() / 8; + paint.setStrokeWidth(1); + paint.setTextSize(textSize); + int defX = rect.left; + int defY = rect.bottom + rect.width() / 8; + + String isStrInfo0 = drawInfo.getIsWithinBoundary() == 1 ? "是" : "否"; + String strInfo0 = "是否在相机范围内: " + isStrInfo0; + canvas.drawText(strInfo0, defX, defY, paint); + + String isStrInfo1 = attributeInfo.getWearGlasses() == 1 ? "是" : "否"; + String strInfo1 = "是否戴眼镜: " + isStrInfo1; + canvas.drawText(strInfo1, defX, defY + textSize, paint); + } + } + } + +} \ No newline at end of file diff --git a/android/app/src/main/java/io/flutter/plugins/facediscern/tool/file/FileUtil.java b/android/app/src/main/java/io/flutter/plugins/facediscern/tool/file/FileUtil.java new file mode 100644 index 0000000..4f94960 --- /dev/null +++ b/android/app/src/main/java/io/flutter/plugins/facediscern/tool/file/FileUtil.java @@ -0,0 +1,71 @@ +package io.flutter.plugins.facediscern.tool.file; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; + +public class FileUtil { + /** + * 读取文件中的数据内容 + * + * @param file 文件 + * @return 二进制数据内容 + */ + public static byte[] fileToData(File file) { + FileInputStream fis = null; + try { + fis = new FileInputStream(file); + byte[] data = new byte[fis.available()]; + fis.read(data); + fis.close(); + return data; + } catch (IOException e) { + e.printStackTrace(); + return null; + } + } + + public static boolean saveDataToFile(byte[] data, File file, boolean append) { + if (data == null){ + return false; + } + File parentFile = file.getParentFile(); + if (parentFile == null) { + return false; + } + if (!file.getParentFile().exists() && !file.getParentFile().mkdirs()) { + return false; + } + FileOutputStream fos = null; + try { + fos = new FileOutputStream(file, append); + int bufferSize = 1024; + int index = 0; + while (index < data.length) { + if (data.length - index < bufferSize) { + bufferSize = data.length - index; + } + fos.write(data, index, bufferSize); + index += bufferSize; + } + return true; + } catch (IOException e) { + e.printStackTrace(); + return false; + } finally { + try { + if (fos != null) { + fos.close(); + } + } catch (IOException e) { + e.printStackTrace(); + } + } + } + + public static boolean saveDataToFile(byte[] data, File file) { + return saveDataToFile(data, file, false); + } + +} diff --git a/android/app/src/main/java/io/flutter/plugins/facediscern/ui/FaceDiscern.java b/android/app/src/main/java/io/flutter/plugins/facediscern/ui/FaceDiscern.java new file mode 100644 index 0000000..ad1ba1b --- /dev/null +++ b/android/app/src/main/java/io/flutter/plugins/facediscern/ui/FaceDiscern.java @@ -0,0 +1,306 @@ +package io.flutter.plugins.facediscern.ui; + +import android.Manifest; +import android.content.Intent; +import android.graphics.Point; +import android.hardware.Camera; +import android.os.Bundle; +import android.util.DisplayMetrics; +import android.view.View; +import android.view.ViewGroup; +import android.view.ViewTreeObserver; + +import androidx.activity.EdgeToEdge; +import androidx.core.app.ActivityCompat; +import androidx.databinding.DataBindingUtil; +import androidx.lifecycle.ViewModelProvider; + +import com.example.pj_supermarket_cloud.R; +import com.example.pj_supermarket_cloud.databinding.ActivityFaceDiscernBinding; +import com.example.pj_supermarket_cloud.tool.FlutterRouterManager; + +import io.flutter.Log; +import io.flutter.embedding.android.FlutterActivity; +import io.flutter.plugins.facediscern.flutter.FlutterFaceDiscernPlugin; +import io.flutter.plugins.facediscern.tool.camera.DualCameraHelper; +import io.flutter.plugins.facediscern.tool.camera.callback.CameraListener; +import io.flutter.plugins.facediscern.tool.face.constants.LivenessType; +import io.flutter.plugins.facediscern.tool.face.help.FaceRectTransformer; +import io.flutter.plugins.facediscern.tool.face.model.FacePreviewInfo; +import io.flutter.plugins.facediscern.tool.face.widget.FaceRectView; +import io.flutter.plugins.facediscern.ui.base.BaseActivity; +import io.flutter.plugins.facediscern.ui.viewmodel.FaceDiscernViewModel; +import io.flutter.plugin.common.MethodChannel; + +import java.util.List; +import java.util.Objects; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +public class FaceDiscern extends BaseActivity implements ViewTreeObserver.OnGlobalLayoutListener { + + private static final String TAG = "人脸识别页面"; + + private static final String[] NEEDED_PERMISSIONS = new String[]{Manifest.permission.CAMERA, Manifest.permission.READ_PHONE_STATE}; + + private FaceRectTransformer rgbFaceRectTransformer; + + // 彩色摄像头 + private DualCameraHelper rgbCameraHelper; + + private FaceDiscernViewModel viewModel; + + private ActivityFaceDiscernBinding binding; + + private Boolean previewLock = false; + + private static final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1); + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + overridePendingTransition(android.R.anim.fade_in, android.R.anim.fade_out); + EdgeToEdge.enable(this); + binding = DataBindingUtil.setContentView(this, R.layout.activity_face_discern); + runOnUiThread(() -> { + this.initViewModel(); + initView(); + MethodChannel channel = FlutterFaceDiscernPlugin.staticChannel; + if (Objects.nonNull(channel)) channel.invokeMethod("faceDiscernPageInit", null); + }); + Log.e(TAG, "相机触发onCreate"); + } + + @Override + public void onGlobalLayout() { + binding.dualCameraTexturePreviewRgb.getViewTreeObserver().removeOnGlobalLayoutListener(FaceDiscern.this); + if (!checkPermissions(NEEDED_PERMISSIONS)) { + ActivityCompat.requestPermissions(this, NEEDED_PERMISSIONS, 0x001); + return; + } + initRgbCamera(); + Log.e(TAG, "相机onGlobalLayout"); + } + + // 请求权限的回调 + @Override + protected void afterRequestPermission(int requestCode, boolean isAllGranted) { + super.afterRequestPermission(requestCode, isAllGranted); + if (!checkPermissions(NEEDED_PERMISSIONS)) { + return; + } + initRgbCamera(); + } + + @Override + protected void onDestroy() { + super.onDestroy(); + if (rgbCameraHelper != null) { + rgbCameraHelper.release(); + rgbCameraHelper = null; + } + if (binding != null) binding = null; + viewModel.destroy(); + Log.e(TAG, "相机触发onDestroy"); + } + + @Override + protected void onPause() { + super.onPause(); + if (rgbCameraHelper != null) { + rgbCameraHelper.stop(); + } + if (viewModel != null) viewModel.stop(); + binding.dualCameraFaceRectView.clearFaceInfo(); + previewLock = true; + Log.i(TAG, "相机onPause"); + } + + @Override + protected void onResume() { + super.onResume(); + MethodChannel channel = FlutterFaceDiscernPlugin.staticChannel; + if (Objects.nonNull(channel)) { + channel.invokeMethod("faceDiscernPageInit", null); + } + if (rgbCameraHelper != null) { + rgbCameraHelper.resumePreview(); + } + scheduler.schedule(() -> { + try { + if (viewModel != null) { + viewModel.stop(); + previewLock = false; + Log.i(TAG, "相机开始识别"); + } + } catch (Exception e) { + Log.e(TAG, "相机开始识别错误", e); + } + }, 500, TimeUnit.MILLISECONDS); + Log.i(TAG, "相机onResume"); + } + + @Override + protected void onStop() { + super.onStop(); + if (rgbCameraHelper != null && !isFinishing()) { + rgbCameraHelper.stop(); + } + } + + // 手机返回按钮 + @Override + public void onBackPressed() { + Log.e(TAG, "相机onBackPressed"); + + if (rgbCameraHelper != null) { + rgbCameraHelper.stop(); + } + if (viewModel != null) { + viewModel.stop(); + } + binding.dualCameraFaceRectView.clearFaceInfo(); + previewLock = true; + + MethodChannel channel = FlutterFaceDiscernPlugin.staticChannel; + if (Objects.nonNull(channel)) { + channel.invokeMethod("faceDiscernBack", null); + } + + // FlutterRouterManager loadEngine = new FlutterRouterManager("/load", "login_engine", this); + // loadEngine.push(); + // loadEngine.destroy(); + + Intent intent = new Intent(); + intent.addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT); + intent.setClassName(this.getPackageName(), this.getPackageName() + ".MainActivity"); + this.startActivity(intent); + } + + + /* ***************** start 初始化 start ***************** */ + private void initViewModel() { + viewModel = new ViewModelProvider(this).get(FaceDiscernViewModel.class); + viewModel.init(FaceDiscern.this); + + } + + private void initView() { + binding.dualCameraTexturePreviewRgb.getViewTreeObserver().addOnGlobalLayoutListener(FaceDiscern.this); + } + + private void initRgbCamera() { + CameraListener cameraListener = new CameraListener() { + @Override + public void onCameraOpened(Camera camera, int cameraId, int displayOrientation, boolean isMirror) { + runOnUiThread(() -> { + Camera.Size previewSizeRgb = camera.getParameters().getPreviewSize(); + ViewGroup.LayoutParams layoutParams = adjustPreviewViewSize(binding.dualCameraTexturePreviewRgb, binding.dualCameraTexturePreviewRgb, binding.dualCameraFaceRectView, previewSizeRgb, displayOrientation, 1.0f); + rgbFaceRectTransformer = new FaceRectTransformer(previewSizeRgb.width, previewSizeRgb.height, layoutParams.width, layoutParams.height, displayOrientation, cameraId, isMirror, true, true); + viewModel.onRgbCameraOpened(camera); + viewModel.setRgbFaceRectTransformer(rgbFaceRectTransformer); + Log.e(TAG, "相机打开,宽:" + previewSizeRgb.width + ",高:" + previewSizeRgb.height + ";layout宽:" + layoutParams.width + ",layout高:" + layoutParams.height + ";"); + }); + } + + @Override + public void onPreview(byte[] data, Camera camera) { + if (previewLock) return; + binding.dualCameraFaceRectView.clearFaceInfo(); + List facePreviewInfoList = viewModel.onPreviewFrame(data, true); + if (facePreviewInfoList != null && rgbFaceRectTransformer != null) { + drawPreviewInfo(facePreviewInfoList); + } + } + + @Override + public void onCameraClosed() { + Log.e(TAG, "相机关闭"); + } + + @Override + public void onCameraError(Exception e) { + Log.e(TAG, "相机错误error", e); + } + + @Override + public void onCameraConfigurationChanged(int cameraID, int displayOrientation) { + + } + }; + // 使用前置相机 + int mCameraId = 0; + int numberOfCameras = Camera.getNumberOfCameras(); + Camera.CameraInfo cameraInfo = new Camera.CameraInfo(); + for (int i = 0; i < numberOfCameras; i++) { + Camera.getCameraInfo(i, cameraInfo); + if (cameraInfo.facing == Camera.CameraInfo.CAMERA_FACING_FRONT) { + mCameraId = i; + break; + } + } + Point previewSize = new Point(binding.dualCameraTexturePreviewRgb.getMeasuredWidth(), binding.dualCameraTexturePreviewRgb.getMeasuredHeight()); + rgbCameraHelper = new DualCameraHelper.Builder().previewViewSize(previewSize).previewSize(previewSize).rotation(getWindowManager().getDefaultDisplay().getRotation()).additionalRotation(0).specificCameraId(mCameraId).isMirror(false).previewOn(binding.dualCameraTexturePreviewRgb).cameraListener(cameraListener).build(); + rgbCameraHelper.init(); + rgbCameraHelper.start(); + viewModel.setRgbCameraHelper(rgbCameraHelper); + } + /* ***************** end end end ***************** */ + + + private ViewGroup.LayoutParams adjustPreviewViewSize(View rgbPreview, View previewView, FaceRectView faceRectView, Camera.Size previewSize, int displayOrientation, float scale) { + ViewGroup.LayoutParams layoutParams = previewView.getLayoutParams(); + int measuredWidth = previewView.getMeasuredWidth(); + int measuredHeight = previewView.getMeasuredHeight(); + float ratio = ((float) previewSize.height) / (float) previewSize.width; + if (ratio > 1) { + ratio = 1 / ratio; + } + if (displayOrientation % 180 == 0) { + layoutParams.width = measuredWidth; + layoutParams.height = (int) (measuredWidth * ratio); + } else { + layoutParams.height = measuredHeight; + layoutParams.width = (int) (measuredHeight * ratio); + } + if (scale < 1f) { + ViewGroup.LayoutParams rgbParam = rgbPreview.getLayoutParams(); + layoutParams.width = (int) (rgbParam.width * scale); + layoutParams.height = (int) (rgbParam.height * scale); + } else { + layoutParams.width *= scale; + layoutParams.height *= scale; + } + + DisplayMetrics metrics = new DisplayMetrics(); + getWindowManager().getDefaultDisplay().getMetrics(metrics); + + if (layoutParams.width >= metrics.widthPixels) { + float viewRatio = layoutParams.width / ((float) metrics.widthPixels); + layoutParams.width /= viewRatio; + layoutParams.height /= viewRatio; + } + if (layoutParams.height >= metrics.heightPixels) { + float viewRatio = layoutParams.height / ((float) metrics.heightPixels); + layoutParams.width /= viewRatio; + layoutParams.height /= viewRatio; + } + + previewView.setLayoutParams(layoutParams); + faceRectView.setLayoutParams(layoutParams); + return layoutParams; + } + + /** + * 绘制RGB、IR画面的实时人脸信息 + * + * @param facePreviewInfoList RGB画面的实时人脸信息 + */ + private void drawPreviewInfo(List facePreviewInfoList) { + if (rgbFaceRectTransformer != null) { + List rgbDrawInfoList = viewModel.getDrawInfo(facePreviewInfoList, LivenessType.RGB, false); + binding.dualCameraFaceRectView.drawRealtimeFaceInfo(rgbDrawInfoList); + } + } +} \ No newline at end of file diff --git a/android/app/src/main/java/io/flutter/plugins/facediscern/ui/base/BaseActivity.java b/android/app/src/main/java/io/flutter/plugins/facediscern/ui/base/BaseActivity.java new file mode 100644 index 0000000..ad124f6 --- /dev/null +++ b/android/app/src/main/java/io/flutter/plugins/facediscern/ui/base/BaseActivity.java @@ -0,0 +1,82 @@ +package io.flutter.plugins.facediscern.ui.base; + +import android.app.Activity; +import android.content.pm.PackageManager; +import android.os.Bundle; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AppCompatActivity; +import androidx.core.content.ContextCompat; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; + +public class BaseActivity extends AppCompatActivity { + private static ExecutorService executor; + private static List activityList; + + @Override + protected void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + if (activityList == null) { + activityList = new ArrayList<>(); + } + if (executor == null) { + executor = new ThreadPoolExecutor( + 1, + 1, + 0L, + TimeUnit.MILLISECONDS, + new LinkedBlockingQueue(), + r -> { + Thread t = new Thread(r); + t.setName("activity-sub-thread-" + t.getId()); + return t; + }); + } + activityList.add(this); + } + + @Override + protected void onDestroy() { + activityList.remove(this); + if (activityList.isEmpty()) { + activityList = null; + executor.shutdown(); + executor = null; + } + super.onDestroy(); + } + + @Override + public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults); + boolean isAllGranted = true; + for (int grantResult : grantResults) { + isAllGranted &= (grantResult == PackageManager.PERMISSION_GRANTED); + } + afterRequestPermission(requestCode, isAllGranted); + } + + // 请求权限的回调 + protected void afterRequestPermission(int requestCode, boolean isAllGranted) { + + } + + // 检查权限 + protected boolean checkPermissions(String[] neededPermissions) { + if (neededPermissions == null || neededPermissions.length == 0) { + return true; + } + boolean allGranted = true; + for (String neededPermission : neededPermissions) { + allGranted &= ContextCompat.checkSelfPermission(this, neededPermission) == PackageManager.PERMISSION_GRANTED; + } + return allGranted; + } +} \ No newline at end of file diff --git a/android/app/src/main/java/io/flutter/plugins/facediscern/ui/viewmodel/FaceDiscernViewModel.java b/android/app/src/main/java/io/flutter/plugins/facediscern/ui/viewmodel/FaceDiscernViewModel.java new file mode 100644 index 0000000..879bcdc --- /dev/null +++ b/android/app/src/main/java/io/flutter/plugins/facediscern/ui/viewmodel/FaceDiscernViewModel.java @@ -0,0 +1,310 @@ +package io.flutter.plugins.facediscern.ui.viewmodel; + + +import android.app.Activity; +import android.content.Context; +import android.content.Intent; +import android.graphics.Point; +import android.graphics.Rect; +import android.hardware.Camera; +import android.util.Log; + +import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.ViewModel; + +import com.arcsoft.face.AgeInfo; +import com.arcsoft.face.FaceEngine; +import com.arcsoft.face.FaceInfo; +import com.arcsoft.face.GenderInfo; +import com.arcsoft.face.LivenessInfo; +import com.arcsoft.face.LivenessParam; +import com.arcsoft.face.enums.DetectFaceOrientPriority; +import com.arcsoft.face.enums.DetectMode; +import com.google.gson.Gson; + +import io.flutter.plugins.facediscern.tool.camera.DualCameraHelper; +import io.flutter.plugins.facediscern.tool.camera.config.PreviewConfig; +import io.flutter.plugins.facediscern.tool.face.constants.FaceBrodConstant; +import io.flutter.plugins.facediscern.tool.face.constants.LivenessType; +import io.flutter.plugins.facediscern.tool.face.constants.RecognizeColor; +import io.flutter.plugins.facediscern.tool.face.constants.RequestFeatureStatus; +import io.flutter.plugins.facediscern.tool.face.help.FaceHelper; +import io.flutter.plugins.facediscern.tool.face.help.FaceRectTransformer; +import io.flutter.plugins.facediscern.tool.face.help.callback.RecognizeCallback; +import io.flutter.plugins.facediscern.tool.face.model.CompareResult; +import io.flutter.plugins.facediscern.tool.face.model.FaceEntity; +import io.flutter.plugins.facediscern.tool.face.model.FacePreviewInfo; +import io.flutter.plugins.facediscern.tool.face.model.RecognizeConfiguration; +import io.flutter.plugins.facediscern.tool.face.widget.FaceRectView; +import io.flutter.plugins.facediscern.ui.FaceDiscern; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Objects; + +public class FaceDiscernViewModel extends ViewModel implements RecognizeCallback { + + private final String TAG = "FaceDiscernViewModel"; + + // 相机预览的分辨率 + private Camera.Size previewSize; + + /// ****************** start 引擎 start ****************** + + // 用于特征提取的引擎 + private FaceEngine frEngine; + // VIDEO模式人脸检测引擎,用于预览帧人脸追踪及图像质量检测 + private FaceEngine ftEngine; + // VIDEO模式人脸口罩检测引擎,用于预览帧人脸口罩检测 + private FaceEngine maskEngine; + // IMAGE模式活体检测引擎,用于预览帧人脸活体检测 + private FaceEngine flEngine; + // 各个引擎初始化的错误码 + private MutableLiveData ftInitCode = new MutableLiveData<>(); + private MutableLiveData frInitCode = new MutableLiveData<>(); + private MutableLiveData flInitCode = new MutableLiveData<>(); + + /// ****************** end 引擎 end ****************** + + private Rect recognizeArea = new Rect(Integer.MIN_VALUE, Integer.MIN_VALUE, Integer.MAX_VALUE, Integer.MAX_VALUE); + + // 当前活体检测的检测类型 + private LivenessType livenessType; + + private FaceRectTransformer rgbFaceRectTransformer; + + private PreviewConfig previewConfig; + + // 人脸操作辅助类,推帧即可,内部会进行特征提取、识别 + private FaceHelper faceHelper; + + // 人脸库数据加载完成 + private boolean loadFaceList = true; + + // IR活体数据 + private byte[] irNV21 = null; + + public static final int REGISTER_STATUS_READY = 0; + // 注册人脸状态码,注册中 + public static final int REGISTER_STATUS_PROCESSING = 1; + // 册人脸状态码,注册结束(无论成功失败) + public static final int REGISTER_STATUS_DONE = 2; + + private int registerStatus = REGISTER_STATUS_DONE; + + private Intent flutterFaceCallbackIntent; + + private Context context; + + private DualCameraHelper rgbCameraHelper; + + private HashMap faceDiscern = null; + + private MutableLiveData recognizeConfiguration = new MutableLiveData<>(); + + public void setRgbFaceRectTransformer(FaceRectTransformer rgbFaceRectTransformer) { + faceHelper.setRgbFaceRectTransformer(rgbFaceRectTransformer); + } + + public void setRgbCameraHelper(DualCameraHelper rgbCameraHelper) { + this.rgbCameraHelper = rgbCameraHelper; + } + + public void setContext(Context context) { + this.context = context; + } + + private void updateRegisterStatus(int status) { + registerStatus = status; + } + + // 初始化引擎 + public void init(Context context) { + setContext(context); + boolean switchCamera = false; + previewConfig = new PreviewConfig(switchCamera ? Camera.CameraInfo.CAMERA_FACING_FRONT : Camera.CameraInfo.CAMERA_FACING_BACK, switchCamera ? Camera.CameraInfo.CAMERA_FACING_BACK : Camera.CameraInfo.CAMERA_FACING_FRONT, 0, 0); + // 填入在设置界面设置好的配置信息 + boolean enableLive = false; + boolean enableFaceQualityDetect = true; + boolean enableFaceMoveLimit = false; + boolean enableFaceSizeLimit = false; + int faceSizeLimit = 160; + int faceMoveLimit = 20; + int maxDetectFaces = 1; + boolean keepMaxFace = true; + float similarThreshold = 0.8F; + float imageQualityNoMaskRecognizeThreshold = 0.49F; + float imageQualityMaskRecognizeThreshold = 0.29F; + RecognizeConfiguration configuration = new RecognizeConfiguration.Builder().enableFaceMoveLimit(enableFaceMoveLimit).enableFaceSizeLimit(enableFaceSizeLimit).faceSizeLimit(faceSizeLimit).faceMoveLimit(faceMoveLimit).enableLiveness(enableLive).enableImageQuality(enableFaceQualityDetect).maxDetectFaces(maxDetectFaces).keepMaxFace(keepMaxFace).similarThreshold(similarThreshold).imageQualityNoMaskRecognizeThreshold(imageQualityNoMaskRecognizeThreshold).imageQualityMaskRecognizeThreshold(imageQualityMaskRecognizeThreshold).livenessParam(new LivenessParam(0.5F, 0.5F)).build(); + + ftEngine = new FaceEngine(); + ftInitCode.postValue(ftEngine.init(context, DetectMode.ASF_DETECT_MODE_VIDEO, DetectFaceOrientPriority.ASF_OP_ALL_OUT, 1, FaceEngine.ASF_FACE_DETECT)); + maskEngine = new FaceEngine(); + ftInitCode.postValue(maskEngine.init(context, DetectMode.ASF_DETECT_MODE_IMAGE, DetectFaceOrientPriority.ASF_OP_ALL_OUT, 1, FaceEngine.ASF_MASK_DETECT)); + + frEngine = new FaceEngine(); + int frEngineMask = FaceEngine.ASF_FACE_RECOGNITION; + if (enableFaceQualityDetect) { + frEngineMask |= FaceEngine.ASF_IMAGEQUALITY; + } + frInitCode.postValue(frEngine.init(context, DetectMode.ASF_DETECT_MODE_IMAGE, DetectFaceOrientPriority.ASF_OP_0_ONLY, 10, frEngineMask)); + //启用活体检测时,才初始化活体引擎 + if (enableLive) { + flEngine = new FaceEngine(); + int flEngineMask = (livenessType == LivenessType.RGB ? FaceEngine.ASF_LIVENESS : (FaceEngine.ASF_IR_LIVENESS | FaceEngine.ASF_FACE_DETECT)); + flInitCode.postValue(flEngine.init(context, DetectMode.ASF_DETECT_MODE_IMAGE, DetectFaceOrientPriority.ASF_OP_ALL_OUT, 10, flEngineMask)); + LivenessParam livenessParam = new LivenessParam(0.5F, 0.5F); + flEngine.setLivenessParam(livenessParam); + } + recognizeConfiguration.setValue(configuration); + flutterFaceCallbackIntent = new Intent(FaceBrodConstant.FACE_GLOBAL_INTENT); + } + + private void initFaceHelper(Camera.Size lastPreviewSize) { + if (faceHelper == null || lastPreviewSize == null || lastPreviewSize.width != previewSize.width || lastPreviewSize.height != previewSize.height) { + Integer trackedFaceCount = null; + // 记录切换时的人脸序号 + if (faceHelper != null) { + trackedFaceCount = faceHelper.getTrackedFaceCount(); + faceHelper.release(); + } + int horizontalOffset = 0; + int verticalOffset = 0; + int maxDetectFaceNum = 1; + faceHelper = new FaceHelper.Builder().ftEngine(ftEngine).maskEngine(maskEngine).frEngine(frEngine).flEngine(flEngine).frQueueSize(maxDetectFaceNum).flQueueSize(maxDetectFaceNum).previewSize(previewSize).recognizeCallback(this).recognizeConfiguration(recognizeConfiguration.getValue()).trackedFaceCount(trackedFaceCount == null ? 0 : trackedFaceCount).dualCameraFaceInfoTransformer(faceInfo -> { + FaceInfo irFaceInfo = new FaceInfo(faceInfo); + irFaceInfo.getRect().offset(horizontalOffset, verticalOffset); + return irFaceInfo; + }).build(); + } + } + + + // 当相机打开时由activity调用,进行一些初始化操作 + public void onRgbCameraOpened(Camera camera) { + Camera.Size lastPreviewSize = previewSize; + previewSize = camera.getParameters().getPreviewSize(); + // 切换相机的时候可能会导致预览尺寸发生变化 + initFaceHelper(lastPreviewSize); + } + + public Point loadPreviewSize() { + String[] size = new String[]{"1280", "720"}; + return new Point(Integer.parseInt(size[0]), Integer.parseInt(size[1])); + } + + /** + * 传入可见光相机预览数据 + * + * @param nv21 可见光相机预览数据 + * @param doRecognize 是否进行识别 + * @return 当前帧的检测结果信息 + */ + public List onPreviewFrame(byte[] nv21, boolean doRecognize) { + if (faceHelper != null) { + if (!loadFaceList) { + return null; + } + if (livenessType == LivenessType.IR && irNV21 == null) { + return null; + } + return faceHelper.onPreviewFrame(nv21, irNV21, doRecognize); + } + return null; + } + + + /** + * 根据预览信息生成绘制信息 + * + * @param facePreviewInfoList 预览信息 + * @return 绘制信息 + */ + public List getDrawInfo(List facePreviewInfoList, LivenessType livenessType, boolean drawRectInfo) { + List drawInfoList = new ArrayList<>(); + for (int i = 0; i < facePreviewInfoList.size(); i++) { + int trackId = facePreviewInfoList.get(i).getTrackId(); + String name = faceHelper.getName(trackId); + Integer liveness = faceHelper.getLiveness(trackId); + Integer recognizeStatus = faceHelper.getRecognizeStatus(trackId); + + // 根据识别结果和活体结果设置颜色 + int color = RecognizeColor.COLOR_UNKNOWN; + if (recognizeStatus != null) { + if (recognizeStatus == RequestFeatureStatus.FAILED) { + color = RecognizeColor.COLOR_FAILED; + } + if (recognizeStatus == RequestFeatureStatus.SUCCEED) { + color = RecognizeColor.COLOR_SUCCESS; + } + } + if (liveness != null && liveness == LivenessInfo.NOT_ALIVE) { + color = RecognizeColor.COLOR_FAILED; + } + + drawInfoList.add(new FaceRectView.DrawInfo(livenessType == LivenessType.RGB ? facePreviewInfoList.get(i).getRgbTransformedRect() : facePreviewInfoList.get(i).getIrTransformedRect(), GenderInfo.UNKNOWN, AgeInfo.UNKNOWN_AGE, liveness == null ? LivenessInfo.UNKNOWN : liveness, color, name == null ? "" : name, facePreviewInfoList.get(i).getFaceInfoRgb().getIsWithinBoundary(), facePreviewInfoList.get(i).getForeRect(), facePreviewInfoList.get(i).getFaceInfoRgb().getFaceAttributeInfo(), drawRectInfo, livenessType == LivenessType.RGB)); + } + return drawInfoList; + } + + public void destroy() { + if (faceHelper != null) { + faceHelper.release(); + } + // FaceServer.getInstance().release(); + } + + public void stop() { + if (faceHelper != null) { + faceHelper.stop(); + } + } + + // 识别结果回调 + @Override + public void onRecognized(CompareResult compareResult, Integer liveness, boolean similarPass) { + try { + if (!similarPass) return; + HashMap json = new HashMap<>(); + json.put("similar", compareResult.getSimilar()); + json.put("cost", compareResult.getCost()); + if (Objects.nonNull(compareResult.getFaceEntity())) { + FaceEntity face = compareResult.getFaceEntity(); + json.put("faceId", face.getFaceId()); + json.put("sampleId", face.getSampleId()); + json.put("userId", face.getUserId()); + json.put("userName", face.getUserName()); + } + faceDiscern = json; + } catch (Exception e) { + Log.e(TAG, "识别结果回调,错误error:" + e.getMessage(), e); + } + } + + // 提示文字变更的回调 + @Override + public void onNoticeChanged(String notice) { + try { + if (!Objects.equals(notice, "")) return; + if (Objects.isNull(faceDiscern)) return; + if (flutterFaceCallbackIntent == null) { + flutterFaceCallbackIntent = new Intent(FaceBrodConstant.FACE_GLOBAL_INTENT); + } + Log.i(TAG, "人脸识别成功json:" + new Gson().toJson(faceDiscern)); + flutterFaceCallbackIntent.putExtra("json", new Gson().toJson(faceDiscern)); + context.sendBroadcast(flutterFaceCallbackIntent); + faceDiscern = null; + if (context instanceof Activity) { + Thread.sleep(500); + String packageName = context.getPackageName(); + Intent intent = new Intent(); + intent.addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT); + intent.setClassName(packageName, packageName + ".MainActivity"); + context.startActivity(intent); + } + } catch (Exception e) { + Log.e(TAG, "提示文字变更的回调,错误error:" + e.getMessage(), e); + } + } +} diff --git a/android/app/src/main/jniLibs/arm64-v8a/libarcsoft_face.so b/android/app/src/main/jniLibs/arm64-v8a/libarcsoft_face.so new file mode 100644 index 0000000..d473515 Binary files /dev/null and b/android/app/src/main/jniLibs/arm64-v8a/libarcsoft_face.so differ diff --git a/android/app/src/main/jniLibs/arm64-v8a/libarcsoft_face_engine.so b/android/app/src/main/jniLibs/arm64-v8a/libarcsoft_face_engine.so new file mode 100644 index 0000000..62fe98f Binary files /dev/null and b/android/app/src/main/jniLibs/arm64-v8a/libarcsoft_face_engine.so differ diff --git a/android/app/src/main/jniLibs/arm64-v8a/libarcsoft_image_util.so b/android/app/src/main/jniLibs/arm64-v8a/libarcsoft_image_util.so new file mode 100644 index 0000000..490a2e9 Binary files /dev/null and b/android/app/src/main/jniLibs/arm64-v8a/libarcsoft_image_util.so differ diff --git a/android/app/src/main/jniLibs/arm64-v8a/libmsc.so b/android/app/src/main/jniLibs/arm64-v8a/libmsc.so new file mode 100644 index 0000000..a09c4b5 Binary files /dev/null and b/android/app/src/main/jniLibs/arm64-v8a/libmsc.so differ diff --git a/android/app/src/main/jniLibs/arm64-v8a/librknnrt.so b/android/app/src/main/jniLibs/arm64-v8a/librknnrt.so new file mode 100644 index 0000000..dadb2a9 Binary files /dev/null and b/android/app/src/main/jniLibs/arm64-v8a/librknnrt.so differ diff --git a/android/app/src/main/kotlin/com/example/pj_supermarket_cloud/MainActivity.kt b/android/app/src/main/kotlin/com/example/pj_supermarket_cloud/MainActivity.kt new file mode 100644 index 0000000..f90689a --- /dev/null +++ b/android/app/src/main/kotlin/com/example/pj_supermarket_cloud/MainActivity.kt @@ -0,0 +1,117 @@ +package com.example.pj_supermarket_cloud + +import android.content.Context +import android.net.ConnectivityManager +import android.net.NetworkCapabilities +import android.net.TrafficStats +import android.hardware.display.DisplayManager +import android.os.Build +import android.telephony.TelephonyManager +import io.flutter.embedding.android.FlutterActivity +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.plugins.GeneratedPluginRegistrant +import io.flutter.plugins.facediscern.flutter.FlutterFaceDiscernPlugin +import io.flutter.plugin.common.MethodChannel + +class MainActivity : FlutterActivity() { + + private val runtimeChannel = "biz_xfj/runtime_environment" + + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { + super.configureFlutterEngine(flutterEngine) + + // 人脸识别插件 + val flutterFaceDiscernPlugin = FlutterFaceDiscernPlugin() + flutterEngine.plugins.add(flutterFaceDiscernPlugin) + + MethodChannel(flutterEngine.dartExecutor.binaryMessenger, runtimeChannel).setMethodCallHandler { call, result -> + when (call.method) { + "getRuntimeEnvironment" -> { + val displayManager = getSystemService(Context.DISPLAY_SERVICE) as DisplayManager + result.success( + mapOf( + "isEmulator" to isEmulator(), + "displayCount" to displayManager.displays.size + ) + ) + } + "getNetworkSnapshot" -> result.success(getNetworkSnapshot()) + else -> result.notImplemented() + } + } + + GeneratedPluginRegistrant.registerWith(flutterEngine) +// +// +// FlutterSubscreenPlugin.registerThirdPlugins( +// arrayListOf( +// +// ), +// this.flutterEngine!!.plugins +// ) + } + + /** + * 使用 Android Build 特征识别常见虚拟机,避免在虚拟设备上初始化串口、人脸和副屏硬件。 + */ + private fun isEmulator(): Boolean { + return Build.FINGERPRINT.startsWith("generic") || + Build.FINGERPRINT.lowercase().contains("emulator") || + Build.MODEL.contains("google_sdk") || + Build.MODEL.lowercase().contains("emulator") || + Build.MODEL.contains("Android SDK built for x86") || + Build.MANUFACTURER.lowercase().contains("genymotion") || + Build.BRAND.startsWith("generic") && Build.DEVICE.startsWith("generic") || + Build.PRODUCT == "google_sdk" || + Build.PRODUCT.lowercase().contains("sdk_gphone") || + Build.PRODUCT.lowercase().contains("vbox86p") || + Build.HARDWARE.lowercase().contains("goldfish") || + Build.HARDWARE.lowercase().contains("ranchu") + } + + /** + * 返回当前物理网络类型和累计流量。Flutter 侧只计算相邻快照差值,避免测速请求 + * 额外占用收银终端带宽,也不依赖外部测速服务是否可用。 + */ + private fun getNetworkSnapshot(): Map { + val connectivity = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + val capabilities = connectivity.getNetworkCapabilities(connectivity.activeNetwork) + val type = when { + capabilities == null -> "offline" + capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> "ethernet" + capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> "wifi" + capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> mobileNetworkType() + else -> "network" + } + return mapOf( + "type" to type, + "connected" to (capabilities != null), + "rxBytes" to TrafficStats.getTotalRxBytes().coerceAtLeast(0L), + "txBytes" to TrafficStats.getTotalTxBytes().coerceAtLeast(0L) + ) + } + + @Suppress("DEPRECATION") + private fun mobileNetworkType(): String { + return try { + val telephony = getSystemService(Context.TELEPHONY_SERVICE) as TelephonyManager + when (telephony.dataNetworkType) { + TelephonyManager.NETWORK_TYPE_NR -> "5g" + TelephonyManager.NETWORK_TYPE_LTE -> "4g" + TelephonyManager.NETWORK_TYPE_UMTS, + TelephonyManager.NETWORK_TYPE_HSDPA, + TelephonyManager.NETWORK_TYPE_HSUPA, + TelephonyManager.NETWORK_TYPE_HSPA, + TelephonyManager.NETWORK_TYPE_HSPAP -> "3g" + TelephonyManager.NETWORK_TYPE_GPRS, + TelephonyManager.NETWORK_TYPE_EDGE, + TelephonyManager.NETWORK_TYPE_CDMA, + TelephonyManager.NETWORK_TYPE_1xRTT -> "2g" + else -> "mobile" + } + } catch (_: SecurityException) { + "mobile" + } + } + +} diff --git a/android/app/src/main/kotlin/com/example/pj_supermarket_cloud/tool/FlutterRouterManager.kt b/android/app/src/main/kotlin/com/example/pj_supermarket_cloud/tool/FlutterRouterManager.kt new file mode 100644 index 0000000..fe14931 --- /dev/null +++ b/android/app/src/main/kotlin/com/example/pj_supermarket_cloud/tool/FlutterRouterManager.kt @@ -0,0 +1,75 @@ +package com.example.pj_supermarket_cloud.tool + +import android.app.Activity +import io.flutter.embedding.android.FlutterActivity +import io.flutter.embedding.android.FlutterActivityLaunchConfigs +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.embedding.engine.FlutterEngineCache +import io.flutter.embedding.engine.dart.DartExecutor + +class FlutterRouterManager(val targetRoute: String, val mEngineId: String, val mContext: Activity) { + + var mEngine: FlutterEngine? = null + + init { + createCachedEngine() + } + + companion object { + + /** + * 获取缓存中的 Flutter引擎 + */ + @JvmStatic + fun getEngineCacheInstance(engineId: String): FlutterEngine? { + return FlutterEngineCache.getInstance().get(engineId) + } + + } + + /** + * 1、创建Flutter引擎 + * 2、将初始命名路由,修改为目标页面路由 + * 3、缓存Flutter引擎 + */ + fun createCachedEngine(): FlutterEngine { + val flutterEngine = FlutterEngine(mContext) // 创建Flutter引擎 + + // 将初始命名路由,修改为目标页面路由 + flutterEngine.navigationChannel.setInitialRoute(targetRoute) + + // 这一步,是在执行相关Dart文件入口的 main函数,将Flutter页面渲染出结果 + // 原生端获取结果,进行最终渲染上屏 + flutterEngine.dartExecutor.executeDartEntrypoint( + DartExecutor.DartEntrypoint.createDefault() + ) + + // 将加载好的引擎,存储起来 + FlutterEngineCache.getInstance().put(mEngineId, flutterEngine) + + mEngine = flutterEngine + + return flutterEngine + } + + /** + * 根据引擎ID,前往指定的Flutter页面 + */ + fun push() { + // 使用缓存好的引擎(推荐) + mContext.startActivity( + FlutterActivity + .withCachedEngine(mEngineId) + .backgroundMode(FlutterActivityLaunchConfigs.BackgroundMode.transparent) + .build(mContext) + ) + } + + /** + * 销毁当前Flutter引擎 + */ + fun destroy() { + mEngine?.destroy() + } + +} diff --git a/android/app/src/main/res/drawable-v21/launch_background.xml b/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000..f74085f --- /dev/null +++ b/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/android/app/src/main/res/drawable/launch_background.xml b/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..304732f --- /dev/null +++ b/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/android/app/src/main/res/layout/activity_face_discern.xml b/android/app/src/main/res/layout/activity_face_discern.xml new file mode 100644 index 0000000..da8201a --- /dev/null +++ b/android/app/src/main/res/layout/activity_face_discern.xml @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/layout/flutter_presentation_view.xml b/android/app/src/main/res/layout/flutter_presentation_view.xml new file mode 100644 index 0000000..272680d --- /dev/null +++ b/android/app/src/main/res/layout/flutter_presentation_view.xml @@ -0,0 +1,12 @@ + + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..b87267b Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..99791bd Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..7d9779e Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..315d4ac Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..eb3ba2a Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/values-night/styles.xml b/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..ee5a78e --- /dev/null +++ b/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,9 @@ + + + + + diff --git a/android/app/src/main/res/values/attrs.xml b/android/app/src/main/res/values/attrs.xml new file mode 100644 index 0000000..a3133ba --- /dev/null +++ b/android/app/src/main/res/values/attrs.xml @@ -0,0 +1,5 @@ + + + + false + diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..cd6409d --- /dev/null +++ b/android/app/src/main/res/values/styles.xml @@ -0,0 +1,24 @@ + + + + + + + + #0B4D46 + #FFFFFFFF + #C2995C + #E91E63 + #58E35E + #37B13C + #334141 + #AA0B4D46 + #6817023D + + diff --git a/android/app/src/main/res/xml/network_security_config.xml b/android/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 0000000..18a10d4 --- /dev/null +++ b/android/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,7 @@ + + + + + stgl.pj.gov.cn + + diff --git a/android/app/src/profile/AndroidManifest.xml b/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android/build.gradle b/android/build.gradle new file mode 100644 index 0000000..d2ffbff --- /dev/null +++ b/android/build.gradle @@ -0,0 +1,18 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +rootProject.buildDir = "../build" +subprojects { + project.buildDir = "${rootProject.buildDir}/${project.name}" +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean", Delete) { + delete rootProject.buildDir +} diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..2597170 --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,3 @@ +org.gradle.jvmargs=-Xmx4G -XX:MaxMetaspaceSize=2G -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true +android.enableJetifier=true diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..0ce3064 --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +#distributionUrl=https\://services.gradle.org/distributions/gradle-8.3-all.zip +distributionUrl=https\://mirrors.cloud.tencent.com/gradle/gradle-8.3-all.zip diff --git a/android/settings.gradle b/android/settings.gradle new file mode 100644 index 0000000..5c454f5 --- /dev/null +++ b/android/settings.gradle @@ -0,0 +1,28 @@ +pluginManagement { + def flutterSdkPath = { + def properties = new Properties() + file("local.properties").withInputStream { properties.load(it) } + def flutterSdkPath = properties.getProperty("flutter.sdk") + assert flutterSdkPath != null, "flutter.sdk not set in local.properties" + return flutterSdkPath + }() + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + maven { url "https://maven.aliyun.com/repository/google" } + maven { url "https://maven.aliyun.com/repository/central" } + maven { url "https://maven.aliyun.com/repository/gradle-plugin" } + maven { url "https://jitpack.io" } + google() + mavenCentral() + } +} + +plugins { + id "dev.flutter.flutter-plugin-loader" version "1.0.0" + id "com.android.application" version "8.1.0" apply false + id "org.jetbrains.kotlin.android" version "1.8.22" apply false +} + +include ":app" diff --git a/assets/crt/emqxsl-ca.crt b/assets/crt/emqxsl-ca.crt new file mode 100644 index 0000000..8f3aea9 --- /dev/null +++ b/assets/crt/emqxsl-ca.crt @@ -0,0 +1,22 @@ +-----BEGIN CERTIFICATE----- +MIIDrzCCApegAwIBAgIQCDvgVpBCRrGhdWrJWZHHSjANBgkqhkiG9w0BAQUFADBh +MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3 +d3cuZGlnaWNlcnQuY29tMSAwHgYDVQQDExdEaWdpQ2VydCBHbG9iYWwgUm9vdCBD +QTAeFw0wNjExMTAwMDAwMDBaFw0zMTExMTAwMDAwMDBaMGExCzAJBgNVBAYTAlVT +MRUwEwYDVQQKEwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5j +b20xIDAeBgNVBAMTF0RpZ2lDZXJ0IEdsb2JhbCBSb290IENBMIIBIjANBgkqhkiG +9w0BAQEFAAOCAQ8AMIIBCgKCAQEA4jvhEXLeqKTTo1eqUKKPC3eQyaKl7hLOllsB +CSDMAZOnTjC3U/dDxGkAV53ijSLdhwZAAIEJzs4bg7/fzTtxRuLWZscFs3YnFo97 +nh6Vfe63SKMI2tavegw5BmV/Sl0fvBf4q77uKNd0f3p4mVmFaG5cIzJLv07A6Fpt +43C/dxC//AH2hdmoRBBYMql1GNXRor5H4idq9Joz+EkIYIvUX7Q6hL+hqkpMfT7P +T19sdl6gSzeRntwi5m3OFBqOasv+zbMUZBfHWymeMr/y7vrTC0LUq7dBMtoM1O/4 +gdW7jVg/tRvoSSiicNoxBN33shbyTApOB6jtSj1etX+jkMOvJwIDAQABo2MwYTAO +BgNVHQ8BAf8EBAMCAYYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUA95QNVbR +TLtm8KPiGxvDl7I90VUwHwYDVR0jBBgwFoAUA95QNVbRTLtm8KPiGxvDl7I90VUw +DQYJKoZIhvcNAQEFBQADggEBAMucN6pIExIK+t1EnE9SsPTfrgT1eXkIoyQY/Esr +hMAtudXH/vTBH1jLuG2cenTnmCmrEbXjcKChzUyImZOMkXDiqw8cvpOp/2PV5Adg +06O/nVsJ8dWO41P0jmP6P6fbtGbfYmbW0W5BjfIttep3Sp+dWOIrWcBAI+0tKIJF +PnlUkiaY4IBIqDfv8NZ5YBberOgOzW6sRBc4L0na4UU+Krk2U886UAb3LujEV0ls +YSEY1QSteDwsOoBrp+uvFRTp2InBuThs4pFsiv9kuXclVzDAGySj4dzp30d8tbQk +CAUw7C29C79Fv1C5qfPrmAESrciIxpg0X40KPMbp1ZWVbd4= +-----END CERTIFICATE----- \ No newline at end of file diff --git a/assets/images/default_avatar.png b/assets/images/default_avatar.png new file mode 100644 index 0000000..98c75ba Binary files /dev/null and b/assets/images/default_avatar.png differ diff --git a/assets/images/logo.png b/assets/images/logo.png new file mode 100644 index 0000000..476365d Binary files /dev/null and b/assets/images/logo.png differ diff --git a/icons_launcher.yaml b/icons_launcher.yaml new file mode 100644 index 0000000..258cfff --- /dev/null +++ b/icons_launcher.yaml @@ -0,0 +1,5 @@ +icons_launcher: + image_path: "assets/images/logo.png" + platforms: + android: + enable: true \ No newline at end of file diff --git a/lib/app.dart b/lib/app.dart new file mode 100644 index 0000000..7f6a146 --- /dev/null +++ b/lib/app.dart @@ -0,0 +1,178 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_easyloading/flutter_easyloading.dart'; +import 'package:forui/forui.dart'; +import 'package:get/get.dart'; +import 'package:ducafe_ui_core/ducafe_ui_core.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +class MainApp extends StatefulWidget { + const MainApp({super.key}); + + @override + State createState() => _MainAppState(); +} + +class _MainAppState extends State with WidgetsBindingObserver { + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) async { + if (state == AppLifecycleState.paused || + state == AppLifecycleState.detached) { + try { + AppSubScreenTool.hide(); + } catch (_) {} + } + } + + @override + Widget build(BuildContext context) { + return ScreenUtilInit( + designSize: const Size(1920, 1080), + minTextAdapt: true, + splitScreenMode: true, + builder: (_, child) { + return Obx(() { + final appTheme = Get.find(); + final fTheme = appTheme.theme.value; + final sizeScalar = appTheme.sizeScalar.value; + final buttonRadius = appTheme.buttonRadius.value.radius.w; + final cardRadius = appTheme.cardRadius.value.radius.w; + final buttonSizeScale = appTheme.buttonSize.value.scale; + // 新 UI 保留用户字号缩放能力,只统一视觉色板与组件形态。 + final colorScheme = fTheme.colorScheme.copyWith( + background: XfjDesignTokens.surface, + foreground: XfjDesignTokens.ink, + primary: XfjDesignTokens.primary, + primaryForeground: Colors.white, + secondary: XfjDesignTokens.surface, + secondaryForeground: XfjDesignTokens.ink, + muted: XfjDesignTokens.canvas, + mutedForeground: XfjDesignTokens.muted, + destructive: XfjDesignTokens.danger, + destructiveForeground: Colors.white, + error: XfjDesignTokens.danger, + errorForeground: Colors.white, + border: XfjDesignTokens.line, + ); + final inheritedTheme = FThemeData.inherit( + colorScheme: colorScheme, + typography: fTheme.typography.scale(sizeScalar: sizeScalar), + style: fTheme.style.copyWith( + borderRadius: BorderRadius.all( + Radius.circular(buttonRadius), + ), + borderWidth: 1, + iconStyle: fTheme.style.iconStyle.copyWith( + size: fTheme.typography.xl2.fontSize, + ), + pagePadding: EdgeInsets.symmetric(vertical: 5.h, horizontal: 1.w), + ), + ); + final buttonPadding = EdgeInsets.symmetric( + horizontal: 16.w * buttonSizeScale, + vertical: 12.5.h * buttonSizeScale, + ); + final iconPadding = EdgeInsets.all(7.5.w * buttonSizeScale); + final badgePadding = EdgeInsets.symmetric( + horizontal: 14.w * buttonSizeScale, + vertical: 2.h * buttonSizeScale, + ); + final themeData = inheritedTheme.copyWith( + buttonStyles: _buttonStyles( + inheritedTheme.buttonStyles, + buttonPadding, + iconPadding, + ), + badgeStyles: _badgeStyles( + inheritedTheme.badgeStyles, + buttonRadius, + badgePadding, + ), + cardStyle: inheritedTheme.cardStyle.copyWith( + decoration: inheritedTheme.cardStyle.decoration.copyWith( + borderRadius: BorderRadius.circular(cardRadius), + ), + ), + ); + return GetMaterialApp( + title: '收银机', + theme: ThemeData( + scaffoldBackgroundColor: XfjDesignTokens.canvas, + colorScheme: ColorScheme.fromSeed( + seedColor: XfjDesignTokens.primary, + surface: XfjDesignTokens.surface, + ), + bottomSheetTheme: const BottomSheetThemeData( + backgroundColor: Colors.transparent, + ), + ), + defaultTransition: Transition.noTransition, + initialRoute: RouteNames.load, + getPages: RoutePages.list, + enableLog: true, + debugShowCheckedModeBanner: false, + locale: const Locale('zh', 'CN'), + localizationsDelegates: FLocalizations.localizationsDelegates, + supportedLocales: FLocalizations.supportedLocales, + navigatorObservers: [ + GlobalRouteObserver(), + ], + builder: EasyLoading.init( + builder: (_, childSecond) => FTheme( + data: themeData, + child: childSecond!, + ), + ), + ); + }); + }, + ); + } +} + +FButtonStyles _buttonStyles( + FButtonStyles styles, + EdgeInsets padding, + EdgeInsets iconPadding, +) { + FButtonCustomStyle resize(FButtonCustomStyle style) => style.copyWith( + contentStyle: style.contentStyle.copyWith(padding: padding), + iconContentStyle: style.iconContentStyle.copyWith(padding: iconPadding), + ); + return styles.copyWith( + primary: resize(styles.primary), + secondary: resize(styles.secondary), + destructive: resize(styles.destructive), + outline: resize(styles.outline), + ghost: resize(styles.ghost), + ); +} + +FBadgeStyles _badgeStyles( + FBadgeStyles styles, + double radius, + EdgeInsets padding, +) { + FBadgeCustomStyle resize(FBadgeCustomStyle style) => style.copyWith( + borderRadius: BorderRadius.circular(radius), + contentStyle: style.contentStyle.copyWith(padding: padding), + ); + return styles.copyWith( + primary: resize(styles.primary), + secondary: resize(styles.secondary), + outline: resize(styles.outline), + destructive: resize(styles.destructive), + ); +} diff --git a/lib/common/api/app_api.dart b/lib/common/api/app_api.dart new file mode 100644 index 0000000..b7f6791 --- /dev/null +++ b/lib/common/api/app_api.dart @@ -0,0 +1,15 @@ +import 'package:pj_supermarket_cloud/common/index.dart'; + +abstract class AppApi { + /// 获取所有版本列表 + static Future openCardList( + dynamic pa, + ) async { + final res = await HttpManage().dio.get( + "/integrated/app-version/ad/page", + queryParameters: pa, + ); + if (res.data == null) return null; + return ServiceAppVersionPage.fromJson(res.data); + } +} diff --git a/lib/common/api/hard_api.dart b/lib/common/api/hard_api.dart new file mode 100644 index 0000000..276a7cf --- /dev/null +++ b/lib/common/api/hard_api.dart @@ -0,0 +1,190 @@ +import 'package:pj_supermarket_cloud/common/index.dart'; + +abstract class HardApi { + /// 获取硬件配置信息 + static Future bind(dynamic pa) async { + final res = await HttpManage().dio.post( + "/hard/hard-api/ad/bind", + data: pa, + ); + if (res.data == null) return null; + return ServiceHardware.fromJson(res.data); + } + + /// 获取硬件配置信息 + static Future get(dynamic pa) async { + final res = await HttpManage().dio.get( + "/hard/hard-api/ad/get", + queryParameters: pa, + ); + if (res.data == null) return null; + return ServiceHardware.fromJson(res.data); + } + + /// 获取用户信息 + static Future getUserInfoById( + dynamic formData, + ) async { + final res = await HttpManage().dio.get( + "/hard/hard-api/ad/get-user", + queryParameters: formData, + ); + if (res.data == null) return null; + return ServiceUser.fromJson(res.data); + } + + /// 获取用户信息 + static Future getUserByCard( + dynamic formData, + ) async { + final res = await HttpManage().dio.get( + "/hard/hard-api/ad/get-user-by-card", + queryParameters: formData, + ); + if (res.data == null) return null; + return ServiceUser.fromJson(res.data); + } + + /// 创建硬件订单 + static Future createHardwareOrder(dynamic formData) async { + final res = await HttpManage().dio.post( + "/hard/hard-api/ad/create-order", + data: formData, + ); + if (res.data == null) return null; + return res.data; + } + + /// 创建硬件订单 + static Future + createHardQrCodeScanOrder(dynamic formData) async { + final res = await HttpManage().dio.post( + "/hard/hard-api/ad/create-hard-qr-code-order", + data: formData, + ); + if (res.data == null) return null; + return ServiceHardCreateHardQrCodeScanOrder.fromJson(res.data); + } + + /// 根据订单id,获取订单详情 + static Future getOrder(dynamic formData) async { + final res = await HttpManage().dio.get( + "/hard/hard-api/ad/get-order", + queryParameters: formData, + ); + if (res.data == null) return null; + return ServiceHardOrder.fromJson(res.data); + } + + /// 获取订单购物车 + static Future?> getCarByOrderId( + dynamic formData, + ) async { + final res = await HttpManage().dio.get( + "/hard/hard-api/ad/get-by-order-id", + queryParameters: formData, + ); + if (res.data == null) return null; + return res.data?.map((e) => ServiceCar.fromJson(e)).toList(); + } + + /// 核销订单 + static Future cancelQrCode(dynamic formData) async { + final res = await HttpManage().dio.post( + "/hard/hard-api/ad/cancel-code", + data: formData, + ); + if (res.data == null) return false; + return true; + } + + /// 核销活动订单 + static Future cancelActQrCode(dynamic formData) async { + final res = await HttpManage().dio.post( + "/hard/hard-api/ad/cancel-act-code", + data: formData, + ); + if (res.data == null) return false; + return true; + } + + /// 生成发票 + static Future generateInvoice( + dynamic formData, + ) async { + final res = await HttpManage().dio.get( + "/hard/hard-api/ad/generate-invoice", + queryParameters: formData, + ); + if (res.data == null) return null; + return ServiceGenerateInvoice.fromJson(res.data); + } + + /// 打印成功 + static Future printInvoice(dynamic formData) async { + final res = await HttpManage().dio.get( + "/hard/hard-api/ad/print-invoice", + queryParameters: formData, + ); + if (res.data == null) return false; + return true; + } + + /// 离线同步 + static Future?> syncOfflineOrder(dynamic formData) async { + final res = await HttpManage().dio.post( + "/hard/hard-api/ad/sync-offline-order", + data: formData, + ); + if (res.data == null) return null; + return res.data?.map((e) => e.toString()).toList(); + } + + /// 统计每天、每周、每月、每年的交易人数和次数 + static Future chartLatitude( + dynamic formData, + ) async { + final res = await HttpManage().dio.get( + "/hard/hard-api/ad/chart-latitude", + queryParameters: formData, + ); + if (res.data == null) return null; + return ServiceChartLatitude.fromJson(res.data); + } + + /// android查询今日的广播 + static Future broadcastToday( + dynamic formData, + ) async { + final res = await HttpManage().dio.get( + "/hard/hard-broadcast/ad/today", + queryParameters: formData, + data: {"hardId": formData["hardId"]}, + ); + if (res.data == null) return null; + return ServiceBroadcastTodayPage.fromJson(res.data); + } + + /// android完成一轮广播 + static Future broadcast( + dynamic formData, + ) async { + final res = await HttpManage() + .dio + .post("/hard/hard-broadcast/ad/broadcast", data: formData); + if (res.data == null) return null; + return ServiceBroadcastTodayPage.fromJson(res.data); + } + + /// 消费机限时退款 + static Future timeLimitRefund( + dynamic formData, + ) async { + final res = await HttpManage().dio.get( + "/hard/hard-after-sale/ad/time-limit-refund", + queryParameters: formData, + ); + if (res.data == null) return false; + return true; + } +} diff --git a/lib/common/api/index.dart b/lib/common/api/index.dart new file mode 100644 index 0000000..6480e99 --- /dev/null +++ b/lib/common/api/index.dart @@ -0,0 +1,7 @@ +library; + +export './product_api.dart'; +export './hard_api.dart'; +export './user_api.dart'; +export './wallet_api.dart'; +export './app_api.dart'; diff --git a/lib/common/api/product_api.dart b/lib/common/api/product_api.dart new file mode 100644 index 0000000..8e4d927 --- /dev/null +++ b/lib/common/api/product_api.dart @@ -0,0 +1,75 @@ +import 'package:pj_supermarket_cloud/common/index.dart'; + +abstract class ProductApi { + /// 菜品分类 + static Future?> canteenClassify( + dynamic pa, + ) async { + final res = await HttpManage().dio.get( + "/canteen/canteenCategory/getAllSimple", + queryParameters: pa, + ); + if (res.data == null) return null; + return res.data + ?.map((e) => ServiceClassify.fromJson(e)) + .toList(); + } + + /// 菜品商品 + static Future?> canteenProduct( + dynamic pa, + ) async { + final res = await HttpManage().dio.get( + "/canteen/canteenDailyMenu/ad/getCanteenList", + queryParameters: pa, + ); + if (res.data == null) return null; + return res.data + ?.map((e) => ServiceProduct.fromJson(e)) + .toList(); + } + + /// 超市分类 + static Future?> supermarketClassify( + dynamic pa, + ) async { + final res = await HttpManage().dio.get( + "/supermarket/supermarketCategory/simpleAll", + queryParameters: pa, + ); + if (res.data == null) return null; + return res.data + ?.map((e) => ServiceClassify.fromJson(e)) + .toList(); + } + + /// 超市 spu + static Future supermarketSpu( + dynamic pa, + ) async { + final res = await HttpManage().dio.post( + "/supermarket/supermarketSpu/ad/selectListAll", + queryParameters: { + "current": pa["current"] ?? 1, + "size": pa["size"] ?? 100, + }, + data: pa, + ); + if (res.data == null) return null; + return ServiceSupermarketSpuPage.fromJson(res.data); + } + + /// 超市 spu 详情 + static Future?> supermarketSupDetial( + dynamic pa, + ) async { + final res = await HttpManage().dio.get( + "/supermarket/supermarketSpu/ad/SpuDetails", + queryParameters: pa, + ); + if (res.data == null) return null; + return res.data + ?.map((e) => ServiceSupermarketSku.fromJson(e)) + .toList(); + } +} diff --git a/lib/common/api/user_api.dart b/lib/common/api/user_api.dart new file mode 100644 index 0000000..f29f277 --- /dev/null +++ b/lib/common/api/user_api.dart @@ -0,0 +1,82 @@ +import 'package:dio/dio.dart' as dio; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +abstract class UserApi { + /// 硬件管理登录 + static Future login(dynamic pa) async { + final res = await HttpManage().dio.post( + "/auth/oauth2/token", + queryParameters: { + "username": pa["username"], + "randomStr": "", + "code": "", + "grant_type": pa["grant_type"], + "scope": pa["scope"], + }, + data: { + "password": pa["password"], + }, + options: dio.Options( + headers: { + "Authorization": EncryptionTool.getLoginAuthorization(), + "skipToken": true, + "content-type": "application/x-www-form-urlencoded", + }, + ), + ); + return res.data; + } + + /// 刷新token + static Future> refreshToken(dynamic pa) async { + final res = await HttpManage().dio.post( + "/refresh-token", + queryParameters: pa, + ); + if (res.data?.data == null) { + return res.data; + } + final map = res.data!.data as Map; + final typeData = ServiceAuth.fromJson(map); + res.data?.data = typeData; + return res.data; + } + + /// 获取人脸 + static Future?> getFaceList( + dynamic formData, + ) async { + final res = await HttpManage().dio.get( + "/admin/user-face/ad/find-face", + queryParameters: formData, + ); + if (res.data == null) return null; + return res.data + ?.map((e) => ServiceAsyncFace.fromJson(e)) + .toList(); + } + + /// 获取人脸v2 + static Future?> getFaceListV2( + dynamic formData, + ) async { + final res = await HttpManage().dio.get( + "/admin/user-face/ad/v2/find-face", + queryParameters: formData, + ); + if (res.data == null) return null; + return res.data + ?.map((e) => ServiceAsyncFace.fromJson(e)) + .toList(); + } + + /// 获取租户列表 + static Future?> getTenantList() async { + final res = await HttpManage().dio.get("/admin/tenant/list"); + if (res.data == null) return null; + return res.data + ?.map((e) => ServiceTenant.fromJson(e)) + .toList(); + } +} diff --git a/lib/common/api/wallet_api.dart b/lib/common/api/wallet_api.dart new file mode 100644 index 0000000..9bdb9ed --- /dev/null +++ b/lib/common/api/wallet_api.dart @@ -0,0 +1,88 @@ +import 'package:pj_supermarket_cloud/common/index.dart'; + +abstract class WalletApi { + /// 支付配置 + static Future getPaySetting() async { + final res = await HttpManage().dio.get( + "/wallet/wallet-setting/ad/get-setting", + ); + if (res.data == null) return null; + return ServicePaySetting.fromJson(res.data); + } + + /// 开卡列表 + static Future openCardList( + dynamic pa, + ) async { + final res = await HttpManage().dio.get( + "/wallet/wallet-active/ad/open-card-list", + queryParameters: pa, + ); + if (res.data == null) return null; + return ServiceOpenCardPage.fromJson(res.data); + } + + /// 开卡成功 + static Future openCardSuccess(dynamic pa) async { + final res = await HttpManage().dio.get( + "/wallet/wallet-active/ad/open-card-success", + queryParameters: pa, + ); + if (res.data == null) return false; + return true; + } + + /// 重新激活 + static Future refreshAvatar(dynamic pa) async { + final res = await HttpManage().dio.get( + "/wallet/wallet-active/ad/refresh-avatar", + queryParameters: pa, + ); + if (res.data == null) return false; + return true; + } + + /// 根据短码查询加密信息 + static Future getByShortCode(dynamic pa) async { + final res = await HttpManage().dio.get( + "/wallet/wallet-code-use/ad/by-short-code", + queryParameters: pa, + ); + if (res.data == null) return null; + return res.data; + } + + /// 获取钱包规则(android离线计算展示使用) + static Future?> getMchRoleList(dynamic pa) async { + final res = await HttpManage().dio.get( + "/wallet/mch-rule-join/ad/list", + queryParameters: pa, + ); + if (res.data == null) return null; + return res.data + ?.map((e) => ServiceMchRule.fromJson(e)) + .toList(); + } + + /// 获取钱包规则(android离线计算展示使用) + static Future?> getWalletList(dynamic pa) async { + final res = await HttpManage().dio.get( + "/wallet/wallet-manage/ad/wallet/list", + queryParameters: pa, + ); + if (res.data == null) return null; + return res.data + ?.map((e) => ServiceWallet.fromJson(e)) + .toList(); + } + + /// 获取通用活动详情 + static Future getComAct(dynamic pa) async { + final res = await HttpManage().dio.get( + "/wallet/act/com/ad/get-act", + queryParameters: pa, + ); + if (res.data == null) return null; + return ServiceComAct.fromJson(res.data); + } +} diff --git a/lib/common/components/index.dart b/lib/common/components/index.dart new file mode 100644 index 0000000..fa8d106 --- /dev/null +++ b/lib/common/components/index.dart @@ -0,0 +1,3 @@ +library; + +// export './xxxx.dart'; diff --git a/lib/common/extension/index.dart b/lib/common/extension/index.dart new file mode 100644 index 0000000..fa8d106 --- /dev/null +++ b/lib/common/extension/index.dart @@ -0,0 +1,3 @@ +library; + +// export './xxxx.dart'; diff --git a/lib/common/i18n/index.dart b/lib/common/i18n/index.dart new file mode 100644 index 0000000..fa8d106 --- /dev/null +++ b/lib/common/i18n/index.dart @@ -0,0 +1,3 @@ +library; + +// export './xxxx.dart'; diff --git a/lib/common/index.dart b/lib/common/index.dart new file mode 100644 index 0000000..0e997ad --- /dev/null +++ b/lib/common/index.dart @@ -0,0 +1,13 @@ +library; + +export 'api/index.dart'; +export 'components/index.dart'; +export 'extension/index.dart'; +export 'i18n/index.dart'; +export 'models/index.dart'; +export 'routers/index.dart'; +export 'services/index.dart'; +export 'style/index.dart'; +export 'utils/index.dart'; +export 'values/index.dart'; +export 'widgets/index.dart'; diff --git a/lib/common/models/api/app_api_models.dart b/lib/common/models/api/app_api_models.dart new file mode 100644 index 0000000..5581d12 --- /dev/null +++ b/lib/common/models/api/app_api_models.dart @@ -0,0 +1,73 @@ +import 'package:json_annotation/json_annotation.dart'; +import 'package:pj_supermarket_cloud/common/index.dart'; + +part 'app_api_models.g.dart'; + +@JsonSerializable() +class ServiceAppVersion { + /// 唯一标识符 + final String? id; + + /// app唯一编码 + final String? appUniqueCode; + + /// 平台 + final String? platform; + + /// 内部版本号 + final int? versionCode; + + /// 版本名称 + final String? versionName; + + /// 更新内容/更新日志 + final String? updateContent; + + /// 安装包下载地址(wgt、apk) + final String? downloadUrl; + + /// 是否强制更新 + @JsonKey(fromJson: JsonTool.stringToBoolean, toJson: JsonTool.booleanToString) + final bool? isForce; + + /// 是否静默更新(WGT用) + @JsonKey(fromJson: JsonTool.stringToBoolean, toJson: JsonTool.booleanToString) + final bool? isSilent; + + /// 状态(已发布、未发布、已下架) + final String? status; + + ServiceAppVersion({ + this.id, + this.appUniqueCode, + this.platform, + this.versionCode, + this.versionName, + this.updateContent, + this.downloadUrl, + this.isForce, + this.isSilent, + this.status, + }); + + factory ServiceAppVersion.fromJson(Map json) => + _$ServiceAppVersionFromJson(json); + + Map toJson() => _$ServiceAppVersionToJson(this); +} + +@JsonSerializable() +class ServiceAppVersionPage extends ServicePageBase { + List? records; + + ServiceAppVersionPage({ + this.records, + }); + + factory ServiceAppVersionPage.fromJson(Map json) => + _$ServiceAppVersionPageFromJson( + json, + ); + + Map toJson() => _$ServiceAppVersionPageToJson(this); +} diff --git a/lib/common/models/api/app_api_models.g.dart b/lib/common/models/api/app_api_models.g.dart new file mode 100644 index 0000000..18c7197 --- /dev/null +++ b/lib/common/models/api/app_api_models.g.dart @@ -0,0 +1,57 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'app_api_models.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +ServiceAppVersion _$ServiceAppVersionFromJson(Map json) => + ServiceAppVersion( + id: json['id'] as String?, + appUniqueCode: json['appUniqueCode'] as String?, + platform: json['platform'] as String?, + versionCode: (json['versionCode'] as num?)?.toInt(), + versionName: json['versionName'] as String?, + updateContent: json['updateContent'] as String?, + downloadUrl: json['downloadUrl'] as String?, + isForce: JsonTool.stringToBoolean(json['isForce'] as String?), + isSilent: JsonTool.stringToBoolean(json['isSilent'] as String?), + status: json['status'] as String?, + ); + +Map _$ServiceAppVersionToJson(ServiceAppVersion instance) => + { + 'id': instance.id, + 'appUniqueCode': instance.appUniqueCode, + 'platform': instance.platform, + 'versionCode': instance.versionCode, + 'versionName': instance.versionName, + 'updateContent': instance.updateContent, + 'downloadUrl': instance.downloadUrl, + 'isForce': JsonTool.booleanToString(instance.isForce), + 'isSilent': JsonTool.booleanToString(instance.isSilent), + 'status': instance.status, + }; + +ServiceAppVersionPage _$ServiceAppVersionPageFromJson( + Map json) => + ServiceAppVersionPage( + records: (json['records'] as List?) + ?.map((e) => ServiceAppVersion.fromJson(e as Map)) + .toList(), + ) + ..current = (json['current'] as num).toInt() + ..pages = (json['pages'] as num).toInt() + ..size = (json['size'] as num).toInt() + ..total = (json['total'] as num).toInt(); + +Map _$ServiceAppVersionPageToJson( + ServiceAppVersionPage instance) => + { + 'current': instance.current, + 'pages': instance.pages, + 'size': instance.size, + 'total': instance.total, + 'records': instance.records, + }; diff --git a/lib/common/models/api/base_api_models.dart b/lib/common/models/api/base_api_models.dart new file mode 100644 index 0000000..fcdab14 --- /dev/null +++ b/lib/common/models/api/base_api_models.dart @@ -0,0 +1,41 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'base_api_models.g.dart'; + +@JsonSerializable() +class ServiceBase { + String? msg; + int? code; + dynamic data; + + ServiceBase({ + this.msg, + this.code, + this.data, + }); + + factory ServiceBase.fromJson(Map json) => + _$ServiceBaseFromJson(json); + + Map toJson() => _$ServiceBaseToJson(this); +} + +@JsonSerializable() +class ServicePageBase { + int current; + int pages; + int size; + int total; + + ServicePageBase({ + this.current = 1, + this.pages = 1, + this.size = 10, + this.total = 0, + }); + + factory ServicePageBase.fromJson(Map json) => + _$ServicePageBaseFromJson(json); + + Map toJson() => _$ServicePageBaseToJson(this); +} diff --git a/lib/common/models/api/base_api_models.g.dart b/lib/common/models/api/base_api_models.g.dart new file mode 100644 index 0000000..bcc04e7 --- /dev/null +++ b/lib/common/models/api/base_api_models.g.dart @@ -0,0 +1,37 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'base_api_models.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +ServiceBase _$ServiceBaseFromJson(Map json) => + ServiceBase( + msg: json['msg'] as String?, + code: (json['code'] as num?)?.toInt(), + data: json['data'], + ); + +Map _$ServiceBaseToJson(ServiceBase instance) => + { + 'msg': instance.msg, + 'code': instance.code, + 'data': instance.data, + }; + +ServicePageBase _$ServicePageBaseFromJson(Map json) => + ServicePageBase( + current: (json['current'] as num?)?.toInt() ?? 1, + pages: (json['pages'] as num?)?.toInt() ?? 1, + size: (json['size'] as num?)?.toInt() ?? 10, + total: (json['total'] as num?)?.toInt() ?? 0, + ); + +Map _$ServicePageBaseToJson(ServicePageBase instance) => + { + 'current': instance.current, + 'pages': instance.pages, + 'size': instance.size, + 'total': instance.total, + }; diff --git a/lib/common/models/api/hard_api_models.dart b/lib/common/models/api/hard_api_models.dart new file mode 100644 index 0000000..52fe46d --- /dev/null +++ b/lib/common/models/api/hard_api_models.dart @@ -0,0 +1,522 @@ +import 'package:json_annotation/json_annotation.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +part 'hard_api_models.g.dart'; + +@JsonSerializable() +class ServiceHardware extends ServiceBase { + /// 编号 + final String? id; + + /// 设备名 + final String? name; + + /// 设备类型 + final String? type; + + /// 设备类型enum + final String? typeEnum; + + /// 商户id + final String? merchantId; + + /// 商户部门id + final String? merchantDeptId; + + /// 硬件分组id + final String? hardGroupId; + + /// 商户名 + final String? merchantStr; + + /// mq连接id + final String? mqClientId; + + /// android唯一id + final String? uniqueCode; + + /// 当前设备的topic + final String? mainTopic; + + /// mq地址 + final String? mqSocketAddress; + + /// mq端口 + final String? mqSocketPort; + + /// mq用户名 + final String? mqUsername; + + /// mq密码 + final String? mqPassword; + + /// 同步次数版本 + final int? mqSyncCount; + + /// 人脸appId + final String? faceAppId; + + /// 人脸sdkKey + final String? faceSdkKey; + + /// 人脸activeKey + final String? faceActiveKey; + + ServiceHardware({ + this.id, + this.name, + this.type, + this.typeEnum, + this.merchantId, + this.merchantDeptId, + this.hardGroupId, + this.merchantStr, + this.mqClientId, + this.uniqueCode, + this.mainTopic, + this.mqSocketAddress, + this.mqSocketPort, + this.mqUsername, + this.mqPassword, + this.mqSyncCount, + this.faceAppId, + this.faceSdkKey, + this.faceActiveKey, + }); + + factory ServiceHardware.fromJson(Map json) => + _$ServiceHardwareFromJson(json); + + @override + Map toJson() => _$ServiceHardwareToJson(this); +} + +@JsonSerializable() +class ServiceCar { + /// 编号 + final String? id; + + /// 父购物车id + final String? parentCarId; + + /// 商品名 + final String? productName; + + /// 商品价格 + final int? productPrice; + + /// 商品id + final String? productId; + + /// 数量 + final int? productCount; + + /// 总价(分) + @JsonKey(fromJson: JsonTool.stringToInt, toJson: JsonTool.intToString) + final int? totalPrice; + + /// 商品图片 + final String? productPic; + + ServiceCar({ + this.id, + this.parentCarId, + this.productName, + this.productPrice, + this.productId, + this.productCount, + this.totalPrice, + this.productPic, + }); + + factory ServiceCar.fromJson(Map json) => + _$ServiceCarFromJson(json); + + Map toJson() => _$ServiceCarToJson(this); +} + +@JsonSerializable() +class ServiceGenerateInvoice { + /// 图片 + final String? pic; + + /// 记录id + final String? historyId; + + ServiceGenerateInvoice({ + this.pic, + this.historyId, + }); + + factory ServiceGenerateInvoice.fromJson(Map json) => + _$ServiceGenerateInvoiceFromJson(json); + + Map toJson() => _$ServiceGenerateInvoiceToJson(this); +} + +@JsonSerializable() +class ServiceQRCode { + /// 总id + @JsonKey(fromJson: JsonTool.intToString, toJson: JsonTool.stringToInt) + final String? id; + + /// 子id + @JsonKey(fromJson: JsonTool.intToString, toJson: JsonTool.stringToInt) + final String? itemId; + + /// 类型id 比如钱包类型 + @JsonKey(fromJson: JsonTool.intToString, toJson: JsonTool.stringToInt) + final String? typeId; + + /// 用户id + @JsonKey(fromJson: JsonTool.intToString, toJson: JsonTool.stringToInt) + final String? userId; + + /// 订单类型 + @JsonKey( + fromJson: JsonTool.stringToOrderType, + toJson: JsonTool.orderTypeToString, + ) + final OrderType? orderType; + + /// 支付类型 + @JsonKey( + fromJson: JsonTool.stringToOrderPayType, + toJson: JsonTool.orderPayTypeToString, + ) + final OrderPayType? payType; + + /// 过期时间 -1 代表没有限制 + final int? exceedTime; + + /// 额外的用户信息 + final String? extra; + + ServiceQRCode({ + this.id, + this.itemId, + this.typeId, + this.userId, + this.orderType, + this.payType, + this.exceedTime, + this.extra, + }); + + factory ServiceQRCode.fromJson(Map json) => + _$ServiceQRCodeFromJson(json); + + Map toJson() => _$ServiceQRCodeToJson(this); +} + +@JsonSerializable() +class ServiceHardOrder { + /// 用户id + final String? userId; + + /// 用户名 + final String? username; + + /// 用户手机号 + final String? userMobile; + + /// 用户头像 + final String? userAvatar; + + /// 订单类型 + final String? orderType; + + /// 订单状态 + final String? orderStatus; + + /// 支付方式 + final String? payType; + + /// 支付方式 + final String? walletId; + + ServiceHardOrder({ + this.userId, + this.username, + this.userMobile, + this.userAvatar, + this.orderType, + this.orderStatus, + this.payType, + this.walletId, + }); + + factory ServiceHardOrder.fromJson(Map json) => + _$ServiceHardOrderFromJson(json); + + Map toJson() => _$ServiceHardOrderToJson(this); +} + +@JsonSerializable() +class ServiceOpenCard { + /// 用户名 + String? username; + + /// 用户手机号 + String? mobile; + + /// 用户头像 + String? avatar; + + /// 饭卡状态 + String? cardStatus; + + /// 编号 + String? id; + + /// 部门名 + String? deptId; + + /// 部门名 + String? deptName; + + /// 用户编号 + String? userId; + + /// 饭卡类型Id + String? walletTypeId; + + /// 饭卡类型 + String? walletTypeUniqueCode; + + /// 饭卡图标 + String? walletTypeIcon; + + /// 用户卡号 + String? cardNumber; + + /// 钱包编号 + String? walletId; + + /// 是否激活 + @JsonKey(fromJson: JsonTool.stringToBoolean, toJson: JsonTool.booleanToString) + bool? isActiveHardware; + + ServiceOpenCard({ + this.username, + this.mobile, + this.avatar, + this.cardStatus, + this.id, + this.deptId, + this.deptName, + this.userId, + this.walletTypeId, + this.walletTypeUniqueCode, + this.walletTypeIcon, + this.cardNumber, + this.walletId, + this.isActiveHardware, + }); + + factory ServiceOpenCard.fromJson(Map json) => + _$ServiceOpenCardFromJson(json); + + Map toJson() => _$ServiceOpenCardToJson(this); +} + +@JsonSerializable() +class ServiceOpenCardPage extends ServicePageBase { + List? records; + + ServiceOpenCardPage({ + this.records, + }); + + factory ServiceOpenCardPage.fromJson(Map json) => + _$ServiceOpenCardPageFromJson( + json, + ); + + Map toJson() => _$ServiceOpenCardPageToJson(this); +} + +@JsonSerializable() +class ServiceChartLatitude { + /// 早餐、中餐、晚餐 + final List dayTotal; + + /// 每天的交易量 + final List weekTotal; + + /// 每天的交易金额 + final List monthTotal; + + /// 每周的交易量 + final List yearTotal; + + /// 每周的交易量 + final List allTotal; + + ServiceChartLatitude({ + required this.dayTotal, + required this.weekTotal, + required this.monthTotal, + required this.yearTotal, + required this.allTotal, + }); + + factory ServiceChartLatitude.fromJson(Map json) => + _$ServiceChartLatitudeFromJson( + json, + ); + + Map toJson() => _$ServiceChartLatitudeToJson(this); +} + +@JsonSerializable() +class ServiceChartLatitudeItem { + /// 早餐、中餐、晚餐 + final String? hourType; + + /// 每月的交易量 + final String? deptId; + + /// 每月的交易量 + final String? sum; + + /// 每月的交易量 + final String? count; + + ServiceChartLatitudeItem({ + this.hourType, + this.deptId, + this.sum, + this.count, + }); + + factory ServiceChartLatitudeItem.fromJson(Map json) => + _$ServiceChartLatitudeItemFromJson( + json, + ); + + Map toJson() => _$ServiceChartLatitudeItemToJson(this); +} + +@JsonSerializable() +class ServiceBroadcastToday { + /// 广播id + final String id; + + /// 预定用户 + final String userId; + + /// 用户名 + final String username; + + /// 用户手机号 + final String userMobile; + + /// 硬件id + final String hardId; + + /// mq主题 + final String topic; + + /// 业务类型 + final String? bizType; + + /// 关联业务编号 + final String? bizId; + + /// 预定标题 + final String title; + + /// 预定内容 + final String content; + + /// 当前广播次数 + final int? broadcastCount; + + /// 最大广播次数 + final int? broadcastMaxCount; + + /// 是否广播语音 + @JsonKey(fromJson: JsonTool.stringToBoolean, toJson: JsonTool.booleanToString) + final bool isBroadcast; + + /// 用餐时间段 + final String? hourType; + + /// 商品介绍 + String productDesc; + + /// 支付价格 + int payPrice; + + /// 创建时间 + String createTime; + + ServiceBroadcastToday({ + required this.id, + required this.userId, + required this.username, + required this.userMobile, + required this.hardId, + required this.topic, + this.bizType, + this.bizId, + required this.title, + required this.content, + this.broadcastCount, + this.broadcastMaxCount, + required this.isBroadcast, + this.hourType, + required this.productDesc, + required this.payPrice, + required this.createTime, + }); + + factory ServiceBroadcastToday.fromJson(Map json) => + _$ServiceBroadcastTodayFromJson( + json, + ); + + Map toJson() => _$ServiceBroadcastTodayToJson(this); +} + +@JsonSerializable() +class ServiceBroadcastTodayPage extends ServicePageBase { + List? records; + ServiceBroadcastTodayPage({ + this.records, + }); + factory ServiceBroadcastTodayPage.fromJson(Map json) => + _$ServiceBroadcastTodayPageFromJson( + json, + ); + Map toJson() => _$ServiceBroadcastTodayPageToJson(this); +} + +@JsonSerializable() +class ServiceHardCreateHardQrCodeScanOrder { + /// 订单id + String orderId; + + /// 订单id + String mchId; + + /// 订单id + String qrCodeData; + + ServiceHardCreateHardQrCodeScanOrder({ + required this.orderId, + required this.mchId, + required this.qrCodeData, + }); + + factory ServiceHardCreateHardQrCodeScanOrder.fromJson( + Map json) => + _$ServiceHardCreateHardQrCodeScanOrderFromJson( + json, + ); + + Map toJson() => + _$ServiceHardCreateHardQrCodeScanOrderToJson(this); +} diff --git a/lib/common/models/api/hard_api_models.g.dart b/lib/common/models/api/hard_api_models.g.dart new file mode 100644 index 0000000..b5dbd3d --- /dev/null +++ b/lib/common/models/api/hard_api_models.g.dart @@ -0,0 +1,338 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'hard_api_models.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +ServiceHardware _$ServiceHardwareFromJson(Map json) => + ServiceHardware( + id: json['id'] as String?, + name: json['name'] as String?, + type: json['type'] as String?, + typeEnum: json['typeEnum'] as String?, + merchantId: json['merchantId'] as String?, + merchantDeptId: json['merchantDeptId'] as String?, + hardGroupId: json['hardGroupId'] as String?, + merchantStr: json['merchantStr'] as String?, + mqClientId: json['mqClientId'] as String?, + uniqueCode: json['uniqueCode'] as String?, + mainTopic: json['mainTopic'] as String?, + mqSocketAddress: json['mqSocketAddress'] as String?, + mqSocketPort: json['mqSocketPort'] as String?, + mqUsername: json['mqUsername'] as String?, + mqPassword: json['mqPassword'] as String?, + mqSyncCount: (json['mqSyncCount'] as num?)?.toInt(), + faceAppId: json['faceAppId'] as String?, + faceSdkKey: json['faceSdkKey'] as String?, + faceActiveKey: json['faceActiveKey'] as String?, + ) + ..msg = json['msg'] as String? + ..code = (json['code'] as num?)?.toInt() + ..data = json['data']; + +Map _$ServiceHardwareToJson(ServiceHardware instance) => + { + 'msg': instance.msg, + 'code': instance.code, + 'data': instance.data, + 'id': instance.id, + 'name': instance.name, + 'type': instance.type, + 'typeEnum': instance.typeEnum, + 'merchantId': instance.merchantId, + 'merchantDeptId': instance.merchantDeptId, + 'hardGroupId': instance.hardGroupId, + 'merchantStr': instance.merchantStr, + 'mqClientId': instance.mqClientId, + 'uniqueCode': instance.uniqueCode, + 'mainTopic': instance.mainTopic, + 'mqSocketAddress': instance.mqSocketAddress, + 'mqSocketPort': instance.mqSocketPort, + 'mqUsername': instance.mqUsername, + 'mqPassword': instance.mqPassword, + 'mqSyncCount': instance.mqSyncCount, + 'faceAppId': instance.faceAppId, + 'faceSdkKey': instance.faceSdkKey, + 'faceActiveKey': instance.faceActiveKey, + }; + +ServiceCar _$ServiceCarFromJson(Map json) => ServiceCar( + id: json['id'] as String?, + parentCarId: json['parentCarId'] as String?, + productName: json['productName'] as String?, + productPrice: (json['productPrice'] as num?)?.toInt(), + productId: json['productId'] as String?, + productCount: (json['productCount'] as num?)?.toInt(), + totalPrice: JsonTool.stringToInt(json['totalPrice'] as String?), + productPic: json['productPic'] as String?, + ); + +Map _$ServiceCarToJson(ServiceCar instance) => + { + 'id': instance.id, + 'parentCarId': instance.parentCarId, + 'productName': instance.productName, + 'productPrice': instance.productPrice, + 'productId': instance.productId, + 'productCount': instance.productCount, + 'totalPrice': JsonTool.intToString(instance.totalPrice), + 'productPic': instance.productPic, + }; + +ServiceGenerateInvoice _$ServiceGenerateInvoiceFromJson( + Map json) => + ServiceGenerateInvoice( + pic: json['pic'] as String?, + historyId: json['historyId'] as String?, + ); + +Map _$ServiceGenerateInvoiceToJson( + ServiceGenerateInvoice instance) => + { + 'pic': instance.pic, + 'historyId': instance.historyId, + }; + +ServiceQRCode _$ServiceQRCodeFromJson(Map json) => + ServiceQRCode( + id: JsonTool.intToString((json['id'] as num?)?.toInt()), + itemId: JsonTool.intToString((json['itemId'] as num?)?.toInt()), + typeId: JsonTool.intToString((json['typeId'] as num?)?.toInt()), + userId: JsonTool.intToString((json['userId'] as num?)?.toInt()), + orderType: JsonTool.stringToOrderType(json['orderType'] as String?), + payType: JsonTool.stringToOrderPayType(json['payType'] as String?), + exceedTime: (json['exceedTime'] as num?)?.toInt(), + extra: json['extra'] as String?, + ); + +Map _$ServiceQRCodeToJson(ServiceQRCode instance) => + { + 'id': JsonTool.stringToInt(instance.id), + 'itemId': JsonTool.stringToInt(instance.itemId), + 'typeId': JsonTool.stringToInt(instance.typeId), + 'userId': JsonTool.stringToInt(instance.userId), + 'orderType': JsonTool.orderTypeToString(instance.orderType), + 'payType': JsonTool.orderPayTypeToString(instance.payType), + 'exceedTime': instance.exceedTime, + 'extra': instance.extra, + }; + +ServiceHardOrder _$ServiceHardOrderFromJson(Map json) => + ServiceHardOrder( + userId: json['userId'] as String?, + username: json['username'] as String?, + userMobile: json['userMobile'] as String?, + userAvatar: json['userAvatar'] as String?, + orderType: json['orderType'] as String?, + orderStatus: json['orderStatus'] as String?, + payType: json['payType'] as String?, + walletId: json['walletId'] as String?, + ); + +Map _$ServiceHardOrderToJson(ServiceHardOrder instance) => + { + 'userId': instance.userId, + 'username': instance.username, + 'userMobile': instance.userMobile, + 'userAvatar': instance.userAvatar, + 'orderType': instance.orderType, + 'orderStatus': instance.orderStatus, + 'payType': instance.payType, + 'walletId': instance.walletId, + }; + +ServiceOpenCard _$ServiceOpenCardFromJson(Map json) => + ServiceOpenCard( + username: json['username'] as String?, + mobile: json['mobile'] as String?, + avatar: json['avatar'] as String?, + cardStatus: json['cardStatus'] as String?, + id: json['id'] as String?, + deptId: json['deptId'] as String?, + deptName: json['deptName'] as String?, + userId: json['userId'] as String?, + walletTypeId: json['walletTypeId'] as String?, + walletTypeUniqueCode: json['walletTypeUniqueCode'] as String?, + walletTypeIcon: json['walletTypeIcon'] as String?, + cardNumber: json['cardNumber'] as String?, + walletId: json['walletId'] as String?, + isActiveHardware: + JsonTool.stringToBoolean(json['isActiveHardware'] as String?), + ); + +Map _$ServiceOpenCardToJson(ServiceOpenCard instance) => + { + 'username': instance.username, + 'mobile': instance.mobile, + 'avatar': instance.avatar, + 'cardStatus': instance.cardStatus, + 'id': instance.id, + 'deptId': instance.deptId, + 'deptName': instance.deptName, + 'userId': instance.userId, + 'walletTypeId': instance.walletTypeId, + 'walletTypeUniqueCode': instance.walletTypeUniqueCode, + 'walletTypeIcon': instance.walletTypeIcon, + 'cardNumber': instance.cardNumber, + 'walletId': instance.walletId, + 'isActiveHardware': JsonTool.booleanToString(instance.isActiveHardware), + }; + +ServiceOpenCardPage _$ServiceOpenCardPageFromJson(Map json) => + ServiceOpenCardPage( + records: (json['records'] as List?) + ?.map((e) => ServiceOpenCard.fromJson(e as Map)) + .toList(), + ) + ..current = (json['current'] as num).toInt() + ..pages = (json['pages'] as num).toInt() + ..size = (json['size'] as num).toInt() + ..total = (json['total'] as num).toInt(); + +Map _$ServiceOpenCardPageToJson( + ServiceOpenCardPage instance) => + { + 'current': instance.current, + 'pages': instance.pages, + 'size': instance.size, + 'total': instance.total, + 'records': instance.records, + }; + +ServiceChartLatitude _$ServiceChartLatitudeFromJson( + Map json) => + ServiceChartLatitude( + dayTotal: (json['dayTotal'] as List) + .map((e) => + ServiceChartLatitudeItem.fromJson(e as Map)) + .toList(), + weekTotal: (json['weekTotal'] as List) + .map((e) => + ServiceChartLatitudeItem.fromJson(e as Map)) + .toList(), + monthTotal: (json['monthTotal'] as List) + .map((e) => + ServiceChartLatitudeItem.fromJson(e as Map)) + .toList(), + yearTotal: (json['yearTotal'] as List) + .map((e) => + ServiceChartLatitudeItem.fromJson(e as Map)) + .toList(), + allTotal: (json['allTotal'] as List) + .map((e) => + ServiceChartLatitudeItem.fromJson(e as Map)) + .toList(), + ); + +Map _$ServiceChartLatitudeToJson( + ServiceChartLatitude instance) => + { + 'dayTotal': instance.dayTotal, + 'weekTotal': instance.weekTotal, + 'monthTotal': instance.monthTotal, + 'yearTotal': instance.yearTotal, + 'allTotal': instance.allTotal, + }; + +ServiceChartLatitudeItem _$ServiceChartLatitudeItemFromJson( + Map json) => + ServiceChartLatitudeItem( + hourType: json['hourType'] as String?, + deptId: json['deptId'] as String?, + sum: json['sum'] as String?, + count: json['count'] as String?, + ); + +Map _$ServiceChartLatitudeItemToJson( + ServiceChartLatitudeItem instance) => + { + 'hourType': instance.hourType, + 'deptId': instance.deptId, + 'sum': instance.sum, + 'count': instance.count, + }; + +ServiceBroadcastToday _$ServiceBroadcastTodayFromJson( + Map json) => + ServiceBroadcastToday( + id: json['id'] as String, + userId: json['userId'] as String, + username: json['username'] as String, + userMobile: json['userMobile'] as String, + hardId: json['hardId'] as String, + topic: json['topic'] as String, + bizType: json['bizType'] as String?, + bizId: json['bizId'] as String?, + title: json['title'] as String, + content: json['content'] as String, + broadcastCount: (json['broadcastCount'] as num?)?.toInt(), + broadcastMaxCount: (json['broadcastMaxCount'] as num?)?.toInt(), + isBroadcast: JsonTool.stringToBoolean(json['isBroadcast'] as String?), + hourType: json['hourType'] as String?, + productDesc: json['productDesc'] as String, + payPrice: (json['payPrice'] as num).toInt(), + createTime: json['createTime'] as String, + ); + +Map _$ServiceBroadcastTodayToJson( + ServiceBroadcastToday instance) => + { + 'id': instance.id, + 'userId': instance.userId, + 'username': instance.username, + 'userMobile': instance.userMobile, + 'hardId': instance.hardId, + 'topic': instance.topic, + 'bizType': instance.bizType, + 'bizId': instance.bizId, + 'title': instance.title, + 'content': instance.content, + 'broadcastCount': instance.broadcastCount, + 'broadcastMaxCount': instance.broadcastMaxCount, + 'isBroadcast': JsonTool.booleanToString(instance.isBroadcast), + 'hourType': instance.hourType, + 'productDesc': instance.productDesc, + 'payPrice': instance.payPrice, + 'createTime': instance.createTime, + }; + +ServiceBroadcastTodayPage _$ServiceBroadcastTodayPageFromJson( + Map json) => + ServiceBroadcastTodayPage( + records: (json['records'] as List?) + ?.map( + (e) => ServiceBroadcastToday.fromJson(e as Map)) + .toList(), + ) + ..current = (json['current'] as num).toInt() + ..pages = (json['pages'] as num).toInt() + ..size = (json['size'] as num).toInt() + ..total = (json['total'] as num).toInt(); + +Map _$ServiceBroadcastTodayPageToJson( + ServiceBroadcastTodayPage instance) => + { + 'current': instance.current, + 'pages': instance.pages, + 'size': instance.size, + 'total': instance.total, + 'records': instance.records, + }; + +ServiceHardCreateHardQrCodeScanOrder + _$ServiceHardCreateHardQrCodeScanOrderFromJson(Map json) => + ServiceHardCreateHardQrCodeScanOrder( + orderId: json['orderId'] as String, + mchId: json['mchId'] as String, + qrCodeData: json['qrCodeData'] as String, + ); + +Map _$ServiceHardCreateHardQrCodeScanOrderToJson( + ServiceHardCreateHardQrCodeScanOrder instance) => + { + 'orderId': instance.orderId, + 'mchId': instance.mchId, + 'qrCodeData': instance.qrCodeData, + }; diff --git a/lib/common/models/api/product_api_models.dart b/lib/common/models/api/product_api_models.dart new file mode 100644 index 0000000..c55724f --- /dev/null +++ b/lib/common/models/api/product_api_models.dart @@ -0,0 +1,332 @@ +import 'package:json_annotation/json_annotation.dart'; +import 'package:pj_supermarket_cloud/common/index.dart'; + +part 'product_api_models.g.dart'; + +@JsonSerializable() +class ServiceClassify { + /// 分类编号 + String? id; + + /// 分类名称 + String? name; + + /// 移动端分类图 + String? picUrl; + + /// 分类排序 + int? sort; + + /// 开启状态 + int? status; + + /// 系统类型 + int? systemType; + + /// 用餐类型,早餐、中餐、晚餐 + String? categoryType; + + ServiceClassify({ + this.id, + this.name, + this.picUrl, + this.sort, + this.status, + this.systemType, + this.categoryType, + }); + + factory ServiceClassify.fromJson(Map json) => + _$ServiceClassifyFromJson(json); + + Map toJson() => _$ServiceClassifyToJson(this); +} + +@JsonSerializable() +class ServiceProduct { + /// id + String? id; + + /// 菜品id + String? spuId; + + /// 时间类型 (食堂特有) + @JsonKey(fromJson: JsonTool.anyToString) + String? hourType; + + /// 菜品分类编号 + String? categoryId; + + /// 商品名 + String? name; + + /// 商品图片 + String? picUrl; + + /// 价格分 + int? price; + + /// 排序 + int? sort; + + /// 用餐类型,早餐、中餐、晚餐 + String? categoryType; + + /// 菜品上架日期 + DateTime? saleDate; + + /// 是否涨价 + @JsonKey(fromJson: JsonTool.stringToBoolean, toJson: JsonTool.booleanToString) + bool? isPriceIncreased; + + ServiceProduct({ + this.id, + this.spuId, + this.categoryId, + this.name, + this.picUrl, + this.hourType, + this.sort, + this.categoryType, + this.saleDate, + }); + + factory ServiceProduct.fromJson(Map json) => + _$ServiceProductFromJson(json); + + Map toJson() => _$ServiceProductToJson(this); +} + +// ************************************************************************** +// 超市 spu start +// ************************************************************************** +@JsonSerializable() +class ServiceSupermarketSpu { + /// id + String? id; + + /// 货号 + String? productCode; + + /// 商品名称 + String? productName; + + /// 单位 + String? unit; + + /// 规格 + String? spec; + + /// 商品分类编号 + String? categoryId; + + /// 商品封面图 + String? picUrl; + + /// 剩余库存 + double? stock; + + /// 商品性质(字典) + String? productNature; + + /// 零售价 + int? retailPrice; + + /// 促销价 + int? promotionPrice; + + /// 销量 + int? salesCount; + + ServiceSupermarketSpu({ + this.id, + this.productCode, + this.productName, + this.unit, + this.spec, + this.categoryId, + this.picUrl, + this.stock, + this.productNature, + this.retailPrice, + this.promotionPrice, + this.salesCount, + }); + + factory ServiceSupermarketSpu.fromJson(Map json) => + _$ServiceSupermarketSpuFromJson(json); + + Map toJson() => _$ServiceSupermarketSpuToJson(this); +} + +@JsonSerializable() +class ServiceSupermarketSpuPage extends ServicePageBase { + List? records; + + ServiceSupermarketSpuPage({ + this.records, + }); + + factory ServiceSupermarketSpuPage.fromJson(Map json) => + _$ServiceSupermarketSpuPageFromJson(json); + + Map toJson() => _$ServiceSupermarketSpuPageToJson(this); +} + +// ************************************************************************** +// 超市 spu end +// ************************************************************************** + +// ************************************************************************** +// 超市 sku start +// ************************************************************************** +@JsonSerializable() +class ServiceSupermarketSku { + /// 商品 SPU 编号,自增 + String? id; + + /// 货号 + String? productCode; + + /// 商品名称 + String? productName; + + /// 商品分类编号 + String? categoryId; + + /// 商品封面图 + String? picUrl; + + /// 剩余库存 + double? stock; + + /// 商户编号 + String? merchantId; + + /// 产地 + String? origin; + + /// 商品性质(字典) + String? productNature; + + /// 品牌id + String? brandId; + + /// 货商id + String? supplierId; + + /// 销量 + int? salesCount; + + /// 辅助条码集合 + List? supermarketAuxiliaryCodeEntityList; + + /// 包装对象集合 + List? supermarketUnitEntityList; + + ServiceSupermarketSku({ + this.id, + this.productCode, + this.productName, + this.categoryId, + this.picUrl, + this.stock, + this.merchantId, + this.origin, + this.productNature, + this.brandId, + this.supplierId, + this.salesCount, + this.supermarketAuxiliaryCodeEntityList, + this.supermarketUnitEntityList, + }); + + factory ServiceSupermarketSku.fromJson(Map json) => + _$ServiceSupermarketSkuFromJson(json); + + Map toJson() => _$ServiceSupermarketSkuToJson(this); +} + +// 辅助条码 +@JsonSerializable() +class ServiceSupermarketSkuCode { + /// 辅助条码id + String? id; + + /// spu Id + String? spuId; + + /// 包装单位id + String? unitId; + + /// 条码 + String? barCode; + + ServiceSupermarketSkuCode({ + this.id, + this.spuId, + this.unitId, + this.barCode, + }); + + factory ServiceSupermarketSkuCode.fromJson(Map json) => + _$ServiceSupermarketSkuCodeFromJson(json); + + Map toJson() => _$ServiceSupermarketSkuCodeToJson(this); +} + +// 包装单位 +@JsonSerializable() +class ServiceSupermarketSkuPackage { + /// 包装单位id + String? id; + + /// spu id + String? spuId; + + /// 条码 + String? barCode; + + /// 规格 + String? spec; + + /// 单位 + String? unit; + + /// 状态(0正常,1停销) + int? status; + + /// 内装数量 + int? packageNum; + + /// 进价/单位分 + int? purchasePrice; + + /// 零售价/单位分 + int? retailPrice; + + /// 促销价/单位分 + int? promotionPrice; + + ServiceSupermarketSkuPackage({ + this.id, + this.spuId, + this.barCode, + this.spec, + this.unit, + this.status, + this.packageNum, + this.purchasePrice, + this.retailPrice, + this.promotionPrice, + }); + + factory ServiceSupermarketSkuPackage.fromJson(Map json) => + _$ServiceSupermarketSkuPackageFromJson(json); + + Map toJson() => _$ServiceSupermarketSkuPackageToJson(this); +} + +// ************************************************************************** +// 超市 sku end +// ************************************************************************** diff --git a/lib/common/models/api/product_api_models.g.dart b/lib/common/models/api/product_api_models.g.dart new file mode 100644 index 0000000..c0e3d83 --- /dev/null +++ b/lib/common/models/api/product_api_models.g.dart @@ -0,0 +1,214 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'product_api_models.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +ServiceClassify _$ServiceClassifyFromJson(Map json) => + ServiceClassify( + id: json['id'] as String?, + name: json['name'] as String?, + picUrl: json['picUrl'] as String?, + sort: (json['sort'] as num?)?.toInt(), + status: (json['status'] as num?)?.toInt(), + systemType: (json['systemType'] as num?)?.toInt(), + categoryType: json['categoryType'] as String?, + ); + +Map _$ServiceClassifyToJson(ServiceClassify instance) => + { + 'id': instance.id, + 'name': instance.name, + 'picUrl': instance.picUrl, + 'sort': instance.sort, + 'status': instance.status, + 'systemType': instance.systemType, + 'categoryType': instance.categoryType, + }; + +ServiceProduct _$ServiceProductFromJson(Map json) => + ServiceProduct( + id: json['id'] as String?, + spuId: json['spuId'] as String?, + categoryId: json['categoryId'] as String?, + name: json['name'] as String?, + picUrl: json['picUrl'] as String?, + hourType: JsonTool.anyToString(json['hourType']), + sort: (json['sort'] as num?)?.toInt(), + categoryType: json['categoryType'] as String?, + saleDate: json['saleDate'] == null + ? null + : DateTime.parse(json['saleDate'] as String), + ) + ..price = (json['price'] as num?)?.toInt() + ..isPriceIncreased = + JsonTool.stringToBoolean(json['isPriceIncreased'] as String?); + +Map _$ServiceProductToJson(ServiceProduct instance) => + { + 'id': instance.id, + 'spuId': instance.spuId, + 'hourType': instance.hourType, + 'categoryId': instance.categoryId, + 'name': instance.name, + 'picUrl': instance.picUrl, + 'price': instance.price, + 'sort': instance.sort, + 'categoryType': instance.categoryType, + 'saleDate': instance.saleDate?.toIso8601String(), + 'isPriceIncreased': JsonTool.booleanToString(instance.isPriceIncreased), + }; + +ServiceSupermarketSpu _$ServiceSupermarketSpuFromJson( + Map json) => + ServiceSupermarketSpu( + id: json['id'] as String?, + productCode: json['productCode'] as String?, + productName: json['productName'] as String?, + unit: json['unit'] as String?, + spec: json['spec'] as String?, + categoryId: json['categoryId'] as String?, + picUrl: json['picUrl'] as String?, + stock: (json['stock'] as num?)?.toDouble(), + productNature: json['productNature'] as String?, + retailPrice: (json['retailPrice'] as num?)?.toInt(), + promotionPrice: (json['promotionPrice'] as num?)?.toInt(), + salesCount: (json['salesCount'] as num?)?.toInt(), + ); + +Map _$ServiceSupermarketSpuToJson( + ServiceSupermarketSpu instance) => + { + 'id': instance.id, + 'productCode': instance.productCode, + 'productName': instance.productName, + 'unit': instance.unit, + 'spec': instance.spec, + 'categoryId': instance.categoryId, + 'picUrl': instance.picUrl, + 'stock': instance.stock, + 'productNature': instance.productNature, + 'retailPrice': instance.retailPrice, + 'promotionPrice': instance.promotionPrice, + 'salesCount': instance.salesCount, + }; + +ServiceSupermarketSpuPage _$ServiceSupermarketSpuPageFromJson( + Map json) => + ServiceSupermarketSpuPage( + records: (json['records'] as List?) + ?.map( + (e) => ServiceSupermarketSpu.fromJson(e as Map)) + .toList(), + ) + ..current = (json['current'] as num).toInt() + ..pages = (json['pages'] as num).toInt() + ..size = (json['size'] as num).toInt() + ..total = (json['total'] as num).toInt(); + +Map _$ServiceSupermarketSpuPageToJson( + ServiceSupermarketSpuPage instance) => + { + 'current': instance.current, + 'pages': instance.pages, + 'size': instance.size, + 'total': instance.total, + 'records': instance.records, + }; + +ServiceSupermarketSku _$ServiceSupermarketSkuFromJson( + Map json) => + ServiceSupermarketSku( + id: json['id'] as String?, + productCode: json['productCode'] as String?, + productName: json['productName'] as String?, + categoryId: json['categoryId'] as String?, + picUrl: json['picUrl'] as String?, + stock: (json['stock'] as num?)?.toDouble(), + merchantId: json['merchantId'] as String?, + origin: json['origin'] as String?, + productNature: json['productNature'] as String?, + brandId: json['brandId'] as String?, + supplierId: json['supplierId'] as String?, + salesCount: (json['salesCount'] as num?)?.toInt(), + supermarketAuxiliaryCodeEntityList: + (json['supermarketAuxiliaryCodeEntityList'] as List?) + ?.map((e) => + ServiceSupermarketSkuCode.fromJson(e as Map)) + .toList(), + supermarketUnitEntityList: (json['supermarketUnitEntityList'] + as List?) + ?.map((e) => + ServiceSupermarketSkuPackage.fromJson(e as Map)) + .toList(), + ); + +Map _$ServiceSupermarketSkuToJson( + ServiceSupermarketSku instance) => + { + 'id': instance.id, + 'productCode': instance.productCode, + 'productName': instance.productName, + 'categoryId': instance.categoryId, + 'picUrl': instance.picUrl, + 'stock': instance.stock, + 'merchantId': instance.merchantId, + 'origin': instance.origin, + 'productNature': instance.productNature, + 'brandId': instance.brandId, + 'supplierId': instance.supplierId, + 'salesCount': instance.salesCount, + 'supermarketAuxiliaryCodeEntityList': + instance.supermarketAuxiliaryCodeEntityList, + 'supermarketUnitEntityList': instance.supermarketUnitEntityList, + }; + +ServiceSupermarketSkuCode _$ServiceSupermarketSkuCodeFromJson( + Map json) => + ServiceSupermarketSkuCode( + id: json['id'] as String?, + spuId: json['spuId'] as String?, + unitId: json['unitId'] as String?, + barCode: json['barCode'] as String?, + ); + +Map _$ServiceSupermarketSkuCodeToJson( + ServiceSupermarketSkuCode instance) => + { + 'id': instance.id, + 'spuId': instance.spuId, + 'unitId': instance.unitId, + 'barCode': instance.barCode, + }; + +ServiceSupermarketSkuPackage _$ServiceSupermarketSkuPackageFromJson( + Map json) => + ServiceSupermarketSkuPackage( + id: json['id'] as String?, + spuId: json['spuId'] as String?, + barCode: json['barCode'] as String?, + spec: json['spec'] as String?, + unit: json['unit'] as String?, + status: (json['status'] as num?)?.toInt(), + packageNum: (json['packageNum'] as num?)?.toInt(), + purchasePrice: (json['purchasePrice'] as num?)?.toInt(), + retailPrice: (json['retailPrice'] as num?)?.toInt(), + promotionPrice: (json['promotionPrice'] as num?)?.toInt(), + ); + +Map _$ServiceSupermarketSkuPackageToJson( + ServiceSupermarketSkuPackage instance) => + { + 'id': instance.id, + 'spuId': instance.spuId, + 'barCode': instance.barCode, + 'spec': instance.spec, + 'unit': instance.unit, + 'status': instance.status, + 'packageNum': instance.packageNum, + 'purchasePrice': instance.purchasePrice, + 'retailPrice': instance.retailPrice, + 'promotionPrice': instance.promotionPrice, + }; diff --git a/lib/common/models/api/user_api_models.dart b/lib/common/models/api/user_api_models.dart new file mode 100644 index 0000000..5fb3f51 --- /dev/null +++ b/lib/common/models/api/user_api_models.dart @@ -0,0 +1,166 @@ +import 'package:json_annotation/json_annotation.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +part 'user_api_models.g.dart'; + +@JsonSerializable() +class ServiceUser { + /// 用户ID + String? userId; + + /// 用户昵称 + String? username; + + /// 部门ID + String? deptId; + + /// 手机号码 + String? userMobile; + + /// 用户头像 + String? userAvatar; + + /// 钱包id + String? walletId; + + /// 钱包类型id + String? walletTypeId; + + /// 钱包余额 + int? walletBalance; + + /// 是否冻结饭卡 + @JsonKey(fromJson: JsonTool.stringToBoolean, toJson: JsonTool.booleanToString) + bool? walletIsFreeze; + + /// 卡号 + String? walletCardNumber; + + /// 查询用户食堂信息 + String? canteenId; + + /// 构造函数 + ServiceUser({ + this.userId, + this.username, + this.deptId, + this.userMobile, + this.userAvatar, + this.walletId, + this.walletTypeId, + this.walletBalance, + this.walletIsFreeze, + this.walletCardNumber, + this.canteenId, + }); + + factory ServiceUser.fromJson(Map json) => + _$ServiceUserFromJson(json); + + Map toJson() => _$ServiceUserToJson(this); +} + +@JsonSerializable() +class ServiceAuth { + /// 用户编号 + final int? userId; + + /// 访问令牌 + final String? accessToken; + + /// 刷新令牌 + final String? refreshToken; + + /// 过期时间 + final int? expiresTime; + + ServiceAuth({ + required this.userId, + required this.accessToken, + required this.refreshToken, + required this.expiresTime, + }); + + factory ServiceAuth.fromJson(Map json) => + _$ServiceAuthFromJson(json); + + Map toJson() => _$ServiceAuthToJson(this); +} + +@JsonSerializable() +class ServiceAsyncFace { + /// 编号 + final String? id; + + /// 人脸编号 + final String? autoCode; + + /// 人脸图片 + final String? faceImg; + + /// 用户id + final String? userId; + + /// 用户名 + final String? username; + + /// 用户头像 + final String? userAvatar; + + /// 部门id + final String? deptId; + + /// 部门名 + final String? deptName; + + /// 删除标志 + @JsonKey(fromJson: JsonTool.stringToBoolean, toJson: JsonTool.booleanToString) + final bool? delFlag; + + ServiceAsyncFace({ + this.id, + this.autoCode, + this.faceImg, + this.userId, + this.username, + this.userAvatar, + this.deptId, + this.deptName, + this.delFlag, + }); + + factory ServiceAsyncFace.fromJson(Map json) => + _$ServiceAsyncFaceFromJson(json); + + Map toJson() => _$ServiceAsyncFaceToJson(this); +} + +@JsonSerializable() +class ServiceTenant { + /// id + final String? id; + + /// 编号 + final String? code; + + /// 编号 + final String? name; + + final String? menuId; + + final String? miniQr; + + ServiceTenant({ + this.id, + this.code, + this.name, + this.menuId, + this.miniQr, + }); + + factory ServiceTenant.fromJson(Map json) => + _$ServiceTenantFromJson(json); + + Map toJson() => _$ServiceTenantToJson(this); +} diff --git a/lib/common/models/api/user_api_models.g.dart b/lib/common/models/api/user_api_models.g.dart new file mode 100644 index 0000000..af81863 --- /dev/null +++ b/lib/common/models/api/user_api_models.g.dart @@ -0,0 +1,96 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'user_api_models.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +ServiceUser _$ServiceUserFromJson(Map json) => ServiceUser( + userId: json['userId'] as String?, + username: json['username'] as String?, + deptId: json['deptId'] as String?, + userMobile: json['userMobile'] as String?, + userAvatar: json['userAvatar'] as String?, + walletId: json['walletId'] as String?, + walletTypeId: json['walletTypeId'] as String?, + walletBalance: (json['walletBalance'] as num?)?.toInt(), + walletIsFreeze: + JsonTool.stringToBoolean(json['walletIsFreeze'] as String?), + walletCardNumber: json['walletCardNumber'] as String?, + canteenId: json['canteenId'] as String?, + ); + +Map _$ServiceUserToJson(ServiceUser instance) => + { + 'userId': instance.userId, + 'username': instance.username, + 'deptId': instance.deptId, + 'userMobile': instance.userMobile, + 'userAvatar': instance.userAvatar, + 'walletId': instance.walletId, + 'walletTypeId': instance.walletTypeId, + 'walletBalance': instance.walletBalance, + 'walletIsFreeze': JsonTool.booleanToString(instance.walletIsFreeze), + 'walletCardNumber': instance.walletCardNumber, + 'canteenId': instance.canteenId, + }; + +ServiceAuth _$ServiceAuthFromJson(Map json) => ServiceAuth( + userId: (json['userId'] as num?)?.toInt(), + accessToken: json['accessToken'] as String?, + refreshToken: json['refreshToken'] as String?, + expiresTime: (json['expiresTime'] as num?)?.toInt(), + ); + +Map _$ServiceAuthToJson(ServiceAuth instance) => + { + 'userId': instance.userId, + 'accessToken': instance.accessToken, + 'refreshToken': instance.refreshToken, + 'expiresTime': instance.expiresTime, + }; + +ServiceAsyncFace _$ServiceAsyncFaceFromJson(Map json) => + ServiceAsyncFace( + id: json['id'] as String?, + autoCode: json['autoCode'] as String?, + faceImg: json['faceImg'] as String?, + userId: json['userId'] as String?, + username: json['username'] as String?, + userAvatar: json['userAvatar'] as String?, + deptId: json['deptId'] as String?, + deptName: json['deptName'] as String?, + delFlag: JsonTool.stringToBoolean(json['delFlag'] as String?), + ); + +Map _$ServiceAsyncFaceToJson(ServiceAsyncFace instance) => + { + 'id': instance.id, + 'autoCode': instance.autoCode, + 'faceImg': instance.faceImg, + 'userId': instance.userId, + 'username': instance.username, + 'userAvatar': instance.userAvatar, + 'deptId': instance.deptId, + 'deptName': instance.deptName, + 'delFlag': JsonTool.booleanToString(instance.delFlag), + }; + +ServiceTenant _$ServiceTenantFromJson(Map json) => + ServiceTenant( + id: json['id'] as String?, + code: json['code'] as String?, + name: json['name'] as String?, + menuId: json['menuId'] as String?, + miniQr: json['miniQr'] as String?, + ); + +Map _$ServiceTenantToJson(ServiceTenant instance) => + { + 'id': instance.id, + 'code': instance.code, + 'name': instance.name, + 'menuId': instance.menuId, + 'miniQr': instance.miniQr, + }; diff --git a/lib/common/models/api/wallet_api_models.dart b/lib/common/models/api/wallet_api_models.dart new file mode 100644 index 0000000..ab30aa5 --- /dev/null +++ b/lib/common/models/api/wallet_api_models.dart @@ -0,0 +1,172 @@ +import 'package:json_annotation/json_annotation.dart'; +import 'package:pj_supermarket_cloud/common/index.dart'; + +part 'wallet_api_models.g.dart'; + +@JsonSerializable() +class ServicePaySetting { + /// 唯一标识符 + final String? id; + + /// 是否开启每一餐的额外缴费 + @JsonKey(fromJson: JsonTool.stringToBoolean, toJson: JsonTool.booleanToString) + final bool? payAmountEnable; + + /// 每一餐的上限(分) + final int? payAmountMealLimit; + + /// 每一餐的倍数 + final double? payAmountMealTimes; + + /// 售后的退款理由 + final String? afterSaleRefundReasons; + + /// 售后的退货理由 + final String? afterSaleReturnReasons; + + /// 是否开启钱包二维码刷新 + @JsonKey(fromJson: JsonTool.stringToBoolean, toJson: JsonTool.booleanToString) + final bool? refreshQrCodeEnable; + + /// 二维码刷新频率 + final int? refreshQrCodeFre; + + /// 是否开启离线订单审批 + @JsonKey(fromJson: JsonTool.stringToBoolean, toJson: JsonTool.booleanToString) + final bool? offLineOrderApplyEnable; + + ServicePaySetting({ + this.id, + this.payAmountEnable, + this.payAmountMealLimit, + this.payAmountMealTimes, + this.afterSaleRefundReasons, + this.afterSaleReturnReasons, + this.refreshQrCodeEnable, + this.refreshQrCodeFre, + this.offLineOrderApplyEnable, + }); + + factory ServicePaySetting.fromJson(Map json) => + _$ServicePaySettingFromJson(json); + + Map toJson() => _$ServicePaySettingToJson(this); +} + +@JsonSerializable() +class ServiceMchRule { + String id; + + /// 源商户id + String sourceMchId; + + /// 目标商户id + String targetMchId; + + /// 规则配置id + String rulePriceConfigId; + + /// 钱包规则类型 + /// 10 跨单位额外支付 + /// 20 每餐额外支付 + String? rulePriceType; + + /// 基础价格 + @JsonKey(fromJson: JsonTool.stringToInt, toJson: JsonTool.intToString) + int? basePrice; + + /// 额外需要支付的倍数 + @JsonKey(fromJson: JsonTool.stringToInt, toJson: JsonTool.intToString) + int? multiple; + + ServiceMchRule({ + required this.id, + required this.sourceMchId, + required this.targetMchId, + required this.rulePriceConfigId, + this.rulePriceType, + this.basePrice, + this.multiple, + }); + + factory ServiceMchRule.fromJson(Map json) => + _$ServiceMchRuleFromJson(json); + + Map toJson() => _$ServiceMchRuleToJson(this); +} + +@JsonSerializable() +class ServiceWallet { + /// 钱包id + String id; + + /// 唯一编号 + String walletTypeUniqueCode; + + /// 用户id + String userId; + + /// 钱包类型 + String walletTypeId; + + /// 余额(单位分) + int balance = 0; + + /// 卡号 + String? cardNumber; + + /// 是否冻结饭卡 + @JsonKey(fromJson: JsonTool.stringToBoolean, toJson: JsonTool.booleanToString) + bool? isFreeze; + + ServiceWallet({ + required this.id, + required this.walletTypeUniqueCode, + required this.userId, + required this.walletTypeId, + required this.balance, + this.cardNumber, + this.isFreeze, + }); + + factory ServiceWallet.fromJson(Map json) => + _$ServiceWalletFromJson(json); + + Map toJson() => _$ServiceWalletToJson(this); +} + +@JsonSerializable() +class ServiceComAct { + /// 活动类型 + @JsonKey( + fromJson: JsonTool.stringToWalletActType, + toJson: JsonTool.walletActTypeToString, + ) + WalletActType? actType; + + /// 活动id + String? actItemId; + + /// 用户id + String? userId; + + /// 用户名 + String? username; + + /// 用户头像 + String? userAvatar; + + /// 详情 + Map? detail; + + ServiceComAct({ + required this.actType, + required this.actItemId, + required this.detail, + }); + + factory ServiceComAct.fromJson(Map json) => + _$ServiceComActFromJson(json); + + Map toJson() => _$ServiceComActToJson(this); +} diff --git a/lib/common/models/api/wallet_api_models.g.dart b/lib/common/models/api/wallet_api_models.g.dart new file mode 100644 index 0000000..3d2c971 --- /dev/null +++ b/lib/common/models/api/wallet_api_models.g.dart @@ -0,0 +1,102 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'wallet_api_models.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +ServicePaySetting _$ServicePaySettingFromJson(Map json) => + ServicePaySetting( + id: json['id'] as String?, + payAmountEnable: + JsonTool.stringToBoolean(json['payAmountEnable'] as String?), + payAmountMealLimit: (json['payAmountMealLimit'] as num?)?.toInt(), + payAmountMealTimes: (json['payAmountMealTimes'] as num?)?.toDouble(), + afterSaleRefundReasons: json['afterSaleRefundReasons'] as String?, + afterSaleReturnReasons: json['afterSaleReturnReasons'] as String?, + refreshQrCodeEnable: + JsonTool.stringToBoolean(json['refreshQrCodeEnable'] as String?), + refreshQrCodeFre: (json['refreshQrCodeFre'] as num?)?.toInt(), + offLineOrderApplyEnable: + JsonTool.stringToBoolean(json['offLineOrderApplyEnable'] as String?), + ); + +Map _$ServicePaySettingToJson(ServicePaySetting instance) => + { + 'id': instance.id, + 'payAmountEnable': JsonTool.booleanToString(instance.payAmountEnable), + 'payAmountMealLimit': instance.payAmountMealLimit, + 'payAmountMealTimes': instance.payAmountMealTimes, + 'afterSaleRefundReasons': instance.afterSaleRefundReasons, + 'afterSaleReturnReasons': instance.afterSaleReturnReasons, + 'refreshQrCodeEnable': + JsonTool.booleanToString(instance.refreshQrCodeEnable), + 'refreshQrCodeFre': instance.refreshQrCodeFre, + 'offLineOrderApplyEnable': + JsonTool.booleanToString(instance.offLineOrderApplyEnable), + }; + +ServiceMchRule _$ServiceMchRuleFromJson(Map json) => + ServiceMchRule( + id: json['id'] as String, + sourceMchId: json['sourceMchId'] as String, + targetMchId: json['targetMchId'] as String, + rulePriceConfigId: json['rulePriceConfigId'] as String, + rulePriceType: json['rulePriceType'] as String?, + basePrice: JsonTool.stringToInt(json['basePrice'] as String?), + multiple: JsonTool.stringToInt(json['multiple'] as String?), + ); + +Map _$ServiceMchRuleToJson(ServiceMchRule instance) => + { + 'id': instance.id, + 'sourceMchId': instance.sourceMchId, + 'targetMchId': instance.targetMchId, + 'rulePriceConfigId': instance.rulePriceConfigId, + 'rulePriceType': instance.rulePriceType, + 'basePrice': JsonTool.intToString(instance.basePrice), + 'multiple': JsonTool.intToString(instance.multiple), + }; + +ServiceWallet _$ServiceWalletFromJson(Map json) => + ServiceWallet( + id: json['id'] as String, + walletTypeUniqueCode: json['walletTypeUniqueCode'] as String, + userId: json['userId'] as String, + walletTypeId: json['walletTypeId'] as String, + balance: (json['balance'] as num).toInt(), + cardNumber: json['cardNumber'] as String?, + isFreeze: JsonTool.stringToBoolean(json['isFreeze'] as String?), + ); + +Map _$ServiceWalletToJson(ServiceWallet instance) => + { + 'id': instance.id, + 'walletTypeUniqueCode': instance.walletTypeUniqueCode, + 'userId': instance.userId, + 'walletTypeId': instance.walletTypeId, + 'balance': instance.balance, + 'cardNumber': instance.cardNumber, + 'isFreeze': JsonTool.booleanToString(instance.isFreeze), + }; + +ServiceComAct _$ServiceComActFromJson(Map json) => + ServiceComAct( + actType: JsonTool.stringToWalletActType(json['actType'] as String?), + actItemId: json['actItemId'] as String?, + detail: json['detail'] as Map?, + ) + ..userId = json['userId'] as String? + ..username = json['username'] as String? + ..userAvatar = json['userAvatar'] as String?; + +Map _$ServiceComActToJson(ServiceComAct instance) => + { + 'actType': JsonTool.walletActTypeToString(instance.actType), + 'actItemId': instance.actItemId, + 'userId': instance.userId, + 'username': instance.username, + 'userAvatar': instance.userAvatar, + 'detail': instance.detail, + }; diff --git a/lib/common/models/base/base_models.dart b/lib/common/models/base/base_models.dart new file mode 100644 index 0000000..203f4bf --- /dev/null +++ b/lib/common/models/base/base_models.dart @@ -0,0 +1,14 @@ +import 'package:common_utils/common_utils.dart'; +import 'package:get/get.dart'; + +abstract interface class BaseEnum { + String get code; + String get title; +} + +extension BaseEnumExtension on List { + T? fromCode(String? code) { + if (ObjectUtil.isEmpty(code)) return null; + return firstWhereOrNull((v) => v.code == code); + } +} diff --git a/lib/common/models/database/database_enum_models.dart b/lib/common/models/database/database_enum_models.dart new file mode 100644 index 0000000..a10f161 --- /dev/null +++ b/lib/common/models/database/database_enum_models.dart @@ -0,0 +1,198 @@ +import 'package:forui/forui.dart'; +import 'package:pj_supermarket_cloud/common/index.dart'; + +/// 设备类型 +enum SysType implements BaseEnum { + SUPERMARKET("1", "超市", "智慧超市", "supermarket"), + CANTEEN("2", "食堂", "智慧食堂", "canteen"), + ; + + @override + final String code; + @override + final String title; + final String cnStr; + final String usStr; + + const SysType(this.code, this.title, this.cnStr, this.usStr); +} + +/// 订单类型 +enum OrderType implements BaseEnum { + BELOW("1", "线下购买"), + RESERVE("2", "线上订餐"), + FOREIGN_TICKET("3", "外来人员"), + STRIDE_DEPT("4", "跨单位"), + ZY("5", "正元公务餐"), + ; + + @override + final String code; + @override + final String title; + + const OrderType(this.code, this.title); +} + +/// 付款方式 +enum OrderPayType implements BaseEnum { + WALLET_DEFAULT("1", "普通钱包支付"), + WALLET_QR_CODE("2", "钱包二维码支付"), + WALLET_QR_CODE_SCAN("22", "钱包扫一扫支付"), + WALLET_CARD("3", "钱包饭卡支付"), + FACE("4", "人脸识别支付"), + STRIDE_DEPT_QR_CODE("5", "跨单位二维码支付"), + FOREIGN_TICKET_QR_CODE("6", "外来人员二维码支付"), + RESERVE_QR_CODE("7", "线上订餐二维码支付"), + CASH("8", "现金支付"), + WECHANT_SELF_QR_CODE_PAY("10", "微信付款码支付"), + ALI_SELF_QR_CODE_PAY("11", "支付宝付款码支付"), + ZYY("12", "中医院"), + RMYY("13", "人民医院"), + CSXFK("13", "超市消费卡"), + ACT_CANCEL("50", "活动核销"), + ; + + @override + final String code; + @override + final String title; + + const OrderPayType(this.code, this.title); + + // 是否是外部人员支付 + static bool isStrideOrForeign(OrderPayType payType) { + return [ + OrderPayType.STRIDE_DEPT_QR_CODE, + OrderPayType.FOREIGN_TICKET_QR_CODE, + ].contains(payType); + } + + // 是否是自付款(微信、支付宝) + static bool isExternalPay(OrderPayType payType) { + return [ + OrderPayType.WECHANT_SELF_QR_CODE_PAY, + OrderPayType.ALI_SELF_QR_CODE_PAY, + ].contains(payType); + } +} + +/// 订单状态 +enum OrderStatus implements BaseEnum { + // 查询使用 + TOTAL("-1", "全部", true), + + CREATE("1", "未付款", true), + PAY("5", "已付款", true), + OFFLINE_CREATE("2", "离线(未支付)", false), + OFFLINE_PAY("6", "离线(本地支付)", true), + REFUND("10", "退款", true), + CANCEL("20", "已核销", false), + ; + + @override + final String code; + @override + final String title; + @override + final bool visible; + + const OrderStatus(this.code, this.title, this.visible); + + static List visibleList() { + return OrderStatus.values.where((e) => e.visible).toList(); + } + + static List successPayCodes() { + return [ + OrderStatus.PAY.code, + OrderStatus.CANCEL.code, + ]; + } +} + +// 打印状态 +enum PrintStatus implements BaseEnum { + NONE("10", "未打印"), + SUCCESS("20", "打印成功"), + SYNC("30", "同步成功"), + ERROR("40", "打印失败"), + ; + + @override + final String code; + @override + final String title; + + const PrintStatus(this.code, this.title); +} + +enum ProductHourType implements BaseEnum { + TOTAL("-1", "查询全部"), + + MORNING("1", "早餐"), + NOON("2", "中餐"), + NIGHT("3", "晚餐"), + ; + + @override + final String code; + @override + final String title; + +// 使用 getter 方法动态获取图标 + SvgAsset get icon { + switch (this) { + case ProductHourType.TOTAL: + return FAssets.icons.calendar; + case ProductHourType.MORNING: + return FAssets.icons.sunrise; + case ProductHourType.NOON: + return FAssets.icons.sun; + case ProductHourType.NIGHT: + return FAssets.icons.sunMoon; + } + } + + const ProductHourType(this.code, this.title); +} + +enum WalletType implements BaseEnum { + EAT("1", "饭卡钱包"), + PAY("2", "消费钱包"), + ; + + @override + final String code; + @override + final String title; + + const WalletType(this.code, this.title); +} + +/// 商品性质 +enum ProductNature implements BaseEnum { + DEFAULT("1", "普通商品"), + WEIGHT("2", "电子称称重商品"), + ; + + @override + final String code; + @override + final String title; + + const ProductNature(this.code, this.title); +} + +/// 优惠类型 +enum DiscountsType implements BaseEnum { + JZ_DISCOUNT("1", "9折优惠"), + ; + + @override + final String code; + @override + final String title; + + const DiscountsType(this.code, this.title); +} diff --git a/lib/common/models/database/database_models.dart b/lib/common/models/database/database_models.dart new file mode 100644 index 0000000..b15fef4 --- /dev/null +++ b/lib/common/models/database/database_models.dart @@ -0,0 +1,276 @@ +import 'package:equatable/equatable.dart'; +import 'package:json_annotation/json_annotation.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +part 'database_models.g.dart'; + +@JsonSerializable() +class SysSetting extends Equatable { + /// 设备类型 + SysType sysType; + + /// mac地址 + String? androidId; + + /// 服务端ID + String? serviceId; + + /// 服务单设备名 + String? serviceName; + + /// 商户id + String? serviceMerchantId; + + /// 商户部门id + String? merchantDeptId; + + /// 硬件分组id + String? hardGroupId; + + /// mq地址 + String? mqSocketAddress; + + /// mq端口 + String? mqSocketPort; + + /// mq用户名 + String? mqUsername; + + /// mq密码 + String? mqPassword; + + /// 当前设备的topic + String? mainTopic; + + /// mq连接id + String? mqClientId; + + /// mq状态 + String? mqStatus; + + /// 外部存储地址 + String? externalPath; + + /// 数据库地址 + String? sqlAddress; + + /// 日志地址 + String? logAddress; + + /// 人脸 appId + String? faceAppId; + + /// 人脸 sdkKey + String? faceSdkKey; + + /// 人脸 activeKey + String? faceActiveKey; + + /// 同步时间 + DateTime? syncTime; + + /// 菜谱分类同步时间 + DateTime? canteenClassifySyncTime; + + /// 菜谱商品同步时间 + DateTime? canteenProductSyncTime; + + /// 超市分类同步时间 + DateTime? supermarketClassifySyncTime; + + /// 超市商品同步时间 + DateTime? supermarketProductSyncTime; + + /// 离线订单同步时间 + DateTime? offlineOrderSyncTime; + + /// 长链接重连 + DateTime? mqSyncTime; + + /// 商品时间段类型 + ProductHourType? hourType; + + // 全局字体大小 + SettingGlobalFontSize? globalFontSize; + + /// 按钮、标签及小型操作控件的圆角。 + @JsonKey(defaultValue: SettingButtonRadius.standard) + SettingButtonRadius buttonRadius; + + /// 页面分组、信息卡片及 Forui 卡片的圆角。 + @JsonKey(defaultValue: SettingCardRadius.standard) + SettingCardRadius cardRadius; + + /// 按钮和标签的内边距档位,不改变全局字体大小。 + @JsonKey(defaultValue: SettingButtonSize.standard) + SettingButtonSize buttonSize; + + // 使用支付方式 + List? usePayDisplayType; + + // 默认支付方式 + OrderPayDisplayType? defaultPayDisplayType; + + // 租户列表 + List? tenantList; + + /// 设置为主控后不访问扫码、饭卡和人脸硬件,改由操作员输入硬件结果。 + @JsonKey(defaultValue: false) + bool manualHardwareInput; + + SysSetting({ + required this.sysType, + this.androidId, + this.serviceId, + this.mqSocketAddress, + this.mqStatus, + this.sqlAddress, + this.logAddress, + this.hardGroupId, + this.tenantList, + this.manualHardwareInput = false, + this.buttonRadius = SettingButtonRadius.standard, + this.cardRadius = SettingCardRadius.standard, + this.buttonSize = SettingButtonSize.standard, + }); + + factory SysSetting.fromJson(Map json) => + _$SysSettingFromJson(json); + + Map toJson() => _$SysSettingToJson(this); + + @override + List get props => [ + sysType, + androidId, + serviceId, + serviceName, + serviceMerchantId, + merchantDeptId, + hardGroupId, + mqSocketAddress, + mqSocketPort, + mqUsername, + mqPassword, + mainTopic, + mqClientId, + mqStatus, + externalPath, + sqlAddress, + logAddress, + faceAppId, + faceSdkKey, + faceActiveKey, + syncTime?.millisecondsSinceEpoch, + canteenClassifySyncTime?.millisecondsSinceEpoch, + canteenProductSyncTime?.millisecondsSinceEpoch, + supermarketClassifySyncTime?.millisecondsSinceEpoch, + supermarketProductSyncTime?.millisecondsSinceEpoch, + offlineOrderSyncTime?.millisecondsSinceEpoch, + mqSyncTime?.millisecondsSinceEpoch, + hourType, + globalFontSize, + buttonRadius, + cardRadius, + buttonSize, + usePayDisplayType, + defaultPayDisplayType, + tenantList, + manualHardwareInput, + ]; +} + +/// 用户信息 +@JsonSerializable() +class UserSetting extends Equatable { + /// 账号 + String? username; + + /// 密码 + String? password; + + /// start 服务端信息 start + + /// 设备类型 + SysType? sysType; + + /// 用户编号 + int? userId; + + /// 访问令牌 + String? accessToken; + + /// 刷新令牌 + String? refreshToken; + + /// 过期时间 + int? expiresTime; + + /// end 服务端信息 end + + /// 租户id + String? tenantId; + + UserSetting({ + this.username, + this.password, + }); + + factory UserSetting.fromJson(Map json) => + _$UserSettingFromJson(json); + + Map toJson() => _$UserSettingToJson(this); + + @override + List get props => [ + username, + password, + sysType, + userId, + accessToken, + refreshToken, + expiresTime, + tenantId, + ]; +} + +@JsonSerializable() +class CanteenExtraPriceSetting extends Equatable { + /// 是否开启额外金额 + bool payAmountEnable; + + /// 每一次上限 + int payAmountMealLimit; + + /// 超过的倍数 + double payAmountMealTimes; + + /// 售后的退货理由 + String? afterSaleReturnReasons; + + // 商户支付规则列表 + List? mchRoleList; + + CanteenExtraPriceSetting({ + required this.payAmountEnable, + required this.payAmountMealLimit, + required this.payAmountMealTimes, + this.afterSaleReturnReasons, + this.mchRoleList, + }); + + @override + List get props => [ + payAmountEnable, + payAmountMealLimit, + payAmountMealTimes, + afterSaleReturnReasons, + mchRoleList, + ]; + + factory CanteenExtraPriceSetting.fromJson(Map json) => + _$CanteenExtraPriceSettingFromJson(json); + + Map toJson() => _$CanteenExtraPriceSettingToJson(this); +} diff --git a/lib/common/models/database/database_models.g.dart b/lib/common/models/database/database_models.g.dart new file mode 100644 index 0000000..e555469 --- /dev/null +++ b/lib/common/models/database/database_models.g.dart @@ -0,0 +1,217 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'database_models.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +SysSetting _$SysSettingFromJson(Map json) => SysSetting( + sysType: $enumDecode(_$SysTypeEnumMap, json['sysType']), + androidId: json['androidId'] as String?, + serviceId: json['serviceId'] as String?, + mqSocketAddress: json['mqSocketAddress'] as String?, + mqStatus: json['mqStatus'] as String?, + sqlAddress: json['sqlAddress'] as String?, + logAddress: json['logAddress'] as String?, + hardGroupId: json['hardGroupId'] as String?, + tenantList: (json['tenantList'] as List?) + ?.map((e) => ServiceTenant.fromJson(e as Map)) + .toList(), + manualHardwareInput: json['manualHardwareInput'] as bool? ?? false, + buttonRadius: $enumDecodeNullable( + _$SettingButtonRadiusEnumMap, json['buttonRadius']) ?? + SettingButtonRadius.standard, + cardRadius: + $enumDecodeNullable(_$SettingCardRadiusEnumMap, json['cardRadius']) ?? + SettingCardRadius.standard, + buttonSize: + $enumDecodeNullable(_$SettingButtonSizeEnumMap, json['buttonSize']) ?? + SettingButtonSize.standard, + ) + ..serviceName = json['serviceName'] as String? + ..serviceMerchantId = json['serviceMerchantId'] as String? + ..merchantDeptId = json['merchantDeptId'] as String? + ..mqSocketPort = json['mqSocketPort'] as String? + ..mqUsername = json['mqUsername'] as String? + ..mqPassword = json['mqPassword'] as String? + ..mainTopic = json['mainTopic'] as String? + ..mqClientId = json['mqClientId'] as String? + ..externalPath = json['externalPath'] as String? + ..faceAppId = json['faceAppId'] as String? + ..faceSdkKey = json['faceSdkKey'] as String? + ..faceActiveKey = json['faceActiveKey'] as String? + ..syncTime = json['syncTime'] == null + ? null + : DateTime.parse(json['syncTime'] as String) + ..canteenClassifySyncTime = json['canteenClassifySyncTime'] == null + ? null + : DateTime.parse(json['canteenClassifySyncTime'] as String) + ..canteenProductSyncTime = json['canteenProductSyncTime'] == null + ? null + : DateTime.parse(json['canteenProductSyncTime'] as String) + ..supermarketClassifySyncTime = + json['supermarketClassifySyncTime'] == null + ? null + : DateTime.parse(json['supermarketClassifySyncTime'] as String) + ..supermarketProductSyncTime = json['supermarketProductSyncTime'] == null + ? null + : DateTime.parse(json['supermarketProductSyncTime'] as String) + ..offlineOrderSyncTime = json['offlineOrderSyncTime'] == null + ? null + : DateTime.parse(json['offlineOrderSyncTime'] as String) + ..mqSyncTime = json['mqSyncTime'] == null + ? null + : DateTime.parse(json['mqSyncTime'] as String) + ..hourType = + $enumDecodeNullable(_$ProductHourTypeEnumMap, json['hourType']) + ..globalFontSize = $enumDecodeNullable( + _$SettingGlobalFontSizeEnumMap, json['globalFontSize']) + ..usePayDisplayType = (json['usePayDisplayType'] as List?) + ?.map((e) => $enumDecode(_$OrderPayDisplayTypeEnumMap, e)) + .toList() + ..defaultPayDisplayType = $enumDecodeNullable( + _$OrderPayDisplayTypeEnumMap, json['defaultPayDisplayType']); + +Map _$SysSettingToJson(SysSetting instance) => + { + 'sysType': _$SysTypeEnumMap[instance.sysType]!, + 'androidId': instance.androidId, + 'serviceId': instance.serviceId, + 'serviceName': instance.serviceName, + 'serviceMerchantId': instance.serviceMerchantId, + 'merchantDeptId': instance.merchantDeptId, + 'hardGroupId': instance.hardGroupId, + 'mqSocketAddress': instance.mqSocketAddress, + 'mqSocketPort': instance.mqSocketPort, + 'mqUsername': instance.mqUsername, + 'mqPassword': instance.mqPassword, + 'mainTopic': instance.mainTopic, + 'mqClientId': instance.mqClientId, + 'mqStatus': instance.mqStatus, + 'externalPath': instance.externalPath, + 'sqlAddress': instance.sqlAddress, + 'logAddress': instance.logAddress, + 'faceAppId': instance.faceAppId, + 'faceSdkKey': instance.faceSdkKey, + 'faceActiveKey': instance.faceActiveKey, + 'syncTime': instance.syncTime?.toIso8601String(), + 'canteenClassifySyncTime': + instance.canteenClassifySyncTime?.toIso8601String(), + 'canteenProductSyncTime': + instance.canteenProductSyncTime?.toIso8601String(), + 'supermarketClassifySyncTime': + instance.supermarketClassifySyncTime?.toIso8601String(), + 'supermarketProductSyncTime': + instance.supermarketProductSyncTime?.toIso8601String(), + 'offlineOrderSyncTime': instance.offlineOrderSyncTime?.toIso8601String(), + 'mqSyncTime': instance.mqSyncTime?.toIso8601String(), + 'hourType': _$ProductHourTypeEnumMap[instance.hourType], + 'globalFontSize': _$SettingGlobalFontSizeEnumMap[instance.globalFontSize], + 'buttonRadius': _$SettingButtonRadiusEnumMap[instance.buttonRadius]!, + 'cardRadius': _$SettingCardRadiusEnumMap[instance.cardRadius]!, + 'buttonSize': _$SettingButtonSizeEnumMap[instance.buttonSize]!, + 'usePayDisplayType': instance.usePayDisplayType + ?.map((e) => _$OrderPayDisplayTypeEnumMap[e]!) + .toList(), + 'defaultPayDisplayType': + _$OrderPayDisplayTypeEnumMap[instance.defaultPayDisplayType], + 'tenantList': instance.tenantList, + 'manualHardwareInput': instance.manualHardwareInput, + }; + +const _$SysTypeEnumMap = { + SysType.SUPERMARKET: 'SUPERMARKET', + SysType.CANTEEN: 'CANTEEN', +}; + +const _$SettingButtonRadiusEnumMap = { + SettingButtonRadius.compact: 'compact', + SettingButtonRadius.standard: 'standard', + SettingButtonRadius.large: 'large', +}; + +const _$SettingCardRadiusEnumMap = { + SettingCardRadius.compact: 'compact', + SettingCardRadius.standard: 'standard', + SettingCardRadius.large: 'large', +}; + +const _$SettingButtonSizeEnumMap = { + SettingButtonSize.compact: 'compact', + SettingButtonSize.standard: 'standard', + SettingButtonSize.large: 'large', +}; + +const _$ProductHourTypeEnumMap = { + ProductHourType.TOTAL: 'TOTAL', + ProductHourType.MORNING: 'MORNING', + ProductHourType.NOON: 'NOON', + ProductHourType.NIGHT: 'NIGHT', +}; + +const _$SettingGlobalFontSizeEnumMap = { + SettingGlobalFontSize.xs: 'xs', + SettingGlobalFontSize.sm: 'sm', + SettingGlobalFontSize.base: 'base', + SettingGlobalFontSize.xl: 'xl', + SettingGlobalFontSize.xl2: 'xl2', + SettingGlobalFontSize.xl3: 'xl3', + SettingGlobalFontSize.xl4: 'xl4', +}; + +const _$OrderPayDisplayTypeEnumMap = { + OrderPayDisplayType.qrCodeScan: 'qrCodeScan', + OrderPayDisplayType.qrCode: 'qrCode', + OrderPayDisplayType.walletCard: 'walletCard', + OrderPayDisplayType.face: 'face', + OrderPayDisplayType.cash: 'cash', + OrderPayDisplayType.zyy: 'zyy', + OrderPayDisplayType.rmyy: 'rmyy', + OrderPayDisplayType.csxfk: 'csxfk', +}; + +UserSetting _$UserSettingFromJson(Map json) => UserSetting( + username: json['username'] as String?, + password: json['password'] as String?, + ) + ..sysType = $enumDecodeNullable(_$SysTypeEnumMap, json['sysType']) + ..userId = (json['userId'] as num?)?.toInt() + ..accessToken = json['accessToken'] as String? + ..refreshToken = json['refreshToken'] as String? + ..expiresTime = (json['expiresTime'] as num?)?.toInt() + ..tenantId = json['tenantId'] as String?; + +Map _$UserSettingToJson(UserSetting instance) => + { + 'username': instance.username, + 'password': instance.password, + 'sysType': _$SysTypeEnumMap[instance.sysType], + 'userId': instance.userId, + 'accessToken': instance.accessToken, + 'refreshToken': instance.refreshToken, + 'expiresTime': instance.expiresTime, + 'tenantId': instance.tenantId, + }; + +CanteenExtraPriceSetting _$CanteenExtraPriceSettingFromJson( + Map json) => + CanteenExtraPriceSetting( + payAmountEnable: json['payAmountEnable'] as bool, + payAmountMealLimit: (json['payAmountMealLimit'] as num).toInt(), + payAmountMealTimes: (json['payAmountMealTimes'] as num).toDouble(), + afterSaleReturnReasons: json['afterSaleReturnReasons'] as String?, + mchRoleList: (json['mchRoleList'] as List?) + ?.map((e) => ServiceMchRule.fromJson(e as Map)) + .toList(), + ); + +Map _$CanteenExtraPriceSettingToJson( + CanteenExtraPriceSetting instance) => + { + 'payAmountEnable': instance.payAmountEnable, + 'payAmountMealLimit': instance.payAmountMealLimit, + 'payAmountMealTimes': instance.payAmountMealTimes, + 'afterSaleReturnReasons': instance.afterSaleReturnReasons, + 'mchRoleList': instance.mchRoleList, + }; diff --git a/lib/common/models/encryption/encryption_tool.dart b/lib/common/models/encryption/encryption_tool.dart new file mode 100644 index 0000000..29a2d2e --- /dev/null +++ b/lib/common/models/encryption/encryption_tool.dart @@ -0,0 +1,10 @@ +import 'package:common_utils/common_utils.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +class EncryptionTool { + // 获取base64加密后的 oauth2 APP模式客户端信息 + static String getLoginAuthorization() { + return "Basic ${EncryptUtil.encodeBase64(Constants.oauth2AppClient)}"; + } +} diff --git a/lib/common/models/index.dart b/lib/common/models/index.dart new file mode 100644 index 0000000..b62b19c --- /dev/null +++ b/lib/common/models/index.dart @@ -0,0 +1,33 @@ +library; + +export './api/app_api_models.dart'; +export './api/base_api_models.dart'; +export './api/product_api_models.dart'; +export './api/hard_api_models.dart'; +export './api/user_api_models.dart'; +export './api/wallet_api_models.dart'; + +export './base/base_models.dart'; + +export './page/app_version_enum_models.dart'; +export './page/order_page_enum_models.dart'; +export './page/order_page_models.dart'; +export './page/setting_page_enum_models.dart'; +export './page/wallet_page_enum_models.dart'; + +export './mq/mq_enum_models.dart'; +export './mq/mq_models.dart'; + +export './manage/usb_facility_enum_manage.dart'; +export './manage/usb_facility_manage.dart'; + +export './encryption/encryption_tool.dart'; + +export './database/database_enum_models.dart'; +export './database/database_models.dart'; + +export './plugs/face_discern_models.dart'; +export './plugs/serial_card_enum_models.dart'; +export './plugs/serial_card_models.dart'; +export './plugs/sub_screen_enum_models.dart'; +export './plugs/sub_screen_models.dart'; diff --git a/lib/common/models/manage/usb_facility_enum_manage.dart b/lib/common/models/manage/usb_facility_enum_manage.dart new file mode 100644 index 0000000..df1192b --- /dev/null +++ b/lib/common/models/manage/usb_facility_enum_manage.dart @@ -0,0 +1,16 @@ +import 'package:pj_supermarket_cloud/common/index.dart'; + +enum UsbFacilityNames implements BaseEnum { + camera("1", "摄像头", "/dev/bus/usb/002/002"), + scanCode("1", "二维码读头", "/dev/bus/usb/001/003"), + print("1", "打印机", ""), + ; + + @override + final String code; + @override + final String title; + final String usbName; + + const UsbFacilityNames(this.code, this.title, this.usbName); +} diff --git a/lib/common/models/manage/usb_facility_manage.dart b/lib/common/models/manage/usb_facility_manage.dart new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/lib/common/models/manage/usb_facility_manage.dart @@ -0,0 +1 @@ + diff --git a/lib/common/models/mq/mq_enum_models.dart b/lib/common/models/mq/mq_enum_models.dart new file mode 100644 index 0000000..4399989 --- /dev/null +++ b/lib/common/models/mq/mq_enum_models.dart @@ -0,0 +1,21 @@ +import 'package:pj_supermarket_cloud/common/index.dart'; + +enum MqtActionEnum implements BaseEnum { + CONFIG("10", "设置变化"), + CANTEEN_PRODUCT("20", "食堂商品变化"), + CANTEEN_CLASSIFY("30", "食堂分类变化"), + SUPERMARKET_PRODUCT("40", "超市商品变化"), + SUPERMARKET_CLASSIFY("50", "超市分类变化"), + SUPERMARKET("51", "超市分类变化"), + PRINT("60", "打印变化"), + FACE("70", "人脸变化"), + TAKE_FOOD_BROADCAST("80", "定餐广播"), + ; + + @override + final String code; + @override + final String title; + + const MqtActionEnum(this.code, this.title); +} diff --git a/lib/common/models/mq/mq_models.dart b/lib/common/models/mq/mq_models.dart new file mode 100644 index 0000000..ed79f9c --- /dev/null +++ b/lib/common/models/mq/mq_models.dart @@ -0,0 +1,127 @@ +import 'package:json_annotation/json_annotation.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +part 'mq_models.g.dart'; + +@JsonSerializable() +class MqBaseMsg { + // 动作 + MqtActionEnum? action; + + MqBaseMsg({ + this.action, + }); + + factory MqBaseMsg.fromJson(Map json) => + _$MqBaseMsgFromJson(json); + + Map toJson() => _$MqBaseMsgToJson(this); +} + +@JsonSerializable() +class MqttInvoiceMsg { + // 动作 + MqtActionEnum? action; + + // 记录id + @JsonKey(fromJson: JsonTool.anyToString, toJson: JsonTool.stringToInt) + final String? historyId; + + // 用户id + @JsonKey(fromJson: JsonTool.anyToString, toJson: JsonTool.stringToInt) + final String? userId; + + // 订单id + @JsonKey(fromJson: JsonTool.anyToString, toJson: JsonTool.stringToInt) + final String? orderId; + + // 安卓订单id + @JsonKey(fromJson: JsonTool.anyToString, toJson: JsonTool.stringToInt) + final String? androidOrderCode; + + // 图片地址url + final String? pic; + + // 是否设置为全局人脸 + final bool? isGlobalFace; + + MqttInvoiceMsg({ + this.action, + this.historyId, + this.userId, + this.orderId, + this.androidOrderCode, + this.pic, + this.isGlobalFace, + }); + + factory MqttInvoiceMsg.fromJson(Map json) => + _$MqttInvoiceMsgFromJson(json); + + Map toJson() => _$MqttInvoiceMsgToJson(this); +} + +@JsonSerializable() +class MqttTakeFoodBroadcastMsg { + /// 发送硬件的topic + String topic; + + /// 用户id + String userId; + + /// 用户id + String username; + + /// 用户id + String userMobile; + + /// 广播id + String id; + + /// 订单id + String orderId; + + /// 预定标题 + String title; + + /// 广播内容 + String content; + + /// 最大广播次数 + int broadcastMaxCount; + + /// 商品介绍 + String productDesc; + + /// 支付价格 + int payPrice; + + /// 时间段类型 + String hourType; + + /// 是否完成订单 + @JsonKey(fromJson: JsonTool.stringToBoolean, toJson: JsonTool.booleanToString) + bool isDone; + + MqttTakeFoodBroadcastMsg({ + required this.topic, + required this.userId, + required this.username, + required this.userMobile, + required this.id, + required this.orderId, + required this.title, + required this.content, + required this.broadcastMaxCount, + required this.productDesc, + required this.payPrice, + required this.hourType, + required this.isDone, + }); + + factory MqttTakeFoodBroadcastMsg.fromJson(Map json) => + _$MqttTakeFoodBroadcastMsgFromJson(json); + + Map toJson() => _$MqttTakeFoodBroadcastMsgToJson(this); +} diff --git a/lib/common/models/mq/mq_models.g.dart b/lib/common/models/mq/mq_models.g.dart new file mode 100644 index 0000000..5ae0b68 --- /dev/null +++ b/lib/common/models/mq/mq_models.g.dart @@ -0,0 +1,85 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'mq_models.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +MqBaseMsg _$MqBaseMsgFromJson(Map json) => MqBaseMsg( + action: $enumDecodeNullable(_$MqtActionEnumEnumMap, json['action']), + ); + +Map _$MqBaseMsgToJson(MqBaseMsg instance) => { + 'action': _$MqtActionEnumEnumMap[instance.action], + }; + +const _$MqtActionEnumEnumMap = { + MqtActionEnum.CONFIG: 'CONFIG', + MqtActionEnum.CANTEEN_PRODUCT: 'CANTEEN_PRODUCT', + MqtActionEnum.CANTEEN_CLASSIFY: 'CANTEEN_CLASSIFY', + MqtActionEnum.SUPERMARKET_PRODUCT: 'SUPERMARKET_PRODUCT', + MqtActionEnum.SUPERMARKET_CLASSIFY: 'SUPERMARKET_CLASSIFY', + MqtActionEnum.SUPERMARKET: 'SUPERMARKET', + MqtActionEnum.PRINT: 'PRINT', + MqtActionEnum.FACE: 'FACE', + MqtActionEnum.TAKE_FOOD_BROADCAST: 'TAKE_FOOD_BROADCAST', +}; + +MqttInvoiceMsg _$MqttInvoiceMsgFromJson(Map json) => + MqttInvoiceMsg( + action: $enumDecodeNullable(_$MqtActionEnumEnumMap, json['action']), + historyId: JsonTool.anyToString(json['historyId']), + userId: JsonTool.anyToString(json['userId']), + orderId: JsonTool.anyToString(json['orderId']), + androidOrderCode: JsonTool.anyToString(json['androidOrderCode']), + pic: json['pic'] as String?, + isGlobalFace: json['isGlobalFace'] as bool?, + ); + +Map _$MqttInvoiceMsgToJson(MqttInvoiceMsg instance) => + { + 'action': _$MqtActionEnumEnumMap[instance.action], + 'historyId': JsonTool.stringToInt(instance.historyId), + 'userId': JsonTool.stringToInt(instance.userId), + 'orderId': JsonTool.stringToInt(instance.orderId), + 'androidOrderCode': JsonTool.stringToInt(instance.androidOrderCode), + 'pic': instance.pic, + 'isGlobalFace': instance.isGlobalFace, + }; + +MqttTakeFoodBroadcastMsg _$MqttTakeFoodBroadcastMsgFromJson( + Map json) => + MqttTakeFoodBroadcastMsg( + topic: json['topic'] as String, + userId: json['userId'] as String, + username: json['username'] as String, + userMobile: json['userMobile'] as String, + id: json['id'] as String, + orderId: json['orderId'] as String, + title: json['title'] as String, + content: json['content'] as String, + broadcastMaxCount: (json['broadcastMaxCount'] as num).toInt(), + productDesc: json['productDesc'] as String, + payPrice: (json['payPrice'] as num).toInt(), + hourType: json['hourType'] as String, + isDone: JsonTool.stringToBoolean(json['isDone'] as String?), + ); + +Map _$MqttTakeFoodBroadcastMsgToJson( + MqttTakeFoodBroadcastMsg instance) => + { + 'topic': instance.topic, + 'userId': instance.userId, + 'username': instance.username, + 'userMobile': instance.userMobile, + 'id': instance.id, + 'orderId': instance.orderId, + 'title': instance.title, + 'content': instance.content, + 'broadcastMaxCount': instance.broadcastMaxCount, + 'productDesc': instance.productDesc, + 'payPrice': instance.payPrice, + 'hourType': instance.hourType, + 'isDone': JsonTool.booleanToString(instance.isDone), + }; diff --git a/lib/common/models/page/app_version_enum_models.dart b/lib/common/models/page/app_version_enum_models.dart new file mode 100644 index 0000000..d01fbe9 --- /dev/null +++ b/lib/common/models/page/app_version_enum_models.dart @@ -0,0 +1,28 @@ +import 'package:pj_supermarket_cloud/common/models/base/base_models.dart'; + +enum AppPlatformEnum implements BaseEnum { + android("1", "安卓"), + ios("2", "苹果"), + ; + + @override + final String code; + @override + final String title; + + const AppPlatformEnum(this.code, this.title); +} + +enum AppVersionStatusEnum implements BaseEnum { + published("1", "已发布"), + unpublished("2", "未发布"), + offShelf("3", "已下架"), + ; + + @override + final String code; + @override + final String title; + + const AppVersionStatusEnum(this.code, this.title); +} diff --git a/lib/common/models/page/order_page_enum_models.dart b/lib/common/models/page/order_page_enum_models.dart new file mode 100644 index 0000000..2102805 --- /dev/null +++ b/lib/common/models/page/order_page_enum_models.dart @@ -0,0 +1,67 @@ +import 'package:pj_supermarket_cloud/common/index.dart'; + +enum OrderPayDisplayType implements BaseEnum { + qrCodeScan("15", "二维码扫一扫", false, 0), + qrCode("1", "二维码支付", false, 1), + walletCard("2", "饭卡支付", true, 2), + face("3", "人脸支付", true, 3), + cash("4", "现金支付", true, 4), + zyy("12", "中医院", true, 5), + rmyy("13", "人民医院", true, 6), + csxfk("14", "超市消费卡", true, 7), + ; + + @override + final String code; + @override + final String title; + // 是否支离线支付 + final bool supportOffLine; + // 是否支离线支付 + final int order; + + // 线下支付的订单类型(现金支付,其他渠道支付如中医院、人民医院) + static const List offlinePayTypeList = [ + OrderPayDisplayType.cash, + OrderPayDisplayType.zyy, + OrderPayDisplayType.rmyy, + OrderPayDisplayType.csxfk, + ]; + + // 是否为线下支付 + static bool isOffline(OrderPayDisplayType type) { + return offlinePayTypeList.contains(type); + } + + // 需要选择钱包 + static const List selectWalletPayTypeList = [ + OrderPayDisplayType.qrCode, + OrderPayDisplayType.face, + OrderPayDisplayType.walletCard, + ]; + + // 是否需要选择钱包 + static bool isSelectWalletPayType(OrderPayDisplayType type) { + return selectWalletPayTypeList.contains(type); + } + + const OrderPayDisplayType( + this.code, + this.title, + this.supportOffLine, + this.order, + ); +} + +enum OrderMoreTypeEnum implements BaseEnum { + addHoldBill("10", "加入挂单"), + holdBillList("20", "挂单列表"), + ; + + @override + final String code; + @override + final String title; + + const OrderMoreTypeEnum(this.code, this.title); +} diff --git a/lib/common/models/page/order_page_models.dart b/lib/common/models/page/order_page_models.dart new file mode 100644 index 0000000..7eff2d2 --- /dev/null +++ b/lib/common/models/page/order_page_models.dart @@ -0,0 +1,22 @@ +import 'package:pj_supermarket_cloud/common/index.dart'; + +class OrderHistoryTableItem { + OrderStatus status; + int total; + + OrderHistoryTableItem({ + required this.status, + this.total = 0, + }); +} + +class OrderMoreItem { + String title; + bool visible; + OrderMoreTypeEnum type; + OrderMoreItem({ + required this.title, + required this.visible, + required this.type, + }); +} diff --git a/lib/common/models/page/setting_page_enum_models.dart b/lib/common/models/page/setting_page_enum_models.dart new file mode 100644 index 0000000..e0c46f3 --- /dev/null +++ b/lib/common/models/page/setting_page_enum_models.dart @@ -0,0 +1,134 @@ +import 'package:forui/forui.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +enum SettingMenuType implements BaseEnum { + baseInfo("10", "基本信息"), + syncSetting("20", "同步设置"), + paySetting("30", "支付设置"), + backup("40", "资源备份"), + faceSetting("50", "人脸管理"), + productSetting("60", "商品管理"), + versionSetting("70", "版本管理"), + ; + + @override + final String code; + @override + final String title; + + const SettingMenuType(this.code, this.title); +} + +/// 主屏左侧一级菜单的稳定标识。 +/// +/// code 会持久化到终端设置中,因此只能追加,不能随页面标题或路由调整而改名。 +enum TerminalMenuItemType implements BaseEnum { + cashier("cashier", "收银"), + reservation("reservation", "预定"), + verification("verification", "核销"), + transactions("transactions", "交易"), + statistics("statistics", "统计"), + card("card", "开卡"), + settings("settings", "设置"), + logout("logout", "退出"), + ; + + @override + final String code; + @override + final String title; + + const TerminalMenuItemType(this.code, this.title); + + /// 退出固定在侧栏底部,不参与业务菜单排序。 + static const configurableValues = [ + cashier, + reservation, + verification, + transactions, + statistics, + card, + settings, + ]; + + SvgAsset get icon => switch (this) { + TerminalMenuItemType.cashier => FAssets.icons.store, + TerminalMenuItemType.reservation => FAssets.icons.calendar, + TerminalMenuItemType.verification => FAssets.icons.scan, + TerminalMenuItemType.transactions => FAssets.icons.coins, + TerminalMenuItemType.statistics => FAssets.icons.chartPie, + TerminalMenuItemType.card => FAssets.icons.creditCard, + TerminalMenuItemType.settings => FAssets.icons.settings, + TerminalMenuItemType.logout => FAssets.icons.logOut, + }; +} + +enum SettingGlobalFontSize implements BaseEnum { + xs("0.9", "0.9", 0.9), + sm("1.0", "1.0", 1.0), + base("1.1", "1.1", 1.1), + xl("1.2", "1.2", 1.2), + xl2("1.3", "1.3", 1.3), + xl3("1.4", "1.4", 1.4), + xl4("1.5", "1.5", 1.5), + ; + + @override + final String code; + @override + final String title; + + final double fontSize; + + const SettingGlobalFontSize(this.code, this.title, this.fontSize); +} + +enum SettingButtonRadius implements BaseEnum { + compact("compact", "紧凑", 8), + standard("standard", "标准", 12), + large("large", "大号", 16), + ; + + @override + final String code; + @override + final String title; + + final double radius; + + const SettingButtonRadius(this.code, this.title, this.radius); +} + +enum SettingCardRadius implements BaseEnum { + compact("compact", "紧凑", 12), + standard("standard", "标准", 16), + large("large", "大号", 20), + ; + + @override + final String code; + @override + final String title; + + final double radius; + + const SettingCardRadius(this.code, this.title, this.radius); +} + +enum SettingButtonSize implements BaseEnum { + compact("compact", "紧凑", 0.88), + standard("standard", "标准", 1), + large("large", "大号", 1.12), + ; + + @override + final String code; + @override + final String title; + + /// 只缩放按钮内边距和图标,不参与文字缩放,避免主题切换改变页面排版。 + final double scale; + + const SettingButtonSize(this.code, this.title, this.scale); +} diff --git a/lib/common/models/page/wallet_page_enum_models.dart b/lib/common/models/page/wallet_page_enum_models.dart new file mode 100644 index 0000000..0759628 --- /dev/null +++ b/lib/common/models/page/wallet_page_enum_models.dart @@ -0,0 +1,14 @@ +import 'package:pj_supermarket_cloud/common/index.dart'; + +enum WalletActType implements BaseEnum { + cancelAct("1", "核销活动"), + couponAct("2", "优惠券活动"), + ; + + @override + final String code; + @override + final String title; + + const WalletActType(this.code, this.title); +} diff --git a/lib/common/models/plugs/face_discern_models.dart b/lib/common/models/plugs/face_discern_models.dart new file mode 100644 index 0000000..6030d4a --- /dev/null +++ b/lib/common/models/plugs/face_discern_models.dart @@ -0,0 +1,109 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'face_discern_models.g.dart'; + +@JsonSerializable() +class FaceStream { + // 相似度 + double similar; + + // 识别时间 + int cost; + + // 人脸id + int? faceId; + + // 人脸样本id + int? sampleId; + + // 样本id + String? userId; + + // 用户名 + String? userName; + + FaceStream({ + required this.similar, + required this.cost, + this.faceId, + this.sampleId, + this.userId, + this.userName, + }); + + factory FaceStream.fromJson(Map json) => + _$FaceStreamFromJson(json); + + Map toJson() => _$FaceStreamToJson(this); +} + +@JsonSerializable() +class FaceConfig { + String? appId; + + String? sdkKey; + + String? activeKey; + + // 是否激活 + bool isActive = false; + + FaceConfig({ + this.appId, + this.sdkKey, + this.activeKey, + this.isActive = false, + }); + + factory FaceConfig.fromJson(Map json) => + _$FaceConfigFromJson(json); + + Map toJson() => _$FaceConfigToJson(this); +} + +@JsonSerializable() +class RegisterAllStorageFaceRes { + // 注册成功数量 + int successCount; + + // 注册失败数量 + int failedCount; + + // 注册数量 + int registerCount; + + // 本地文件数量 + int fileCount; + + RegisterAllStorageFaceRes({ + required this.successCount, + required this.failedCount, + required this.registerCount, + required this.fileCount, + }); + + factory RegisterAllStorageFaceRes.fromJson(Map json) => + _$RegisterAllStorageFaceResFromJson(json); + + Map toJson() => _$RegisterAllStorageFaceResToJson(this); +} + +@JsonSerializable() +class FaceRes { + int searchId; + + List featureData; + + String faceTag; + + FaceRes({ + required this.searchId, + required this.featureData, + required this.faceTag, + }); + + factory FaceRes.fromJson(Map json) => + _$FaceResFromJson(json); + + Map toJson() => _$FaceResToJson(this); +} diff --git a/lib/common/models/plugs/face_discern_models.g.dart b/lib/common/models/plugs/face_discern_models.g.dart new file mode 100644 index 0000000..e5f5880 --- /dev/null +++ b/lib/common/models/plugs/face_discern_models.g.dart @@ -0,0 +1,73 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'face_discern_models.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +FaceStream _$FaceStreamFromJson(Map json) => FaceStream( + similar: (json['similar'] as num).toDouble(), + cost: (json['cost'] as num).toInt(), + faceId: (json['faceId'] as num?)?.toInt(), + sampleId: (json['sampleId'] as num?)?.toInt(), + userId: json['userId'] as String?, + userName: json['userName'] as String?, + ); + +Map _$FaceStreamToJson(FaceStream instance) => + { + 'similar': instance.similar, + 'cost': instance.cost, + 'faceId': instance.faceId, + 'sampleId': instance.sampleId, + 'userId': instance.userId, + 'userName': instance.userName, + }; + +FaceConfig _$FaceConfigFromJson(Map json) => FaceConfig( + appId: json['appId'] as String?, + sdkKey: json['sdkKey'] as String?, + activeKey: json['activeKey'] as String?, + isActive: json['isActive'] as bool? ?? false, + ); + +Map _$FaceConfigToJson(FaceConfig instance) => + { + 'appId': instance.appId, + 'sdkKey': instance.sdkKey, + 'activeKey': instance.activeKey, + 'isActive': instance.isActive, + }; + +RegisterAllStorageFaceRes _$RegisterAllStorageFaceResFromJson( + Map json) => + RegisterAllStorageFaceRes( + successCount: (json['successCount'] as num).toInt(), + failedCount: (json['failedCount'] as num).toInt(), + registerCount: (json['registerCount'] as num).toInt(), + fileCount: (json['fileCount'] as num).toInt(), + ); + +Map _$RegisterAllStorageFaceResToJson( + RegisterAllStorageFaceRes instance) => + { + 'successCount': instance.successCount, + 'failedCount': instance.failedCount, + 'registerCount': instance.registerCount, + 'fileCount': instance.fileCount, + }; + +FaceRes _$FaceResFromJson(Map json) => FaceRes( + searchId: (json['searchId'] as num).toInt(), + featureData: (json['featureData'] as List) + .map((e) => (e as num).toInt()) + .toList(), + faceTag: json['faceTag'] as String, + ); + +Map _$FaceResToJson(FaceRes instance) => { + 'searchId': instance.searchId, + 'featureData': instance.featureData, + 'faceTag': instance.faceTag, + }; diff --git a/lib/common/models/plugs/serial_card_enum_models.dart b/lib/common/models/plugs/serial_card_enum_models.dart new file mode 100644 index 0000000..0c60a7c --- /dev/null +++ b/lib/common/models/plugs/serial_card_enum_models.dart @@ -0,0 +1,10 @@ +enum SerialCardCmdEnum { + write, + read; +} + +enum SerialCardResStatus { + success, + error, + warning, +} diff --git a/lib/common/models/plugs/serial_card_models.dart b/lib/common/models/plugs/serial_card_models.dart new file mode 100644 index 0000000..e69de29 diff --git a/lib/common/models/plugs/sub_screen_enum_models.dart b/lib/common/models/plugs/sub_screen_enum_models.dart new file mode 100644 index 0000000..d8f9353 --- /dev/null +++ b/lib/common/models/plugs/sub_screen_enum_models.dart @@ -0,0 +1,19 @@ +// 父屏 +import 'package:pj_supermarket_cloud/common/index.dart'; + +enum SubScreenAction implements BaseEnum { + updateCard("updateCard", "订单更新"), + jumpSubPage("jumpSubPage", "跳转到订单选择页面"), + jumpSubBack("jumpSubBack", "跳转到上一个页面"), + qrCodeScan("qrCodeScan", "二维码扫一扫"), + qrCodeScanSuccess("qrCodeScanSuccess", "二维码支付成功"), + qrCodeScanCancel("qrCodeScanCancel", "二维码支付取消"), + ; + + @override + final String code; + @override + final String title; + + const SubScreenAction(this.code, this.title); +} diff --git a/lib/common/models/plugs/sub_screen_models.dart b/lib/common/models/plugs/sub_screen_models.dart new file mode 100644 index 0000000..bf75989 --- /dev/null +++ b/lib/common/models/plugs/sub_screen_models.dart @@ -0,0 +1,58 @@ +import 'package:json_annotation/json_annotation.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +part 'sub_screen_models.g.dart'; + +@JsonSerializable() +class SubScreenProductEvent { + // 购物车列表 + // List + List> shopList; + + // 系统类型 + SysType sysType; + + // 支付用户 + // ServiceUser + Map? payUser; + + // 购物车总价 + int totalPrice; + + // 额外支付 + int amountMealPrice; + + SubScreenProductEvent({ + required this.shopList, + required this.sysType, + required this.payUser, + required this.totalPrice, + required this.amountMealPrice, + }); + + factory SubScreenProductEvent.fromJson(Map json) => + _$SubScreenProductEventFromJson(json); + + Map toJson() => _$SubScreenProductEventToJson(this); +} + +@JsonSerializable() +class SubScreenQrCodeScanEvent { + String action; + + String orderId; + + String qrCodeData; + + SubScreenQrCodeScanEvent({ + required this.action, + required this.orderId, + required this.qrCodeData, + }); + + factory SubScreenQrCodeScanEvent.fromJson(Map json) => + _$SubScreenQrCodeScanEventFromJson(json); + + Map toJson() => _$SubScreenQrCodeScanEventToJson(this); +} diff --git a/lib/common/models/plugs/sub_screen_models.g.dart b/lib/common/models/plugs/sub_screen_models.g.dart new file mode 100644 index 0000000..1df180e --- /dev/null +++ b/lib/common/models/plugs/sub_screen_models.g.dart @@ -0,0 +1,50 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'sub_screen_models.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +SubScreenProductEvent _$SubScreenProductEventFromJson( + Map json) => + SubScreenProductEvent( + shopList: (json['shopList'] as List) + .map((e) => e as Map) + .toList(), + sysType: $enumDecode(_$SysTypeEnumMap, json['sysType']), + payUser: json['payUser'] as Map?, + totalPrice: (json['totalPrice'] as num).toInt(), + amountMealPrice: (json['amountMealPrice'] as num).toInt(), + ); + +Map _$SubScreenProductEventToJson( + SubScreenProductEvent instance) => + { + 'shopList': instance.shopList, + 'sysType': _$SysTypeEnumMap[instance.sysType]!, + 'payUser': instance.payUser, + 'totalPrice': instance.totalPrice, + 'amountMealPrice': instance.amountMealPrice, + }; + +const _$SysTypeEnumMap = { + SysType.SUPERMARKET: 'SUPERMARKET', + SysType.CANTEEN: 'CANTEEN', +}; + +SubScreenQrCodeScanEvent _$SubScreenQrCodeScanEventFromJson( + Map json) => + SubScreenQrCodeScanEvent( + action: json['action'] as String, + orderId: json['orderId'] as String, + qrCodeData: json['qrCodeData'] as String, + ); + +Map _$SubScreenQrCodeScanEventToJson( + SubScreenQrCodeScanEvent instance) => + { + 'action': instance.action, + 'orderId': instance.orderId, + 'qrCodeData': instance.qrCodeData, + }; diff --git a/lib/common/routers/index.dart b/lib/common/routers/index.dart new file mode 100644 index 0000000..f345140 --- /dev/null +++ b/lib/common/routers/index.dart @@ -0,0 +1,4 @@ +library; + +export 'names.dart'; +export 'pages.dart'; diff --git a/lib/common/routers/names.dart b/lib/common/routers/names.dart new file mode 100644 index 0000000..442a70d --- /dev/null +++ b/lib/common/routers/names.dart @@ -0,0 +1,28 @@ +class RouteNames { + /// 加载页面 + static const load = '/load'; + + /// 登录 + static const login = '/login'; + + /// 收银主页面;根据设备类型装载超市或食堂业务实现。 + static const cashier = '/cashier'; + + /// 取餐 + static const takeFood = '/take_food'; + + /// 食堂预定 + static const canteenReserve = '/canteen_reserve'; + + /// 食堂交易统计 + static const canteenOrderTotal = '/canteen_order_total'; + + /// 交易查询 + static const orderHistory = '/order_history'; + + /// 设置 + static const setting = '/setting'; + + /// 饭卡 + static const cardActive = '/card_active'; +} diff --git a/lib/common/routers/pages.dart b/lib/common/routers/pages.dart new file mode 100644 index 0000000..1c8786e --- /dev/null +++ b/lib/common/routers/pages.dart @@ -0,0 +1,69 @@ +import 'package:get/get.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; +import 'package:pj_supermarket_cloud/pages/index.dart'; + +class RoutePages { + // 列表 + static List list = [ + GetPage( + name: RouteNames.load, + page: () => const LoadPage(), + ), + GetPage( + name: RouteNames.login, + page: () => const LoginPage(), + ), + GetPage( + name: RouteNames.cashier, + page: () => const CashierPage(), + binding: BindingsBuilder(() { + Get.lazyPut(() => SelectPayTypeDialogController()); + Get.lazyPut(() => SteelyardController()); + }), + ), + GetPage( + name: RouteNames.canteenReserve, + page: () => const CanteenReservePage(), + binding: BindingsBuilder(() { + Get.lazyPut(() => CanteenReserveController()); + }), + ), + GetPage( + name: RouteNames.takeFood, + page: () => const TakeFoodPage(), + binding: BindingsBuilder(() { + Get.lazyPut(() => TakeFoodController()); + }), + ), + GetPage( + name: RouteNames.canteenOrderTotal, + page: () => const CanteenOrderTotalPage(), + binding: BindingsBuilder(() { + Get.lazyPut(() => CanteenOrderTotalController()); + }), + ), + GetPage( + name: RouteNames.orderHistory, + page: () => const OrderHistoryPage(), + binding: BindingsBuilder(() { + Get.lazyPut(() => OrderHistoryController()); + }), + ), + GetPage( + name: RouteNames.setting, + page: () => const SettingPage(), + binding: BindingsBuilder(() { + Get.lazyPut(() => SettingController()); + }), + ), + GetPage( + name: RouteNames.cardActive, + page: () => const CardActivePage(), + binding: BindingsBuilder(() { + Get.lazyPut(() => SettingController()); + Get.lazyPut(() => CardActiveController()); + }), + ), + ]; +} diff --git a/lib/common/services/database/dao/broadcast_dao.dart b/lib/common/services/database/dao/broadcast_dao.dart new file mode 100644 index 0000000..f5433bc --- /dev/null +++ b/lib/common/services/database/dao/broadcast_dao.dart @@ -0,0 +1,99 @@ +import 'package:common_utils/common_utils.dart'; +import 'package:floor/floor.dart'; +import 'package:get/get.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +// broadcast +@dao +abstract class BroadcastDao { + Future toggleEnable(bool enable, ProductHourType hourType) async { + final app = Get.find(); + final database = app.dataBase.database; + final sql = ''' + UPDATE broadcast SET enable = ${enable ? 1 : 0} + WHERE + (${hourType.code} = '-1' OR hourType = ${hourType.code}) + AND strftime('%Y-%m-%d', createTime) = strftime('%Y-%m-%d', 'now', 'localtime') + '''; + return await database.rawUpdate(sql, []); + } + + Future delByServiceCodes( + List codes, + ) async { + if (ObjectUtil.isEmptyList(codes)) return 0; + final appC = Get.find(); + final database = appC.dataBase.database; + final placeholders = List.generate(codes.length, (index) => '?').join(', '); + final sql = ''' + DELETE FROM broadcast + WHERE + serviceCode IN ($placeholders) + AND strftime('%Y-%m-%d', createTime) = strftime('%Y-%m-%d', 'now', 'localtime') + '''; + return await database.rawDelete(sql, [...codes]); + } + + Future delAll() async { + final appC = Get.find(); + final database = appC.dataBase.database; + final sql = ''' + DELETE FROM broadcast + WHERE + strftime('%Y-%m-%d', createTime) = strftime('%Y-%m-%d', 'now', 'localtime') + '''; + return await database.rawDelete(sql, []); + } + + @insert + Future save(BroadcastEntity entity); + + @insert + Future batchSave(List entitys); + + @Query(""" + DELETE FROM broadcast + WHERE + isDone = 0 + AND (:serviceOrderCode = '-1' OR serviceOrderCode = :serviceOrderCode) + """) + Future del(String serviceOrderCode); + + @update + Future edit(BroadcastEntity entity); + + @Query(""" + SELECT * FROM broadcast + WHERE + isDone = 0 + AND enable = 1 + AND broadcastCount < broadcastMaxCount + AND (:hourType = '-1' OR hourType = :hourType) + AND strftime('%Y-%m-%d', createTime) = strftime('%Y-%m-%d', 'now', 'localtime') + """) + Future> getAll(String hourType); + + @Query(""" + SELECT * FROM broadcast + WHERE + strftime('%Y-%m-%d', createTime) = strftime('%Y-%m-%d', 'now', 'localtime') + AND (:hourType = '-1' OR hourType = :hourType) + LIMIT :pageSize OFFSET :offset + """) + Future> findAllPage( + String hourType, + int pageSize, + int offset, + ); + + @Query(""" + SELECT COUNT(0) FROM broadcast + WHERE + strftime('%Y-%m-%d', createTime) = strftime('%Y-%m-%d', 'now', 'localtime') + AND (:hourType = '-1' OR hourType = :hourType) + """) + Future count( + String hourType, + ); +} diff --git a/lib/common/services/database/dao/canteen_spu_dao.dart b/lib/common/services/database/dao/canteen_spu_dao.dart new file mode 100644 index 0000000..5ae5062 --- /dev/null +++ b/lib/common/services/database/dao/canteen_spu_dao.dart @@ -0,0 +1,99 @@ +import 'package:floor/floor.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +@dao +abstract class CanteenSpuDao { + @insert + Future save(CanteenSpuEntity entity); + + @insert + Future batchSave(List entitys); + + @Query(''' + DELETE FROM canteen_spu + ''') + Future delAllByType(); + + @Query(''' + DELETE FROM canteen_spu WHERE strftime('%Y-%m-%d', saleDate) = strftime('%Y-%m-%d', :dayStr) + ''') + Future delAllByDay(String dayStr); + + @delete + Future batchDel(List entitys); + + @Query(""" + UPDATE canteen_spu SET isDefault = :isDefault + WHERE + serviceUniqueCode = :uniqueCode + AND hourType = :hourType + """) + Future editDefault(bool isDefault, String uniqueCode, String hourType); + + @update + Future batchEdit(List entitys); + + @Query(""" + SELECT DISTINCT COUNT(0) FROM canteen_spu + WHERE + (:hourType = '-1' OR INSTR(',' || hourType || ',', ',' || :hourType || ',') > 0) + AND strftime('%Y-%m-%d', saleDate) = strftime('%Y-%m-%d', 'now', 'localtime') + """) + Future getCount(String hourType); + + @Query(''' + SELECT * FROM canteen_spu + WHERE + serviceUniqueCode = :code + AND (:saleDate = '-1' OR strftime('%Y-%m-%d', saleDate) = strftime('%Y-%m-%d', :saleDate)) + LIMIT 1 + ''') + Future getByCode( + String code, + String saleDate, + ); + + @Query(''' + SELECT canteen_spu.* + FROM canteen_spu + LEFT JOIN classify ON canteen_spu.classifyServiceUniqueCode = classify.serviceUniqueCode AND classify.delFlag = 0 + WHERE + (:classifyServiceUniqueCode = '-1' OR canteen_spu.classifyServiceUniqueCode = :classifyServiceUniqueCode) + AND (:hourType = '-1' OR INSTR(',' || hourType || ',', ',' || :hourType || ',') > 0) + AND (:saleDate = '-1' OR strftime('%Y-%m-%d', saleDate) = strftime('%Y-%m-%d', :saleDate)) + ORDER BY classify.sort,canteen_spu.sort desc,canteen_spu.price + ''') + Future> getAll( + String classifyServiceUniqueCode, + String hourType, + String saleDate, + ); + + @Query(''' + SELECT canteen_spu.* + FROM canteen_spu + LEFT JOIN classify ON canteen_spu.classifyServiceUniqueCode = classify.serviceUniqueCode AND classify.delFlag = 0 + WHERE + (:classifyServiceUniqueCode = '-1' OR canteen_spu.classifyServiceUniqueCode = :classifyServiceUniqueCode) + AND (:hourType = '-1' OR INSTR(',' || canteen_spu.hourType || ',', ',' || :hourType || ',') > 0) + AND strftime('%Y-%m-%d', saleDate) = strftime('%Y-%m-%d', 'now', 'localtime') + ORDER BY classify.sort, classify.id, canteen_spu.price + LIMIT :pageSize OFFSET :offset + ''') + Future> getAllPage( + String classifyServiceUniqueCode, + String hourType, + int pageSize, + int offset, + ); + + @Query(''' + SELECT * FROM canteen_spu + WHERE + isDefault = 1 + AND (:hourType = '-1' OR hourType = :hourType) + AND strftime('%Y-%m-%d', saleDate) = strftime('%Y-%m-%d', 'now', 'localtime') + ''') + Future> getDefault(String hourType); +} diff --git a/lib/common/services/database/dao/classify_dao.dart b/lib/common/services/database/dao/classify_dao.dart new file mode 100644 index 0000000..240760b --- /dev/null +++ b/lib/common/services/database/dao/classify_dao.dart @@ -0,0 +1,65 @@ +import 'package:common_utils/common_utils.dart'; +import 'package:floor/floor.dart'; +import 'package:get/get.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +// classify +@dao +abstract class ClassifyDao { + @insert + Future save(ClassifyEntity entity); + + @Query(''' + DELETE FROM classify + ''') + Future delAllForce(); + + @insert + Future batchSave(List entitys); + + Future delByIds(List codes) async { + if (ObjectUtil.isEmptyList(codes)) return -1; + final app = Get.find(); + final database = app.dataBase.database; + final placeholders = List.generate(codes.length, (index) => '?').join(', '); + final sql = ''' + UPDATE classify SET delFlag = 1 WHERE serviceUniqueCode IN ($placeholders) + '''; + return await database.rawUpdate(sql, [...codes]); + } + + @Query(''' + UPDATE classify SET delFlag = 1 + WHERE delFlag = 0 + AND (:sysType = '-1' OR sysType = :sysType) + ''') + Future delBySysType(String sysType); + + @update + Future edit(ClassifyEntity entity); + + @update + Future batchEdit(List entitys); + + @Query('SELECT DISTINCT COUNT(0) FROM classify WHERE delFlag = 0') + Future getCount(); + + @Query(""" + SELECT * FROM classify WHERE delFlag = 0 AND serviceUniqueCode = :code + """) + Future getByCode(String code); + + @Query(''' + SELECT classify.* FROM classify + WHERE + delFlag = 0 AND + (:sysType = '-1' OR sysType = :sysType) AND + (:categoryType = '-1' OR INSTR(',' || categoryType || ',', ',' || :categoryType || ',') > 0) + ORDER BY classify.sort + ''') + Future> getAll( + String sysType, + String categoryType, + ); +} diff --git a/lib/common/services/database/dao/face_dao.dart b/lib/common/services/database/dao/face_dao.dart new file mode 100644 index 0000000..c0ddae6 --- /dev/null +++ b/lib/common/services/database/dao/face_dao.dart @@ -0,0 +1,87 @@ +import 'package:floor/floor.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +// face +@dao +abstract class FaceDao { + @insert + Future save(FaceEntity entity); + + @insert + Future batchSave(List entitys); + + @delete + Future del(FaceEntity entity); + + @Query(""" DELETE FROM face """) + Future delAll(); + + @Query(''' + DELETE FROM face WHERE id = :id + ''') + Future delById(int id); + + @Query(''' + DELETE FROM face WHERE userId = :userId + ''') + Future delByUserId(String userId); + + @Query(''' + DELETE FROM face WHERE sampleId = :sampleId + ''') + Future delBySampleId(String sampleId); + + @delete + Future batchDel(List entitys); + + @update + Future edit(FaceEntity entity); + + @update + Future batchEdit(List entitys); + + @Query(""" + SELECT DISTINCT COUNT(0) FROM face + WHERE + (:userName = '-1' OR userName LIKE '%' || :userName || '%') + """) + Future findCount(String userName); + + @Query(''' + SELECT * FROM face WHERE id = :id + ''') + Future getById(int id); + + @Query(''' + SELECT * FROM face WHERE sampleId = :sampleId + ''') + Future findBySampleId(String sampleId); + + @Query(''' + SELECT * FROM face WHERE userId = :userId + ''') + Future?> findByUserId(String userId); + + @Query(''' + SELECT * FROM face + WHERE + (:userName = '-1' OR userName LIKE '%' || :userName || '%') + LIMIT :pageSize OFFSET :offset + ''') + Future> findAll( + String userName, + int pageSize, + int offset, + ); + + @Query(''' + SELECT DISTINCT a.deptId FROM face a WHERE a.deptId IS NOT NULL + ''') + Future> findAllUserIds(); + + @Query(''' + SELECT * FROM face WHERE faceModel = '' OR faceModel IS NULL + ''') + Future?> getEmptyFaceList(); +} diff --git a/lib/common/services/database/dao/order_dao.dart b/lib/common/services/database/dao/order_dao.dart new file mode 100644 index 0000000..e53ccc6 --- /dev/null +++ b/lib/common/services/database/dao/order_dao.dart @@ -0,0 +1,85 @@ +import 'package:floor/floor.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +@dao +abstract class OrderDao { + @insert + Future save(OrderEntity entity); + + @insert + Future batchSave(List entitys); + + @delete + Future del(OrderEntity entity); + + @delete + Future batchDel(List entitys); + + @update + Future edit(OrderEntity entity); + + @update + Future batchEdit(List entitys); + + @Query(''' + UPDATE order_record SET status = 'PAY' WHERE uniqueCode IN :codes + ''') + Future editOfflineStatus(String codes); + + @Query(''' + SELECT * FROM order_record + WHERE + (:status == "-1" OR status = :status) AND + (:sysType = '-1' OR sysType = :sysType) AND + (:hourType = '-1' OR hourType = :hourType) AND + strftime('%Y-%m-%d', createTime) >= strftime('%Y-%m-%d', :startCreateOrderTime) AND strftime('%Y-%m-%d', createTime) <= strftime('%Y-%m-%d', :endCreateOrderTime) + ORDER BY createTime DESC + LIMIT :pageSize OFFSET :offset + ''') + Future> findAll( + String status, + DateTime startCreateOrderTime, + DateTime endCreateOrderTime, + int pageSize, + int offset, + String sysType, + String hourType, + ); + + @Query(''' + SELECT COUNT(0) FROM order_record + WHERE + (:status == "-1" OR status = :status) AND + (:sysType = '-1' OR sysType = :sysType) AND + (:hourType = '-1' OR hourType = :hourType) AND + strftime('%Y-%m-%d', createTime) >= strftime('%Y-%m-%d', :startCreateOrderTime) AND strftime('%Y-%m-%d', createTime) <= strftime('%Y-%m-%d', :endCreateOrderTime) + ''') + Future count( + String status, + DateTime startCreateOrderTime, + DateTime endCreateOrderTime, + String sysType, + String hourType, + ); + + @Query(''' + SELECT * FROM order_record + WHERE + status = 'OFFLINE_PAY' AND + (:sysType = '-1' OR sysType = :sysType) AND + createTime >= :startCreateOrderTime AND createTime <= :endCreateOrderTime + ''') + Future> findOffline( + String sysType, + DateTime startCreateOrderTime, + DateTime endCreateOrderTime, + ); + + @Query(''' + SELECT * FROM order_record + WHERE + id = :id + ''') + Future findById(int id); +} diff --git a/lib/common/services/database/dao/order_hold_bill_dao.dart b/lib/common/services/database/dao/order_hold_bill_dao.dart new file mode 100644 index 0000000..9116dee --- /dev/null +++ b/lib/common/services/database/dao/order_hold_bill_dao.dart @@ -0,0 +1,40 @@ +import 'package:floor/floor.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +@dao +abstract class OrderHoldBillDao { + @insert + Future save(OrderHoldBillEntity entity); + + @update + Future edit(OrderHoldBillEntity entity); + + @delete + Future del(OrderHoldBillEntity entity); + + @Query(""" + SELECT * FROM order_hold_bill + WHERE + createTime >= :startTime + AND createTime <= :endTime + ORDER BY createTime DESC + LIMIT :pageSize OFFSET :offset + """) + Future> list( + String startTime, + String endTime, + int pageSize, + int offset, + ); + + @Query(""" + SELECT COUNT(*) FROM order_hold_bill WHERE createTime >= :startTime AND createTime <= :endTime + """) + Future count(String startTime, String endTime); + + @Query(""" + DELETE FROM order_hold_bill WHERE createTime < datetime('now', '-7 days') + """) + Future deleteOlderThan7Days(); +} diff --git a/lib/common/services/database/dao/print_history_dao.dart b/lib/common/services/database/dao/print_history_dao.dart new file mode 100644 index 0000000..3cbfe6b --- /dev/null +++ b/lib/common/services/database/dao/print_history_dao.dart @@ -0,0 +1,34 @@ +import 'package:floor/floor.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +@dao +abstract class PrintHistoryDao { + @insert + Future save(PrintHistoryEntity entity); + + @insert + Future batchSave(List entitys); + + @delete + Future del(PrintHistoryEntity entity); + + @Query('DELETE FROM print_history') + Future delAll(); + + @delete + Future batchDel(List entitys); + + @update + Future edit(PrintHistoryEntity entity); + + @update + Future batchEdit(List entitys); + + @Query(''' + SELECT * FROM print_history + WHERE + (:status = '-1' OR status = :status) + ''') + Future> getAll(String status); +} diff --git a/lib/common/services/database/dao/setting_dao.dart b/lib/common/services/database/dao/setting_dao.dart new file mode 100644 index 0000000..fc6f5b4 --- /dev/null +++ b/lib/common/services/database/dao/setting_dao.dart @@ -0,0 +1,20 @@ +import 'package:floor/floor.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +@dao +abstract class SettingDao { + @insert + Future save(SettingEntity entity); + + @delete + Future del(SettingEntity entity); + + @update + Future edit(SettingEntity entity); + + @Query(''' + SELECT * FROM setting LIMIT 1 + ''') + Future get(); +} diff --git a/lib/common/services/database/dao/shop_card_dao.dart b/lib/common/services/database/dao/shop_card_dao.dart new file mode 100644 index 0000000..0cf797c --- /dev/null +++ b/lib/common/services/database/dao/shop_card_dao.dart @@ -0,0 +1,31 @@ +import 'package:floor/floor.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +@dao +abstract class ShopCardDao { + @insert + Future save(ShopCardEntity entity); + + @insert + Future batchSave(List entitys); + + @delete + Future del(ShopCardEntity entity); + + @delete + Future batchDel(List entitys); + + @update + Future edit(ShopCardEntity entity); + + @update + Future batchEdit(List entitys); + + @Query(''' + SELECT * FROM shop_card + WHERE + orderId = :orderId + ''') + Future> findByOrderId(int orderId); +} diff --git a/lib/common/services/database/dao/supermarket_code_dao.dart b/lib/common/services/database/dao/supermarket_code_dao.dart new file mode 100644 index 0000000..803c1ef --- /dev/null +++ b/lib/common/services/database/dao/supermarket_code_dao.dart @@ -0,0 +1,67 @@ +import 'package:common_utils/common_utils.dart'; +import 'package:floor/floor.dart'; +import 'package:get/get.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +// supermarket_code +@dao +abstract class SupermarketCodeDao { + Future delBySpuIds(List spuIds) async { + if (ObjectUtil.isEmptyList(spuIds)) return -1; + final app = Get.find(); + final database = app.dataBase.database; + final placeholders = List.generate( + spuIds.length, + (index) => '?', + ).join(', '); + final sql = ''' + DELETE FROM supermarket_code + WHERE + spuId IN ($placeholders) + '''; + return await database.rawUpdate(sql, [...spuIds]); + } + + @Query(''' + DELETE FROM supermarket_code + ''') + Future delAllForce(); + + @insert + Future batchSave(List changeList); + + @Query(''' + SELECT + supermarket_sku.id, + supermarket_sku.serviceUniqueCode, + supermarket_sku.spuId, + supermarket_spu.productCode, + supermarket_spu.classifyServiceUniqueCode, + supermarket_spu.productName, + supermarket_spu.picUrl, + supermarket_spu.productNature, + supermarket_spu.salesCount, + supermarket_spu.stock, + supermarket_spu.sort, + supermarket_spu.createTime, + supermarket_spu.updateTime, + supermarket_spu.delFlag, + supermarket_sku.barCode, + supermarket_sku.promotionPrice, + supermarket_sku.purchasePrice, + supermarket_sku.retailPrice, + supermarket_sku.spec, + supermarket_sku.packageNum, + supermarket_sku.unit, + supermarket_sku.packageNum + FROM supermarket_code + INNER JOIN supermarket_spu ON supermarket_code.spuId = supermarket_spu.serviceUniqueCode AND supermarket_spu.delFlag = 0 + INNER JOIN supermarket_sku ON supermarket_spu.serviceUniqueCode = supermarket_sku.spuId + WHERE + 1 = 1 + AND (:barCode = '-1' OR supermarket_code.barCode = :barCode) + LIMIT 1 + ''') + Future getByBarCode(String barCode); +} diff --git a/lib/common/services/database/dao/supermarket_sku_dao.dart b/lib/common/services/database/dao/supermarket_sku_dao.dart new file mode 100644 index 0000000..d1289a8 --- /dev/null +++ b/lib/common/services/database/dao/supermarket_sku_dao.dart @@ -0,0 +1,150 @@ +import 'package:common_utils/common_utils.dart'; +import 'package:floor/floor.dart'; +import 'package:get/get.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +// supermarket_sku +@dao +abstract class SupermarketSkuDao { + Future delBySpuIds(List spuIds) async { + if (ObjectUtil.isEmptyList(spuIds)) return -1; + final app = Get.find(); + final database = app.dataBase.database; + final placeholders = List.generate( + spuIds.length, + (index) => '?', + ).join(', '); + final sql = ''' + DELETE FROM supermarket_sku + WHERE + spuId IN ($placeholders) + '''; + return await database.rawUpdate(sql, [...spuIds]); + } + + @Query(''' + DELETE FROM supermarket_sku + ''') + Future delAllForce(); + + @insert + Future batchSave(List changeList); + + @Query(""" + SELECT DISTINCT COUNT(0) FROM supermarket_sku + INNER JOIN supermarket_spu ON supermarket_sku.spuId = supermarket_spu.serviceUniqueCode AND supermarket_spu.delFlag = 0 + WHERE + 1 = 1 + AND (:barCode = '-1' OR supermarket_sku.barCode LIKE '%' || :barCode || '%' OR supermarket_spu.productName LIKE '%' || :barCode || '%' OR (:barCode != '' AND supermarket_spu.productNamePy LIKE '%' || :barCode || '%') ) + AND (:classifyServiceUniqueCode = '-1' OR supermarket_spu.classifyServiceUniqueCode = :classifyServiceUniqueCode) + """) + Future getCount(String barCode, String classifyServiceUniqueCode); + + @Query(''' + SELECT + supermarket_sku.id, + supermarket_sku.serviceUniqueCode, + supermarket_sku.spuId, + supermarket_spu.productCode, + supermarket_spu.classifyServiceUniqueCode, + supermarket_spu.productName, + supermarket_spu.productNamePy, + supermarket_spu.picUrl, + supermarket_spu.productNature, + supermarket_spu.salesCount, + supermarket_spu.stock, + supermarket_spu.sort, + supermarket_spu.createTime, + supermarket_spu.updateTime, + supermarket_spu.delFlag, + supermarket_sku.barCode, + supermarket_sku.promotionPrice, + supermarket_sku.purchasePrice, + supermarket_sku.retailPrice, + supermarket_sku.spec, + supermarket_sku.packageNum, + supermarket_sku.unit, + supermarket_sku.packageNum + FROM supermarket_sku + INNER JOIN supermarket_spu ON supermarket_sku.spuId = supermarket_spu.serviceUniqueCode AND supermarket_spu.delFlag = 0 + WHERE + 1 = 1 + AND (:barCode = '-1' OR supermarket_sku.barCode LIKE '%' || :barCode || '%' OR supermarket_spu.productName LIKE '%' || :barCode || '%' OR (:barCode != '' AND supermarket_spu.productNamePy LIKE '%' || :barCode || '%') ) + AND (:classifyServiceUniqueCode = '-1' OR supermarket_spu.classifyServiceUniqueCode = :classifyServiceUniqueCode) + ORDER BY supermarket_sku.spuId + LIMIT :pageSize OFFSET :offset + ''') + Future> getAllPage( + String barCode, + String classifyServiceUniqueCode, + int pageSize, + int offset, + ); + + @Query(''' + SELECT + supermarket_sku.id, + supermarket_sku.serviceUniqueCode, + supermarket_sku.spuId, + supermarket_spu.productCode, + supermarket_spu.classifyServiceUniqueCode, + supermarket_spu.productName, + supermarket_spu.picUrl, + supermarket_spu.productNature, + supermarket_spu.salesCount, + supermarket_spu.stock, + supermarket_spu.sort, + supermarket_spu.createTime, + supermarket_spu.updateTime, + supermarket_spu.delFlag, + supermarket_sku.barCode, + supermarket_sku.promotionPrice, + supermarket_sku.purchasePrice, + supermarket_sku.retailPrice, + supermarket_sku.spec, + supermarket_sku.packageNum, + supermarket_sku.unit, + supermarket_sku.packageNum + FROM supermarket_sku + INNER JOIN supermarket_spu ON supermarket_sku.spuId = supermarket_spu.serviceUniqueCode AND supermarket_spu.delFlag = 0 + WHERE + 1 = 1 + AND (:barCode = '-1' OR supermarket_sku.barCode = :barCode) + LIMIT 1 + ''') + Future getByBarCode(String barCode); + + @Query(''' + SELECT + supermarket_sku.id, + supermarket_sku.serviceUniqueCode, + supermarket_sku.spuId, + supermarket_spu.productCode, + supermarket_spu.classifyServiceUniqueCode, + supermarket_spu.productName, + supermarket_spu.picUrl, + supermarket_spu.productNature, + supermarket_spu.salesCount, + supermarket_spu.stock, + supermarket_spu.sort, + supermarket_spu.createTime, + supermarket_spu.updateTime, + supermarket_spu.delFlag, + supermarket_sku.barCode, + supermarket_sku.promotionPrice, + supermarket_sku.purchasePrice, + supermarket_sku.retailPrice, + supermarket_sku.spec, + supermarket_sku.packageNum, + supermarket_sku.unit, + supermarket_sku.packageNum + FROM supermarket_sku + INNER JOIN supermarket_spu ON supermarket_sku.spuId = supermarket_spu.serviceUniqueCode AND supermarket_spu.delFlag = 0 + WHERE + 1 = 1 + AND (:skuCode = '-1' OR supermarket_sku.serviceUniqueCode = :skuCode) + LIMIT 1 + ''') + Future getBySkuCode(String skuCode); +} diff --git a/lib/common/services/database/dao/supermarket_spu_dao.dart b/lib/common/services/database/dao/supermarket_spu_dao.dart new file mode 100644 index 0000000..5fc8d89 --- /dev/null +++ b/lib/common/services/database/dao/supermarket_spu_dao.dart @@ -0,0 +1,72 @@ +import 'package:common_utils/common_utils.dart'; +import 'package:floor/floor.dart'; +import 'package:get/get.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +// supermarket_spu +@dao +abstract class SupermarketSpuDao { + @Query(''' + SELECT supermarket_spu.* + FROM supermarket_spu + LEFT JOIN classify ON supermarket_spu.classifyServiceUniqueCode = classify.serviceUniqueCode AND classify.delFlag = 0 + WHERE + supermarket_spu.delFlag = 0 + AND (:classifyServiceUniqueCode = '-1' OR supermarket_spu.classifyServiceUniqueCode = :classifyServiceUniqueCode) + ORDER BY classify.sort + ''') + Future> getAll(String classifyServiceUniqueCode); + + @Query(""" + SELECT DISTINCT COUNT(0) FROM supermarket_spu + WHERE + delFlag = 0 + """) + Future getCount(); + + @Query(''' + SELECT supermarket_spu.* + FROM supermarket_spu + LEFT JOIN classify ON supermarket_spu.classifyServiceUniqueCode = classify.serviceUniqueCode AND classify.delFlag = 0 + WHERE + supermarket_spu.delFlag = 0 + AND (:classifyServiceUniqueCode = '-1' OR supermarket_spu.classifyServiceUniqueCode = :classifyServiceUniqueCode) + ORDER BY classify.sort + LIMIT :pageSize OFFSET :offset + ''') + Future> getAllPage( + String classifyServiceUniqueCode, + int pageSize, + int offset, + ); + + @Query(''' + SELECT * FROM supermarket_spu WHERE delFlag = 0 AND serviceUniqueCode = :code + ''') + Future getByCode(String code); + + @Query(''' + DELETE FROM supermarket_spu + ''') + Future delAllForce(); + + @update + Future batchEdit(List changeList); + + @insert + Future batchSave(List changeList); + + Future delByIds(List codes) async { + if (ObjectUtil.isEmptyList(codes)) return -1; + final app = Get.find(); + final database = app.dataBase.database; + final placeholders = List.generate(codes.length, (index) => '?').join(', '); + final sql = ''' + UPDATE supermarket_spu SET delFlag = 1 + WHERE + serviceUniqueCode IN ($placeholders) + '''; + return await database.rawUpdate(sql, [...codes]); + } +} diff --git a/lib/common/services/database/dao/user_dao.dart b/lib/common/services/database/dao/user_dao.dart new file mode 100644 index 0000000..b8baefc --- /dev/null +++ b/lib/common/services/database/dao/user_dao.dart @@ -0,0 +1,39 @@ +import 'package:floor/floor.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +// user_record 表 +@dao +abstract class UserDao { + @insert + Future save(UserEntity entity); + + @insert + Future batchSave(List entitys); + + @delete + Future del(UserEntity entity); + + @delete + Future batchDel(List entitys); + + @update + Future edit(UserEntity entity); + + @update + Future batchEdit(List entitys); + + @Query(''' + SELECT * FROM user_record + WHERE + userId = :userId + ''') + Future findByUserId(String userId); + + @Query(''' + SELECT * FROM user_record + WHERE + walletCardNumber = :walletCardNumber + ''') + Future findByCardNumber(String walletCardNumber); +} diff --git a/lib/common/services/database/database.dart b/lib/common/services/database/database.dart new file mode 100644 index 0000000..87c5f8b --- /dev/null +++ b/lib/common/services/database/database.dart @@ -0,0 +1,83 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:floor/floor.dart'; +import 'package:pj_supermarket_cloud/common/index.dart'; +import 'package:pj_supermarket_cloud/common/services/database/database_migration.dart'; + +import 'package:sqflite/sqflite.dart' as sqflite; + +part 'database.g.dart'; + +const entities = [ + BroadcastEntity, + OrderEntity, + OrderHoldBillEntity, + CanteenSpuEntity, + SupermarketSkuEntity, + SupermarketSpuEntity, + SupermarketCodeEntity, + ShopCardEntity, + ClassifyEntity, + PrintHistoryEntity, + UserEntity, + FaceEntity, + SettingEntity, +]; + +const typeConverters = [ + DateTimeConverter, + DateTimeEmptyConverter, + OrderStatusConverter, + OrderTypeConverter, + OrderPayTypeConverter, + SysTypeConverter, + PrintStatusConverter, + ProductHourTypeConverter, + SupermarketNatureConverter, + DiscountsTypeConverter, + ListDiscountsConverter, + ListIntConverter, + BoolConverter, +]; + +@Database(version: 7, entities: entities) +@TypeConverters(typeConverters) +abstract class AppDatabase extends FloorDatabase { + BroadcastDao get getBroadcastDao; + OrderDao get getOrderDao; + OrderHoldBillDao get getOrderHoldBillDao; + CanteenSpuDao get getCanteenSpuDao; + SupermarketSkuDao get getSupermarketSkuDao; + SupermarketSpuDao get getSupermarketSpuDao; + SupermarketCodeDao get getSupermarketCodeDao; + ShopCardDao get getShopCardDao; + ClassifyDao get getClassifyDao; + PrintHistoryDao get getPrintHistoryDao; + UserDao get getUserDao; + FaceDao get getFaceDao; + SettingDao get getSettingDao; + + static Future getDataBase() async { + final dbPath = await AppFileTool.getDatabasePath(); + final name = "$dbPath/${Constants.databaseName}"; + final dataBase = await $FloorAppDatabase + .databaseBuilder(name) + .addCallback( + Callback( + onCreate: (database, version) async { + logger.i('数据库已创建,版本: $version'); + }, + onOpen: (database) async { + logger.i('数据库已打开'); + }, + onUpgrade: (database, startVersion, endVersion) { + logger.i('数据库已升级,版本: $startVersion -> $endVersion'); + }, + ), + ) + .addMigrations(DatabaseMigration.getDatabaseMigrations()) + .build(); + return dataBase; + } +} diff --git a/lib/common/services/database/database.g.dart b/lib/common/services/database/database.g.dart new file mode 100644 index 0000000..8db3b37 --- /dev/null +++ b/lib/common/services/database/database.g.dart @@ -0,0 +1,1993 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'database.dart'; + +// ************************************************************************** +// FloorGenerator +// ************************************************************************** + +abstract class $AppDatabaseBuilderContract { + /// Adds migrations to the builder. + $AppDatabaseBuilderContract addMigrations(List migrations); + + /// Adds a database [Callback] to the builder. + $AppDatabaseBuilderContract addCallback(Callback callback); + + /// Creates the database and initializes it. + Future build(); +} + +// ignore: avoid_classes_with_only_static_members +class $FloorAppDatabase { + /// Creates a database builder for a persistent database. + /// Once a database is built, you should keep a reference to it and re-use it. + static $AppDatabaseBuilderContract databaseBuilder(String name) => + _$AppDatabaseBuilder(name); + + /// Creates a database builder for an in memory database. + /// Information stored in an in memory database disappears when the process is killed. + /// Once a database is built, you should keep a reference to it and re-use it. + static $AppDatabaseBuilderContract inMemoryDatabaseBuilder() => + _$AppDatabaseBuilder(null); +} + +class _$AppDatabaseBuilder implements $AppDatabaseBuilderContract { + _$AppDatabaseBuilder(this.name); + + final String? name; + + final List _migrations = []; + + Callback? _callback; + + @override + $AppDatabaseBuilderContract addMigrations(List migrations) { + _migrations.addAll(migrations); + return this; + } + + @override + $AppDatabaseBuilderContract addCallback(Callback callback) { + _callback = callback; + return this; + } + + @override + Future build() async { + final path = name != null + ? await sqfliteDatabaseFactory.getDatabasePath(name!) + : ':memory:'; + final database = _$AppDatabase(); + database.database = await database.open( + path, + _migrations, + _callback, + ); + return database; + } +} + +class _$AppDatabase extends AppDatabase { + _$AppDatabase([StreamController? listener]) { + changeListener = listener ?? StreamController.broadcast(); + } + + BroadcastDao? _getBroadcastDaoInstance; + + OrderDao? _getOrderDaoInstance; + + OrderHoldBillDao? _getOrderHoldBillDaoInstance; + + CanteenSpuDao? _getCanteenSpuDaoInstance; + + SupermarketSkuDao? _getSupermarketSkuDaoInstance; + + SupermarketSpuDao? _getSupermarketSpuDaoInstance; + + SupermarketCodeDao? _getSupermarketCodeDaoInstance; + + ShopCardDao? _getShopCardDaoInstance; + + ClassifyDao? _getClassifyDaoInstance; + + PrintHistoryDao? _getPrintHistoryDaoInstance; + + UserDao? _getUserDaoInstance; + + FaceDao? _getFaceDaoInstance; + + SettingDao? _getSettingDaoInstance; + + Future open( + String path, + List migrations, [ + Callback? callback, + ]) async { + final databaseOptions = sqflite.OpenDatabaseOptions( + version: 7, + onConfigure: (database) async { + await database.execute('PRAGMA foreign_keys = ON'); + await callback?.onConfigure?.call(database); + }, + onOpen: (database) async { + await callback?.onOpen?.call(database); + }, + onUpgrade: (database, startVersion, endVersion) async { + await MigrationAdapter.runMigrations( + database, startVersion, endVersion, migrations); + + await callback?.onUpgrade?.call(database, startVersion, endVersion); + }, + onCreate: (database, version) async { + await database.execute( + 'CREATE TABLE IF NOT EXISTS `broadcast` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `content` TEXT NOT NULL, `broadcastCount` INTEGER NOT NULL, `broadcastMaxCount` INTEGER NOT NULL, `isBroadcast` INTEGER NOT NULL, `isDone` INTEGER NOT NULL, `isDoneTime` TEXT, `serviceCode` TEXT NOT NULL, `serviceOrderCode` TEXT NOT NULL, `userId` TEXT NOT NULL, `username` TEXT NOT NULL, `userMobile` TEXT NOT NULL, `productDesc` TEXT NOT NULL, `payPrice` INTEGER NOT NULL, `hourType` INTEGER, `enable` INTEGER NOT NULL, `createTime` TEXT NOT NULL)'); + await database.execute( + 'CREATE TABLE IF NOT EXISTS `order_record` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `uniqueCode` TEXT NOT NULL, `serviceUniqueCode` TEXT NOT NULL, `sysType` TEXT NOT NULL, `userCode` TEXT NOT NULL, `username` TEXT, `hourType` TEXT, `price` INTEGER NOT NULL, `extraPrice` INTEGER NOT NULL, `payPrice` INTEGER NOT NULL, `walletId` TEXT, `orderSourceTypeId` TEXT, `type` TEXT NOT NULL, `status` TEXT NOT NULL, `payType` TEXT NOT NULL, `productDesc` TEXT, `count` INTEGER NOT NULL, `weight` REAL NOT NULL, `payTime` TEXT, `createTime` TEXT NOT NULL)'); + await database.execute( + 'CREATE TABLE IF NOT EXISTS `order_hold_bill` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `sysType` TEXT NOT NULL, `carListJson` TEXT, `userJson` TEXT, `price` INTEGER NOT NULL, `extraPrice` INTEGER NOT NULL, `payPrice` INTEGER NOT NULL, `createTime` TEXT NOT NULL)'); + await database.execute( + 'CREATE TABLE IF NOT EXISTS `canteen_spu` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `serviceUniqueCode` TEXT NOT NULL, `classifyServiceUniqueCode` TEXT NOT NULL, `hourType` INTEGER, `saleDate` TEXT NOT NULL, `name` TEXT NOT NULL, `price` INTEGER NOT NULL, `image` TEXT, `isDefault` INTEGER NOT NULL, `status` INTEGER NOT NULL, `sort` INTEGER NOT NULL, `isPriceIncreased` INTEGER NOT NULL, `createTime` TEXT NOT NULL, `updateTime` TEXT NOT NULL)'); + await database.execute( + 'CREATE TABLE IF NOT EXISTS `supermarket_sku` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `serviceUniqueCode` TEXT NOT NULL, `spuId` TEXT NOT NULL, `barCode` TEXT NOT NULL, `spec` TEXT NOT NULL, `unit` TEXT NOT NULL, `packageNum` INTEGER NOT NULL, `purchasePrice` INTEGER NOT NULL, `retailPrice` INTEGER NOT NULL, `promotionPrice` INTEGER NOT NULL)'); + await database.execute( + 'CREATE TABLE IF NOT EXISTS `supermarket_spu` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `serviceUniqueCode` TEXT NOT NULL, `classifyServiceUniqueCode` TEXT NOT NULL, `productCode` TEXT NOT NULL, `productName` TEXT NOT NULL, `productNamePy` TEXT, `productNature` TEXT NOT NULL, `barCode` TEXT, `packageNum` INTEGER NOT NULL, `unit` TEXT NOT NULL, `spec` TEXT NOT NULL, `picUrl` TEXT, `stock` REAL, `salesCount` REAL, `purchasePrice` INTEGER NOT NULL, `retailPrice` INTEGER NOT NULL, `promotionPrice` INTEGER NOT NULL, `sort` INTEGER NOT NULL, `createTime` TEXT NOT NULL, `updateTime` TEXT NOT NULL, `delFlag` INTEGER NOT NULL)'); + await database.execute( + 'CREATE TABLE IF NOT EXISTS `supermarket_code` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `serviceUniqueCode` TEXT NOT NULL, `spuId` TEXT NOT NULL, `unitId` TEXT NOT NULL, `barCode` TEXT NOT NULL)'); + await database.execute( + 'CREATE TABLE IF NOT EXISTS `shop_card` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `orderId` INTEGER NOT NULL, `productServiceUniqueCode` TEXT NOT NULL, `productName` TEXT NOT NULL, `productPic` TEXT NOT NULL, `productPrice` INTEGER NOT NULL, `nature` TEXT NOT NULL, `productNumber` INTEGER NOT NULL, `productWidget` REAL NOT NULL, `totalPrice` INTEGER NOT NULL, `isPriceIncreased` INTEGER NOT NULL)'); + await database.execute( + 'CREATE TABLE IF NOT EXISTS `classify` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `serviceUniqueCode` TEXT NOT NULL, `sysType` TEXT NOT NULL, `name` TEXT NOT NULL, `categoryType` TEXT NOT NULL, `sort` INTEGER NOT NULL, `createTime` TEXT NOT NULL, `updateTime` TEXT NOT NULL, `delFlag` INTEGER NOT NULL)'); + await database.execute( + 'CREATE TABLE IF NOT EXISTS `print_history` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `orderId` TEXT NOT NULL, `serviceOrderId` TEXT NOT NULL, `servicePrintId` TEXT NOT NULL, `invoiceUrl` TEXT NOT NULL, `status` TEXT NOT NULL, `printTime` TEXT, `syncTime` TEXT, `createTime` TEXT NOT NULL)'); + await database.execute( + 'CREATE TABLE IF NOT EXISTS `user_record` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `userId` TEXT NOT NULL, `nickname` TEXT NOT NULL, `mobile` TEXT, `avatar` TEXT, `walletCardNumber` TEXT, `walletId` TEXT, `walletTypeId` TEXT, `walletBalance` INTEGER, `canteenId` TEXT, `discountsList` TEXT, `discountsUpdateTime` TEXT, `createTime` TEXT, `updateTime` TEXT)'); + await database.execute( + 'CREATE TABLE IF NOT EXISTS `face` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `userId` TEXT NOT NULL, `sampleId` TEXT NOT NULL, `userName` TEXT NOT NULL, `deptId` TEXT, `deptName` TEXT, `imagePath` TEXT NOT NULL, `path` TEXT, `faceModel` TEXT, `createTime` TEXT, `updateTime` TEXT)'); + await database.execute( + 'CREATE TABLE IF NOT EXISTS `setting` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `userJson` TEXT NOT NULL, `sysJson` TEXT NOT NULL, `canteenExtraPriceConfigJson` TEXT)'); + + await callback?.onCreate?.call(database, version); + }, + ); + return sqfliteDatabaseFactory.openDatabase(path, options: databaseOptions); + } + + @override + BroadcastDao get getBroadcastDao { + return _getBroadcastDaoInstance ??= + _$BroadcastDao(database, changeListener); + } + + @override + OrderDao get getOrderDao { + return _getOrderDaoInstance ??= _$OrderDao(database, changeListener); + } + + @override + OrderHoldBillDao get getOrderHoldBillDao { + return _getOrderHoldBillDaoInstance ??= + _$OrderHoldBillDao(database, changeListener); + } + + @override + CanteenSpuDao get getCanteenSpuDao { + return _getCanteenSpuDaoInstance ??= + _$CanteenSpuDao(database, changeListener); + } + + @override + SupermarketSkuDao get getSupermarketSkuDao { + return _getSupermarketSkuDaoInstance ??= + _$SupermarketSkuDao(database, changeListener); + } + + @override + SupermarketSpuDao get getSupermarketSpuDao { + return _getSupermarketSpuDaoInstance ??= + _$SupermarketSpuDao(database, changeListener); + } + + @override + SupermarketCodeDao get getSupermarketCodeDao { + return _getSupermarketCodeDaoInstance ??= + _$SupermarketCodeDao(database, changeListener); + } + + @override + ShopCardDao get getShopCardDao { + return _getShopCardDaoInstance ??= _$ShopCardDao(database, changeListener); + } + + @override + ClassifyDao get getClassifyDao { + return _getClassifyDaoInstance ??= _$ClassifyDao(database, changeListener); + } + + @override + PrintHistoryDao get getPrintHistoryDao { + return _getPrintHistoryDaoInstance ??= + _$PrintHistoryDao(database, changeListener); + } + + @override + UserDao get getUserDao { + return _getUserDaoInstance ??= _$UserDao(database, changeListener); + } + + @override + FaceDao get getFaceDao { + return _getFaceDaoInstance ??= _$FaceDao(database, changeListener); + } + + @override + SettingDao get getSettingDao { + return _getSettingDaoInstance ??= _$SettingDao(database, changeListener); + } +} + +class _$BroadcastDao extends BroadcastDao { + _$BroadcastDao( + this.database, + this.changeListener, + ) : _queryAdapter = QueryAdapter(database), + _broadcastEntityInsertionAdapter = InsertionAdapter( + database, + 'broadcast', + (BroadcastEntity item) => { + 'id': item.id, + 'content': item.content, + 'broadcastCount': item.broadcastCount, + 'broadcastMaxCount': item.broadcastMaxCount, + 'isBroadcast': _boolConverter.encode(item.isBroadcast), + 'isDone': _boolConverter.encode(item.isDone), + 'isDoneTime': _dateTimeEmptyConverter.encode(item.isDoneTime), + 'serviceCode': item.serviceCode, + 'serviceOrderCode': item.serviceOrderCode, + 'userId': item.userId, + 'username': item.username, + 'userMobile': item.userMobile, + 'productDesc': item.productDesc, + 'payPrice': item.payPrice, + 'hourType': item.hourType?.index, + 'enable': _boolConverter.encode(item.enable), + 'createTime': _dateTimeConverter.encode(item.createTime) + }), + _broadcastEntityUpdateAdapter = UpdateAdapter( + database, + 'broadcast', + ['id'], + (BroadcastEntity item) => { + 'id': item.id, + 'content': item.content, + 'broadcastCount': item.broadcastCount, + 'broadcastMaxCount': item.broadcastMaxCount, + 'isBroadcast': _boolConverter.encode(item.isBroadcast), + 'isDone': _boolConverter.encode(item.isDone), + 'isDoneTime': _dateTimeEmptyConverter.encode(item.isDoneTime), + 'serviceCode': item.serviceCode, + 'serviceOrderCode': item.serviceOrderCode, + 'userId': item.userId, + 'username': item.username, + 'userMobile': item.userMobile, + 'productDesc': item.productDesc, + 'payPrice': item.payPrice, + 'hourType': item.hourType?.index, + 'enable': _boolConverter.encode(item.enable), + 'createTime': _dateTimeConverter.encode(item.createTime) + }); + + final sqflite.DatabaseExecutor database; + + final StreamController changeListener; + + final QueryAdapter _queryAdapter; + + final InsertionAdapter _broadcastEntityInsertionAdapter; + + final UpdateAdapter _broadcastEntityUpdateAdapter; + + @override + Future del(String serviceOrderCode) async { + await _queryAdapter.queryNoReturn( + 'DELETE FROM broadcast WHERE isDone = 0 AND (?1 = \'-1\' OR serviceOrderCode = ?1)', + arguments: [serviceOrderCode]); + } + + @override + Future> getAll(String hourType) async { + return _queryAdapter.queryList( + 'SELECT * FROM broadcast WHERE isDone = 0 AND enable = 1 AND broadcastCount < broadcastMaxCount AND (?1 = \'-1\' OR hourType = ?1) AND strftime(\'%Y-%m-%d\', createTime) = strftime(\'%Y-%m-%d\', \'now\', \'localtime\')', + mapper: (Map row) => BroadcastEntity(id: row['id'] as int?, content: row['content'] as String, broadcastCount: row['broadcastCount'] as int, broadcastMaxCount: row['broadcastMaxCount'] as int, isBroadcast: _boolConverter.decode(row['isBroadcast'] as int), isDone: _boolConverter.decode(row['isDone'] as int), createTime: _dateTimeConverter.decode(row['createTime'] as String), serviceCode: row['serviceCode'] as String, serviceOrderCode: row['serviceOrderCode'] as String, userId: row['userId'] as String, username: row['username'] as String, userMobile: row['userMobile'] as String, productDesc: row['productDesc'] as String, payPrice: row['payPrice'] as int, hourType: row['hourType'] == null ? null : ProductHourType.values[row['hourType'] as int], enable: _boolConverter.decode(row['enable'] as int)), + arguments: [hourType]); + } + + @override + Future> findAllPage( + String hourType, + int pageSize, + int offset, + ) async { + return _queryAdapter.queryList( + 'SELECT * FROM broadcast WHERE strftime(\'%Y-%m-%d\', createTime) = strftime(\'%Y-%m-%d\', \'now\', \'localtime\') AND (?1 = \'-1\' OR hourType = ?1) LIMIT ?2 OFFSET ?3', + mapper: (Map row) => BroadcastEntity(id: row['id'] as int?, content: row['content'] as String, broadcastCount: row['broadcastCount'] as int, broadcastMaxCount: row['broadcastMaxCount'] as int, isBroadcast: _boolConverter.decode(row['isBroadcast'] as int), isDone: _boolConverter.decode(row['isDone'] as int), createTime: _dateTimeConverter.decode(row['createTime'] as String), serviceCode: row['serviceCode'] as String, serviceOrderCode: row['serviceOrderCode'] as String, userId: row['userId'] as String, username: row['username'] as String, userMobile: row['userMobile'] as String, productDesc: row['productDesc'] as String, payPrice: row['payPrice'] as int, hourType: row['hourType'] == null ? null : ProductHourType.values[row['hourType'] as int], enable: _boolConverter.decode(row['enable'] as int)), + arguments: [hourType, pageSize, offset]); + } + + @override + Future count(String hourType) async { + return _queryAdapter.query( + 'SELECT COUNT(0) FROM broadcast WHERE strftime(\'%Y-%m-%d\', createTime) = strftime(\'%Y-%m-%d\', \'now\', \'localtime\') AND (?1 = \'-1\' OR hourType = ?1)', + mapper: (Map row) => row.values.first as int, + arguments: [hourType]); + } + + @override + Future save(BroadcastEntity entity) async { + await _broadcastEntityInsertionAdapter.insert( + entity, OnConflictStrategy.abort); + } + + @override + Future batchSave(List entitys) async { + await _broadcastEntityInsertionAdapter.insertList( + entitys, OnConflictStrategy.abort); + } + + @override + Future edit(BroadcastEntity entity) async { + await _broadcastEntityUpdateAdapter.update( + entity, OnConflictStrategy.abort); + } +} + +class _$OrderDao extends OrderDao { + _$OrderDao( + this.database, + this.changeListener, + ) : _queryAdapter = QueryAdapter(database), + _orderEntityInsertionAdapter = InsertionAdapter( + database, + 'order_record', + (OrderEntity item) => { + 'id': item.id, + 'uniqueCode': item.uniqueCode, + 'serviceUniqueCode': item.serviceUniqueCode, + 'sysType': _sysTypeConverter.encode(item.sysType), + 'userCode': item.userCode, + 'username': item.username, + 'hourType': item.hourType, + 'price': item.price, + 'extraPrice': item.extraPrice, + 'payPrice': item.payPrice, + 'walletId': item.walletId, + 'orderSourceTypeId': item.orderSourceTypeId, + 'type': _orderTypeConverter.encode(item.type), + 'status': _orderStatusConverter.encode(item.status), + 'payType': _orderPayTypeConverter.encode(item.payType), + 'productDesc': item.productDesc, + 'count': item.count, + 'weight': item.weight, + 'payTime': _dateTimeEmptyConverter.encode(item.payTime), + 'createTime': _dateTimeConverter.encode(item.createTime) + }), + _orderEntityUpdateAdapter = UpdateAdapter( + database, + 'order_record', + ['id'], + (OrderEntity item) => { + 'id': item.id, + 'uniqueCode': item.uniqueCode, + 'serviceUniqueCode': item.serviceUniqueCode, + 'sysType': _sysTypeConverter.encode(item.sysType), + 'userCode': item.userCode, + 'username': item.username, + 'hourType': item.hourType, + 'price': item.price, + 'extraPrice': item.extraPrice, + 'payPrice': item.payPrice, + 'walletId': item.walletId, + 'orderSourceTypeId': item.orderSourceTypeId, + 'type': _orderTypeConverter.encode(item.type), + 'status': _orderStatusConverter.encode(item.status), + 'payType': _orderPayTypeConverter.encode(item.payType), + 'productDesc': item.productDesc, + 'count': item.count, + 'weight': item.weight, + 'payTime': _dateTimeEmptyConverter.encode(item.payTime), + 'createTime': _dateTimeConverter.encode(item.createTime) + }), + _orderEntityDeletionAdapter = DeletionAdapter( + database, + 'order_record', + ['id'], + (OrderEntity item) => { + 'id': item.id, + 'uniqueCode': item.uniqueCode, + 'serviceUniqueCode': item.serviceUniqueCode, + 'sysType': _sysTypeConverter.encode(item.sysType), + 'userCode': item.userCode, + 'username': item.username, + 'hourType': item.hourType, + 'price': item.price, + 'extraPrice': item.extraPrice, + 'payPrice': item.payPrice, + 'walletId': item.walletId, + 'orderSourceTypeId': item.orderSourceTypeId, + 'type': _orderTypeConverter.encode(item.type), + 'status': _orderStatusConverter.encode(item.status), + 'payType': _orderPayTypeConverter.encode(item.payType), + 'productDesc': item.productDesc, + 'count': item.count, + 'weight': item.weight, + 'payTime': _dateTimeEmptyConverter.encode(item.payTime), + 'createTime': _dateTimeConverter.encode(item.createTime) + }); + + final sqflite.DatabaseExecutor database; + + final StreamController changeListener; + + final QueryAdapter _queryAdapter; + + final InsertionAdapter _orderEntityInsertionAdapter; + + final UpdateAdapter _orderEntityUpdateAdapter; + + final DeletionAdapter _orderEntityDeletionAdapter; + + @override + Future editOfflineStatus(String codes) async { + await _queryAdapter.queryNoReturn( + 'UPDATE order_record SET status = \'PAY\' WHERE uniqueCode IN ?1', + arguments: [codes]); + } + + @override + Future> findAll( + String status, + DateTime startCreateOrderTime, + DateTime endCreateOrderTime, + int pageSize, + int offset, + String sysType, + String hourType, + ) async { + return _queryAdapter.queryList( + 'SELECT * FROM order_record WHERE (?1 == \"-1\" OR status = ?1) AND (?6 = \'-1\' OR sysType = ?6) AND (?7 = \'-1\' OR hourType = ?7) AND strftime(\'%Y-%m-%d\', createTime) >= strftime(\'%Y-%m-%d\', ?2) AND strftime(\'%Y-%m-%d\', createTime) <= strftime(\'%Y-%m-%d\', ?3) ORDER BY createTime DESC LIMIT ?4 OFFSET ?5', + mapper: (Map row) => OrderEntity( + id: row['id'] as int?, + uniqueCode: row['uniqueCode'] as String, + serviceUniqueCode: row['serviceUniqueCode'] as String, + sysType: _sysTypeConverter.decode(row['sysType'] as String), + userCode: row['userCode'] as String, + username: row['username'] as String?, + hourType: row['hourType'] as String?, + price: row['price'] as int, + extraPrice: row['extraPrice'] as int, + payPrice: row['payPrice'] as int, + walletId: row['walletId'] as String?, + orderSourceTypeId: row['orderSourceTypeId'] as String?, + type: _orderTypeConverter.decode(row['type'] as String), + status: _orderStatusConverter.decode(row['status'] as String), + payType: _orderPayTypeConverter.decode(row['payType'] as String), + productDesc: row['productDesc'] as String?, + count: row['count'] as int, + weight: row['weight'] as double, + payTime: _dateTimeEmptyConverter.decode(row['payTime'] as String), + createTime: _dateTimeConverter.decode(row['createTime'] as String)), + arguments: [ + status, + _dateTimeConverter.encode(startCreateOrderTime), + _dateTimeConverter.encode(endCreateOrderTime), + pageSize, + offset, + sysType, + hourType + ]); + } + + @override + Future count( + String status, + DateTime startCreateOrderTime, + DateTime endCreateOrderTime, + String sysType, + String hourType, + ) async { + return _queryAdapter.query( + 'SELECT COUNT(0) FROM order_record WHERE (?1 == \"-1\" OR status = ?1) AND (?4 = \'-1\' OR sysType = ?4) AND (?5 = \'-1\' OR hourType = ?5) AND strftime(\'%Y-%m-%d\', createTime) >= strftime(\'%Y-%m-%d\', ?2) AND strftime(\'%Y-%m-%d\', createTime) <= strftime(\'%Y-%m-%d\', ?3)', + mapper: (Map row) => row.values.first as int, + arguments: [ + status, + _dateTimeConverter.encode(startCreateOrderTime), + _dateTimeConverter.encode(endCreateOrderTime), + sysType, + hourType + ]); + } + + @override + Future> findOffline( + String sysType, + DateTime startCreateOrderTime, + DateTime endCreateOrderTime, + ) async { + return _queryAdapter.queryList( + 'SELECT * FROM order_record WHERE status = \'OFFLINE_PAY\' AND (?1 = \'-1\' OR sysType = ?1) AND createTime >= ?2 AND createTime <= ?3', + mapper: (Map row) => OrderEntity( + id: row['id'] as int?, + uniqueCode: row['uniqueCode'] as String, + serviceUniqueCode: row['serviceUniqueCode'] as String, + sysType: _sysTypeConverter.decode(row['sysType'] as String), + userCode: row['userCode'] as String, + username: row['username'] as String?, + hourType: row['hourType'] as String?, + price: row['price'] as int, + extraPrice: row['extraPrice'] as int, + payPrice: row['payPrice'] as int, + walletId: row['walletId'] as String?, + orderSourceTypeId: row['orderSourceTypeId'] as String?, + type: _orderTypeConverter.decode(row['type'] as String), + status: _orderStatusConverter.decode(row['status'] as String), + payType: _orderPayTypeConverter.decode(row['payType'] as String), + productDesc: row['productDesc'] as String?, + count: row['count'] as int, + weight: row['weight'] as double, + payTime: _dateTimeEmptyConverter.decode(row['payTime'] as String), + createTime: _dateTimeConverter.decode(row['createTime'] as String)), + arguments: [ + sysType, + _dateTimeConverter.encode(startCreateOrderTime), + _dateTimeConverter.encode(endCreateOrderTime) + ]); + } + + @override + Future findById(int id) async { + return _queryAdapter.query( + 'SELECT * FROM order_record WHERE id = ?1', + mapper: (Map row) => OrderEntity( + id: row['id'] as int?, + uniqueCode: row['uniqueCode'] as String, + serviceUniqueCode: row['serviceUniqueCode'] as String, + sysType: _sysTypeConverter.decode(row['sysType'] as String), + userCode: row['userCode'] as String, + username: row['username'] as String?, + hourType: row['hourType'] as String?, + price: row['price'] as int, + extraPrice: row['extraPrice'] as int, + payPrice: row['payPrice'] as int, + walletId: row['walletId'] as String?, + orderSourceTypeId: row['orderSourceTypeId'] as String?, + type: _orderTypeConverter.decode(row['type'] as String), + status: _orderStatusConverter.decode(row['status'] as String), + payType: _orderPayTypeConverter.decode(row['payType'] as String), + productDesc: row['productDesc'] as String?, + count: row['count'] as int, + weight: row['weight'] as double, + payTime: _dateTimeEmptyConverter.decode(row['payTime'] as String), + createTime: _dateTimeConverter.decode(row['createTime'] as String)), + arguments: [id]); + } + + @override + Future save(OrderEntity entity) { + return _orderEntityInsertionAdapter.insertAndReturnId( + entity, OnConflictStrategy.abort); + } + + @override + Future batchSave(List entitys) async { + await _orderEntityInsertionAdapter.insertList( + entitys, OnConflictStrategy.abort); + } + + @override + Future edit(OrderEntity entity) async { + await _orderEntityUpdateAdapter.update(entity, OnConflictStrategy.abort); + } + + @override + Future batchEdit(List entitys) async { + await _orderEntityUpdateAdapter.updateList( + entitys, OnConflictStrategy.abort); + } + + @override + Future del(OrderEntity entity) async { + await _orderEntityDeletionAdapter.delete(entity); + } + + @override + Future batchDel(List entitys) async { + await _orderEntityDeletionAdapter.deleteList(entitys); + } +} + +class _$OrderHoldBillDao extends OrderHoldBillDao { + _$OrderHoldBillDao( + this.database, + this.changeListener, + ) : _queryAdapter = QueryAdapter(database), + _orderHoldBillEntityInsertionAdapter = InsertionAdapter( + database, + 'order_hold_bill', + (OrderHoldBillEntity item) => { + 'id': item.id, + 'sysType': _sysTypeConverter.encode(item.sysType), + 'carListJson': item.carListJson, + 'userJson': item.userJson, + 'price': item.price, + 'extraPrice': item.extraPrice, + 'payPrice': item.payPrice, + 'createTime': _dateTimeConverter.encode(item.createTime) + }), + _orderHoldBillEntityUpdateAdapter = UpdateAdapter( + database, + 'order_hold_bill', + ['id'], + (OrderHoldBillEntity item) => { + 'id': item.id, + 'sysType': _sysTypeConverter.encode(item.sysType), + 'carListJson': item.carListJson, + 'userJson': item.userJson, + 'price': item.price, + 'extraPrice': item.extraPrice, + 'payPrice': item.payPrice, + 'createTime': _dateTimeConverter.encode(item.createTime) + }), + _orderHoldBillEntityDeletionAdapter = DeletionAdapter( + database, + 'order_hold_bill', + ['id'], + (OrderHoldBillEntity item) => { + 'id': item.id, + 'sysType': _sysTypeConverter.encode(item.sysType), + 'carListJson': item.carListJson, + 'userJson': item.userJson, + 'price': item.price, + 'extraPrice': item.extraPrice, + 'payPrice': item.payPrice, + 'createTime': _dateTimeConverter.encode(item.createTime) + }); + + final sqflite.DatabaseExecutor database; + + final StreamController changeListener; + + final QueryAdapter _queryAdapter; + + final InsertionAdapter + _orderHoldBillEntityInsertionAdapter; + + final UpdateAdapter _orderHoldBillEntityUpdateAdapter; + + final DeletionAdapter + _orderHoldBillEntityDeletionAdapter; + + @override + Future> list( + String startTime, + String endTime, + int pageSize, + int offset, + ) async { + return _queryAdapter.queryList( + 'SELECT * FROM order_hold_bill WHERE createTime >= ?1 AND createTime <= ?2 ORDER BY createTime DESC LIMIT ?3 OFFSET ?4', + mapper: (Map row) => OrderHoldBillEntity(id: row['id'] as int?, sysType: _sysTypeConverter.decode(row['sysType'] as String), carListJson: row['carListJson'] as String?, userJson: row['userJson'] as String?, price: row['price'] as int, extraPrice: row['extraPrice'] as int, payPrice: row['payPrice'] as int, createTime: _dateTimeConverter.decode(row['createTime'] as String)), + arguments: [startTime, endTime, pageSize, offset]); + } + + @override + Future count( + String startTime, + String endTime, + ) async { + return _queryAdapter.query( + 'SELECT COUNT(*) FROM order_hold_bill WHERE createTime >= ?1 AND createTime <= ?2', + mapper: (Map row) => row.values.first as int, + arguments: [startTime, endTime]); + } + + @override + Future deleteOlderThan7Days() async { + return _queryAdapter.query( + 'DELETE FROM order_hold_bill WHERE createTime < datetime(\'now\', \'-7 days\')', + mapper: (Map row) => row.values.first as int); + } + + @override + Future save(OrderHoldBillEntity entity) { + return _orderHoldBillEntityInsertionAdapter.insertAndReturnId( + entity, OnConflictStrategy.abort); + } + + @override + Future edit(OrderHoldBillEntity entity) async { + await _orderHoldBillEntityUpdateAdapter.update( + entity, OnConflictStrategy.abort); + } + + @override + Future del(OrderHoldBillEntity entity) async { + await _orderHoldBillEntityDeletionAdapter.delete(entity); + } +} + +class _$CanteenSpuDao extends CanteenSpuDao { + _$CanteenSpuDao( + this.database, + this.changeListener, + ) : _queryAdapter = QueryAdapter(database), + _canteenSpuEntityInsertionAdapter = InsertionAdapter( + database, + 'canteen_spu', + (CanteenSpuEntity item) => { + 'id': item.id, + 'serviceUniqueCode': item.serviceUniqueCode, + 'classifyServiceUniqueCode': item.classifyServiceUniqueCode, + 'hourType': item.hourType?.index, + 'saleDate': _dateTimeConverter.encode(item.saleDate), + 'name': item.name, + 'price': item.price, + 'image': item.image, + 'isDefault': _boolConverter.encode(item.isDefault), + 'status': item.status, + 'sort': item.sort, + 'isPriceIncreased': + _boolConverter.encode(item.isPriceIncreased), + 'createTime': _dateTimeConverter.encode(item.createTime), + 'updateTime': _dateTimeConverter.encode(item.updateTime) + }), + _canteenSpuEntityUpdateAdapter = UpdateAdapter( + database, + 'canteen_spu', + ['id'], + (CanteenSpuEntity item) => { + 'id': item.id, + 'serviceUniqueCode': item.serviceUniqueCode, + 'classifyServiceUniqueCode': item.classifyServiceUniqueCode, + 'hourType': item.hourType?.index, + 'saleDate': _dateTimeConverter.encode(item.saleDate), + 'name': item.name, + 'price': item.price, + 'image': item.image, + 'isDefault': _boolConverter.encode(item.isDefault), + 'status': item.status, + 'sort': item.sort, + 'isPriceIncreased': + _boolConverter.encode(item.isPriceIncreased), + 'createTime': _dateTimeConverter.encode(item.createTime), + 'updateTime': _dateTimeConverter.encode(item.updateTime) + }), + _canteenSpuEntityDeletionAdapter = DeletionAdapter( + database, + 'canteen_spu', + ['id'], + (CanteenSpuEntity item) => { + 'id': item.id, + 'serviceUniqueCode': item.serviceUniqueCode, + 'classifyServiceUniqueCode': item.classifyServiceUniqueCode, + 'hourType': item.hourType?.index, + 'saleDate': _dateTimeConverter.encode(item.saleDate), + 'name': item.name, + 'price': item.price, + 'image': item.image, + 'isDefault': _boolConverter.encode(item.isDefault), + 'status': item.status, + 'sort': item.sort, + 'isPriceIncreased': + _boolConverter.encode(item.isPriceIncreased), + 'createTime': _dateTimeConverter.encode(item.createTime), + 'updateTime': _dateTimeConverter.encode(item.updateTime) + }); + + final sqflite.DatabaseExecutor database; + + final StreamController changeListener; + + final QueryAdapter _queryAdapter; + + final InsertionAdapter _canteenSpuEntityInsertionAdapter; + + final UpdateAdapter _canteenSpuEntityUpdateAdapter; + + final DeletionAdapter _canteenSpuEntityDeletionAdapter; + + @override + Future delAllByType() async { + await _queryAdapter.queryNoReturn('DELETE FROM canteen_spu'); + } + + @override + Future delAllByDay(String dayStr) async { + await _queryAdapter.queryNoReturn( + 'DELETE FROM canteen_spu WHERE strftime(\'%Y-%m-%d\', saleDate) = strftime(\'%Y-%m-%d\', ?1)', + arguments: [dayStr]); + } + + @override + Future editDefault( + bool isDefault, + String uniqueCode, + String hourType, + ) async { + await _queryAdapter.queryNoReturn( + 'UPDATE canteen_spu SET isDefault = ?1 WHERE serviceUniqueCode = ?2 AND hourType = ?3', + arguments: [_boolConverter.encode(isDefault), uniqueCode, hourType]); + } + + @override + Future getCount(String hourType) async { + return _queryAdapter.query( + 'SELECT DISTINCT COUNT(0) FROM canteen_spu WHERE (?1 = \'-1\' OR INSTR(\',\' || hourType || \',\', \',\' || ?1 || \',\') > 0) AND strftime(\'%Y-%m-%d\', saleDate) = strftime(\'%Y-%m-%d\', \'now\', \'localtime\')', + mapper: (Map row) => row.values.first as int, + arguments: [hourType]); + } + + @override + Future getByCode( + String code, + String saleDate, + ) async { + return _queryAdapter.query( + 'SELECT * FROM canteen_spu WHERE serviceUniqueCode = ?1 AND (?2 = \'-1\' OR strftime(\'%Y-%m-%d\', saleDate) = strftime(\'%Y-%m-%d\', ?2)) LIMIT 1', + mapper: (Map row) => CanteenSpuEntity(id: row['id'] as int?, serviceUniqueCode: row['serviceUniqueCode'] as String, classifyServiceUniqueCode: row['classifyServiceUniqueCode'] as String, hourType: row['hourType'] == null ? null : ProductHourType.values[row['hourType'] as int], saleDate: _dateTimeConverter.decode(row['saleDate'] as String), name: row['name'] as String, price: row['price'] as int, image: row['image'] as String?, isDefault: _boolConverter.decode(row['isDefault'] as int), status: row['status'] as int, sort: row['sort'] as int, isPriceIncreased: _boolConverter.decode(row['isPriceIncreased'] as int), createTime: _dateTimeConverter.decode(row['createTime'] as String), updateTime: _dateTimeConverter.decode(row['updateTime'] as String)), + arguments: [code, saleDate]); + } + + @override + Future> getAll( + String classifyServiceUniqueCode, + String hourType, + String saleDate, + ) async { + return _queryAdapter.queryList( + 'SELECT canteen_spu.* FROM canteen_spu LEFT JOIN classify ON canteen_spu.classifyServiceUniqueCode = classify.serviceUniqueCode AND classify.delFlag = 0 WHERE (?1 = \'-1\' OR canteen_spu.classifyServiceUniqueCode = ?1) AND (?2 = \'-1\' OR INSTR(\',\' || hourType || \',\', \',\' || ?2 || \',\') > 0) AND (?3 = \'-1\' OR strftime(\'%Y-%m-%d\', saleDate) = strftime(\'%Y-%m-%d\', ?3)) ORDER BY classify.sort,canteen_spu.sort desc,canteen_spu.price', + mapper: (Map row) => CanteenSpuEntity(id: row['id'] as int?, serviceUniqueCode: row['serviceUniqueCode'] as String, classifyServiceUniqueCode: row['classifyServiceUniqueCode'] as String, hourType: row['hourType'] == null ? null : ProductHourType.values[row['hourType'] as int], saleDate: _dateTimeConverter.decode(row['saleDate'] as String), name: row['name'] as String, price: row['price'] as int, image: row['image'] as String?, isDefault: _boolConverter.decode(row['isDefault'] as int), status: row['status'] as int, sort: row['sort'] as int, isPriceIncreased: _boolConverter.decode(row['isPriceIncreased'] as int), createTime: _dateTimeConverter.decode(row['createTime'] as String), updateTime: _dateTimeConverter.decode(row['updateTime'] as String)), + arguments: [classifyServiceUniqueCode, hourType, saleDate]); + } + + @override + Future> getAllPage( + String classifyServiceUniqueCode, + String hourType, + int pageSize, + int offset, + ) async { + return _queryAdapter.queryList( + 'SELECT canteen_spu.* FROM canteen_spu LEFT JOIN classify ON canteen_spu.classifyServiceUniqueCode = classify.serviceUniqueCode AND classify.delFlag = 0 WHERE (?1 = \'-1\' OR canteen_spu.classifyServiceUniqueCode = ?1) AND (?2 = \'-1\' OR INSTR(\',\' || canteen_spu.hourType || \',\', \',\' || ?2 || \',\') > 0) AND strftime(\'%Y-%m-%d\', saleDate) = strftime(\'%Y-%m-%d\', \'now\', \'localtime\') ORDER BY classify.sort, classify.id, canteen_spu.price LIMIT ?3 OFFSET ?4', + mapper: (Map row) => CanteenSpuEntity(id: row['id'] as int?, serviceUniqueCode: row['serviceUniqueCode'] as String, classifyServiceUniqueCode: row['classifyServiceUniqueCode'] as String, hourType: row['hourType'] == null ? null : ProductHourType.values[row['hourType'] as int], saleDate: _dateTimeConverter.decode(row['saleDate'] as String), name: row['name'] as String, price: row['price'] as int, image: row['image'] as String?, isDefault: _boolConverter.decode(row['isDefault'] as int), status: row['status'] as int, sort: row['sort'] as int, isPriceIncreased: _boolConverter.decode(row['isPriceIncreased'] as int), createTime: _dateTimeConverter.decode(row['createTime'] as String), updateTime: _dateTimeConverter.decode(row['updateTime'] as String)), + arguments: [classifyServiceUniqueCode, hourType, pageSize, offset]); + } + + @override + Future> getDefault(String hourType) async { + return _queryAdapter.queryList( + 'SELECT * FROM canteen_spu WHERE isDefault = 1 AND (?1 = \'-1\' OR hourType = ?1) AND strftime(\'%Y-%m-%d\', saleDate) = strftime(\'%Y-%m-%d\', \'now\', \'localtime\')', + mapper: (Map row) => CanteenSpuEntity(id: row['id'] as int?, serviceUniqueCode: row['serviceUniqueCode'] as String, classifyServiceUniqueCode: row['classifyServiceUniqueCode'] as String, hourType: row['hourType'] == null ? null : ProductHourType.values[row['hourType'] as int], saleDate: _dateTimeConverter.decode(row['saleDate'] as String), name: row['name'] as String, price: row['price'] as int, image: row['image'] as String?, isDefault: _boolConverter.decode(row['isDefault'] as int), status: row['status'] as int, sort: row['sort'] as int, isPriceIncreased: _boolConverter.decode(row['isPriceIncreased'] as int), createTime: _dateTimeConverter.decode(row['createTime'] as String), updateTime: _dateTimeConverter.decode(row['updateTime'] as String)), + arguments: [hourType]); + } + + @override + Future save(CanteenSpuEntity entity) async { + await _canteenSpuEntityInsertionAdapter.insert( + entity, OnConflictStrategy.abort); + } + + @override + Future batchSave(List entitys) async { + await _canteenSpuEntityInsertionAdapter.insertList( + entitys, OnConflictStrategy.abort); + } + + @override + Future batchEdit(List entitys) async { + await _canteenSpuEntityUpdateAdapter.updateList( + entitys, OnConflictStrategy.abort); + } + + @override + Future batchDel(List entitys) async { + await _canteenSpuEntityDeletionAdapter.deleteList(entitys); + } +} + +class _$SupermarketSkuDao extends SupermarketSkuDao { + _$SupermarketSkuDao( + this.database, + this.changeListener, + ) : _queryAdapter = QueryAdapter(database), + _supermarketSkuEntityInsertionAdapter = InsertionAdapter( + database, + 'supermarket_sku', + (SupermarketSkuEntity item) => { + 'id': item.id, + 'serviceUniqueCode': item.serviceUniqueCode, + 'spuId': item.spuId, + 'barCode': item.barCode, + 'spec': item.spec, + 'unit': item.unit, + 'packageNum': item.packageNum, + 'purchasePrice': item.purchasePrice, + 'retailPrice': item.retailPrice, + 'promotionPrice': item.promotionPrice + }); + + final sqflite.DatabaseExecutor database; + + final StreamController changeListener; + + final QueryAdapter _queryAdapter; + + final InsertionAdapter + _supermarketSkuEntityInsertionAdapter; + + @override + Future delAllForce() async { + await _queryAdapter.queryNoReturn('DELETE FROM supermarket_sku'); + } + + @override + Future getCount( + String barCode, + String classifyServiceUniqueCode, + ) async { + return _queryAdapter.query( + 'SELECT DISTINCT COUNT(0) FROM supermarket_sku INNER JOIN supermarket_spu ON supermarket_sku.spuId = supermarket_spu.serviceUniqueCode AND supermarket_spu.delFlag = 0 WHERE 1 = 1 AND (?1 = \'-1\' OR supermarket_sku.barCode LIKE \'%\' || ?1 || \'%\' OR supermarket_spu.productName LIKE \'%\' || ?1 || \'%\' OR (?1 != \'\' AND supermarket_spu.productNamePy LIKE \'%\' || ?1 || \'%\') ) AND (?2 = \'-1\' OR supermarket_spu.classifyServiceUniqueCode = ?2)', + mapper: (Map row) => row.values.first as int, + arguments: [barCode, classifyServiceUniqueCode]); + } + + @override + Future> getAllPage( + String barCode, + String classifyServiceUniqueCode, + int pageSize, + int offset, + ) async { + return _queryAdapter.queryList( + 'SELECT supermarket_sku.id, supermarket_sku.serviceUniqueCode, supermarket_sku.spuId, supermarket_spu.productCode, supermarket_spu.classifyServiceUniqueCode, supermarket_spu.productName, supermarket_spu.productNamePy, supermarket_spu.picUrl, supermarket_spu.productNature, supermarket_spu.salesCount, supermarket_spu.stock, supermarket_spu.sort, supermarket_spu.createTime, supermarket_spu.updateTime, supermarket_spu.delFlag, supermarket_sku.barCode, supermarket_sku.promotionPrice, supermarket_sku.purchasePrice, supermarket_sku.retailPrice, supermarket_sku.spec, supermarket_sku.packageNum, supermarket_sku.unit, supermarket_sku.packageNum FROM supermarket_sku INNER JOIN supermarket_spu ON supermarket_sku.spuId = supermarket_spu.serviceUniqueCode AND supermarket_spu.delFlag = 0 WHERE 1 = 1 AND (?1 = \'-1\' OR supermarket_sku.barCode LIKE \'%\' || ?1 || \'%\' OR supermarket_spu.productName LIKE \'%\' || ?1 || \'%\' OR (?1 != \'\' AND supermarket_spu.productNamePy LIKE \'%\' || ?1 || \'%\') ) AND (?2 = \'-1\' OR supermarket_spu.classifyServiceUniqueCode = ?2) ORDER BY supermarket_sku.spuId LIMIT ?3 OFFSET ?4', + mapper: (Map row) => SupermarketSpuEntity(id: row['id'] as int?, serviceUniqueCode: row['serviceUniqueCode'] as String, classifyServiceUniqueCode: row['classifyServiceUniqueCode'] as String, productCode: row['productCode'] as String, productName: row['productName'] as String, productNature: _supermarketNatureConverter.decode(row['productNature'] as String), barCode: row['barCode'] as String?, packageNum: row['packageNum'] as int, unit: row['unit'] as String, spec: row['spec'] as String, picUrl: row['picUrl'] as String?, stock: row['stock'] as double?, salesCount: row['salesCount'] as double?, purchasePrice: row['purchasePrice'] as int, retailPrice: row['retailPrice'] as int, promotionPrice: row['promotionPrice'] as int, sort: row['sort'] as int, createTime: _dateTimeConverter.decode(row['createTime'] as String), updateTime: _dateTimeConverter.decode(row['updateTime'] as String), delFlag: _boolConverter.decode(row['delFlag'] as int)), + arguments: [barCode, classifyServiceUniqueCode, pageSize, offset]); + } + + @override + Future getByBarCode(String barCode) async { + return _queryAdapter.query( + 'SELECT supermarket_sku.id, supermarket_sku.serviceUniqueCode, supermarket_sku.spuId, supermarket_spu.productCode, supermarket_spu.classifyServiceUniqueCode, supermarket_spu.productName, supermarket_spu.picUrl, supermarket_spu.productNature, supermarket_spu.salesCount, supermarket_spu.stock, supermarket_spu.sort, supermarket_spu.createTime, supermarket_spu.updateTime, supermarket_spu.delFlag, supermarket_sku.barCode, supermarket_sku.promotionPrice, supermarket_sku.purchasePrice, supermarket_sku.retailPrice, supermarket_sku.spec, supermarket_sku.packageNum, supermarket_sku.unit, supermarket_sku.packageNum FROM supermarket_sku INNER JOIN supermarket_spu ON supermarket_sku.spuId = supermarket_spu.serviceUniqueCode AND supermarket_spu.delFlag = 0 WHERE 1 = 1 AND (?1 = \'-1\' OR supermarket_sku.barCode = ?1) LIMIT 1', + mapper: (Map row) => SupermarketSpuEntity(id: row['id'] as int?, serviceUniqueCode: row['serviceUniqueCode'] as String, classifyServiceUniqueCode: row['classifyServiceUniqueCode'] as String, productCode: row['productCode'] as String, productName: row['productName'] as String, productNature: _supermarketNatureConverter.decode(row['productNature'] as String), barCode: row['barCode'] as String?, packageNum: row['packageNum'] as int, unit: row['unit'] as String, spec: row['spec'] as String, picUrl: row['picUrl'] as String?, stock: row['stock'] as double?, salesCount: row['salesCount'] as double?, purchasePrice: row['purchasePrice'] as int, retailPrice: row['retailPrice'] as int, promotionPrice: row['promotionPrice'] as int, sort: row['sort'] as int, createTime: _dateTimeConverter.decode(row['createTime'] as String), updateTime: _dateTimeConverter.decode(row['updateTime'] as String), delFlag: _boolConverter.decode(row['delFlag'] as int)), + arguments: [barCode]); + } + + @override + Future getBySkuCode(String skuCode) async { + return _queryAdapter.query( + 'SELECT supermarket_sku.id, supermarket_sku.serviceUniqueCode, supermarket_sku.spuId, supermarket_spu.productCode, supermarket_spu.classifyServiceUniqueCode, supermarket_spu.productName, supermarket_spu.picUrl, supermarket_spu.productNature, supermarket_spu.salesCount, supermarket_spu.stock, supermarket_spu.sort, supermarket_spu.createTime, supermarket_spu.updateTime, supermarket_spu.delFlag, supermarket_sku.barCode, supermarket_sku.promotionPrice, supermarket_sku.purchasePrice, supermarket_sku.retailPrice, supermarket_sku.spec, supermarket_sku.packageNum, supermarket_sku.unit, supermarket_sku.packageNum FROM supermarket_sku INNER JOIN supermarket_spu ON supermarket_sku.spuId = supermarket_spu.serviceUniqueCode AND supermarket_spu.delFlag = 0 WHERE 1 = 1 AND (?1 = \'-1\' OR supermarket_sku.serviceUniqueCode = ?1) LIMIT 1', + mapper: (Map row) => SupermarketSpuEntity(id: row['id'] as int?, serviceUniqueCode: row['serviceUniqueCode'] as String, classifyServiceUniqueCode: row['classifyServiceUniqueCode'] as String, productCode: row['productCode'] as String, productName: row['productName'] as String, productNature: _supermarketNatureConverter.decode(row['productNature'] as String), barCode: row['barCode'] as String?, packageNum: row['packageNum'] as int, unit: row['unit'] as String, spec: row['spec'] as String, picUrl: row['picUrl'] as String?, stock: row['stock'] as double?, salesCount: row['salesCount'] as double?, purchasePrice: row['purchasePrice'] as int, retailPrice: row['retailPrice'] as int, promotionPrice: row['promotionPrice'] as int, sort: row['sort'] as int, createTime: _dateTimeConverter.decode(row['createTime'] as String), updateTime: _dateTimeConverter.decode(row['updateTime'] as String), delFlag: _boolConverter.decode(row['delFlag'] as int)), + arguments: [skuCode]); + } + + @override + Future batchSave(List changeList) async { + await _supermarketSkuEntityInsertionAdapter.insertList( + changeList, OnConflictStrategy.abort); + } +} + +class _$SupermarketSpuDao extends SupermarketSpuDao { + _$SupermarketSpuDao( + this.database, + this.changeListener, + ) : _queryAdapter = QueryAdapter(database), + _supermarketSpuEntityInsertionAdapter = InsertionAdapter( + database, + 'supermarket_spu', + (SupermarketSpuEntity item) => { + 'id': item.id, + 'serviceUniqueCode': item.serviceUniqueCode, + 'classifyServiceUniqueCode': item.classifyServiceUniqueCode, + 'productCode': item.productCode, + 'productName': item.productName, + 'productNamePy': item.productNamePy, + 'productNature': + _supermarketNatureConverter.encode(item.productNature), + 'barCode': item.barCode, + 'packageNum': item.packageNum, + 'unit': item.unit, + 'spec': item.spec, + 'picUrl': item.picUrl, + 'stock': item.stock, + 'salesCount': item.salesCount, + 'purchasePrice': item.purchasePrice, + 'retailPrice': item.retailPrice, + 'promotionPrice': item.promotionPrice, + 'sort': item.sort, + 'createTime': _dateTimeConverter.encode(item.createTime), + 'updateTime': _dateTimeConverter.encode(item.updateTime), + 'delFlag': _boolConverter.encode(item.delFlag) + }), + _supermarketSpuEntityUpdateAdapter = UpdateAdapter( + database, + 'supermarket_spu', + ['id'], + (SupermarketSpuEntity item) => { + 'id': item.id, + 'serviceUniqueCode': item.serviceUniqueCode, + 'classifyServiceUniqueCode': item.classifyServiceUniqueCode, + 'productCode': item.productCode, + 'productName': item.productName, + 'productNamePy': item.productNamePy, + 'productNature': + _supermarketNatureConverter.encode(item.productNature), + 'barCode': item.barCode, + 'packageNum': item.packageNum, + 'unit': item.unit, + 'spec': item.spec, + 'picUrl': item.picUrl, + 'stock': item.stock, + 'salesCount': item.salesCount, + 'purchasePrice': item.purchasePrice, + 'retailPrice': item.retailPrice, + 'promotionPrice': item.promotionPrice, + 'sort': item.sort, + 'createTime': _dateTimeConverter.encode(item.createTime), + 'updateTime': _dateTimeConverter.encode(item.updateTime), + 'delFlag': _boolConverter.encode(item.delFlag) + }); + + final sqflite.DatabaseExecutor database; + + final StreamController changeListener; + + final QueryAdapter _queryAdapter; + + final InsertionAdapter + _supermarketSpuEntityInsertionAdapter; + + final UpdateAdapter _supermarketSpuEntityUpdateAdapter; + + @override + Future> getAll( + String classifyServiceUniqueCode) async { + return _queryAdapter.queryList( + 'SELECT supermarket_spu.* FROM supermarket_spu LEFT JOIN classify ON supermarket_spu.classifyServiceUniqueCode = classify.serviceUniqueCode AND classify.delFlag = 0 WHERE supermarket_spu.delFlag = 0 AND (?1 = \'-1\' OR supermarket_spu.classifyServiceUniqueCode = ?1) ORDER BY classify.sort', + mapper: (Map row) => SupermarketSpuEntity(id: row['id'] as int?, serviceUniqueCode: row['serviceUniqueCode'] as String, classifyServiceUniqueCode: row['classifyServiceUniqueCode'] as String, productCode: row['productCode'] as String, productName: row['productName'] as String, productNature: _supermarketNatureConverter.decode(row['productNature'] as String), barCode: row['barCode'] as String?, packageNum: row['packageNum'] as int, unit: row['unit'] as String, spec: row['spec'] as String, picUrl: row['picUrl'] as String?, stock: row['stock'] as double?, salesCount: row['salesCount'] as double?, purchasePrice: row['purchasePrice'] as int, retailPrice: row['retailPrice'] as int, promotionPrice: row['promotionPrice'] as int, sort: row['sort'] as int, createTime: _dateTimeConverter.decode(row['createTime'] as String), updateTime: _dateTimeConverter.decode(row['updateTime'] as String), delFlag: _boolConverter.decode(row['delFlag'] as int)), + arguments: [classifyServiceUniqueCode]); + } + + @override + Future getCount() async { + return _queryAdapter.query( + 'SELECT DISTINCT COUNT(0) FROM supermarket_spu WHERE delFlag = 0', + mapper: (Map row) => row.values.first as int); + } + + @override + Future> getAllPage( + String classifyServiceUniqueCode, + int pageSize, + int offset, + ) async { + return _queryAdapter.queryList( + 'SELECT supermarket_spu.* FROM supermarket_spu LEFT JOIN classify ON supermarket_spu.classifyServiceUniqueCode = classify.serviceUniqueCode AND classify.delFlag = 0 WHERE supermarket_spu.delFlag = 0 AND (?1 = \'-1\' OR supermarket_spu.classifyServiceUniqueCode = ?1) ORDER BY classify.sort LIMIT ?2 OFFSET ?3', + mapper: (Map row) => SupermarketSpuEntity(id: row['id'] as int?, serviceUniqueCode: row['serviceUniqueCode'] as String, classifyServiceUniqueCode: row['classifyServiceUniqueCode'] as String, productCode: row['productCode'] as String, productName: row['productName'] as String, productNature: _supermarketNatureConverter.decode(row['productNature'] as String), barCode: row['barCode'] as String?, packageNum: row['packageNum'] as int, unit: row['unit'] as String, spec: row['spec'] as String, picUrl: row['picUrl'] as String?, stock: row['stock'] as double?, salesCount: row['salesCount'] as double?, purchasePrice: row['purchasePrice'] as int, retailPrice: row['retailPrice'] as int, promotionPrice: row['promotionPrice'] as int, sort: row['sort'] as int, createTime: _dateTimeConverter.decode(row['createTime'] as String), updateTime: _dateTimeConverter.decode(row['updateTime'] as String), delFlag: _boolConverter.decode(row['delFlag'] as int)), + arguments: [classifyServiceUniqueCode, pageSize, offset]); + } + + @override + Future getByCode(String code) async { + return _queryAdapter.query( + 'SELECT * FROM supermarket_spu WHERE delFlag = 0 AND serviceUniqueCode = ?1', + mapper: (Map row) => SupermarketSpuEntity(id: row['id'] as int?, serviceUniqueCode: row['serviceUniqueCode'] as String, classifyServiceUniqueCode: row['classifyServiceUniqueCode'] as String, productCode: row['productCode'] as String, productName: row['productName'] as String, productNature: _supermarketNatureConverter.decode(row['productNature'] as String), barCode: row['barCode'] as String?, packageNum: row['packageNum'] as int, unit: row['unit'] as String, spec: row['spec'] as String, picUrl: row['picUrl'] as String?, stock: row['stock'] as double?, salesCount: row['salesCount'] as double?, purchasePrice: row['purchasePrice'] as int, retailPrice: row['retailPrice'] as int, promotionPrice: row['promotionPrice'] as int, sort: row['sort'] as int, createTime: _dateTimeConverter.decode(row['createTime'] as String), updateTime: _dateTimeConverter.decode(row['updateTime'] as String), delFlag: _boolConverter.decode(row['delFlag'] as int)), + arguments: [code]); + } + + @override + Future delAllForce() async { + await _queryAdapter.queryNoReturn('DELETE FROM supermarket_spu'); + } + + @override + Future batchSave(List changeList) async { + await _supermarketSpuEntityInsertionAdapter.insertList( + changeList, OnConflictStrategy.abort); + } + + @override + Future batchEdit(List changeList) async { + await _supermarketSpuEntityUpdateAdapter.updateList( + changeList, OnConflictStrategy.abort); + } +} + +class _$SupermarketCodeDao extends SupermarketCodeDao { + _$SupermarketCodeDao( + this.database, + this.changeListener, + ) : _queryAdapter = QueryAdapter(database), + _supermarketCodeEntityInsertionAdapter = InsertionAdapter( + database, + 'supermarket_code', + (SupermarketCodeEntity item) => { + 'id': item.id, + 'serviceUniqueCode': item.serviceUniqueCode, + 'spuId': item.spuId, + 'unitId': item.unitId, + 'barCode': item.barCode + }); + + final sqflite.DatabaseExecutor database; + + final StreamController changeListener; + + final QueryAdapter _queryAdapter; + + final InsertionAdapter + _supermarketCodeEntityInsertionAdapter; + + @override + Future delAllForce() async { + await _queryAdapter.queryNoReturn('DELETE FROM supermarket_code'); + } + + @override + Future getByBarCode(String barCode) async { + return _queryAdapter.query( + 'SELECT supermarket_sku.id, supermarket_sku.serviceUniqueCode, supermarket_sku.spuId, supermarket_spu.productCode, supermarket_spu.classifyServiceUniqueCode, supermarket_spu.productName, supermarket_spu.picUrl, supermarket_spu.productNature, supermarket_spu.salesCount, supermarket_spu.stock, supermarket_spu.sort, supermarket_spu.createTime, supermarket_spu.updateTime, supermarket_spu.delFlag, supermarket_sku.barCode, supermarket_sku.promotionPrice, supermarket_sku.purchasePrice, supermarket_sku.retailPrice, supermarket_sku.spec, supermarket_sku.packageNum, supermarket_sku.unit, supermarket_sku.packageNum FROM supermarket_code INNER JOIN supermarket_spu ON supermarket_code.spuId = supermarket_spu.serviceUniqueCode AND supermarket_spu.delFlag = 0 INNER JOIN supermarket_sku ON supermarket_spu.serviceUniqueCode = supermarket_sku.spuId WHERE 1 = 1 AND (?1 = \'-1\' OR supermarket_code.barCode = ?1) LIMIT 1', + mapper: (Map row) => SupermarketSpuEntity(id: row['id'] as int?, serviceUniqueCode: row['serviceUniqueCode'] as String, classifyServiceUniqueCode: row['classifyServiceUniqueCode'] as String, productCode: row['productCode'] as String, productName: row['productName'] as String, productNature: _supermarketNatureConverter.decode(row['productNature'] as String), barCode: row['barCode'] as String?, packageNum: row['packageNum'] as int, unit: row['unit'] as String, spec: row['spec'] as String, picUrl: row['picUrl'] as String?, stock: row['stock'] as double?, salesCount: row['salesCount'] as double?, purchasePrice: row['purchasePrice'] as int, retailPrice: row['retailPrice'] as int, promotionPrice: row['promotionPrice'] as int, sort: row['sort'] as int, createTime: _dateTimeConverter.decode(row['createTime'] as String), updateTime: _dateTimeConverter.decode(row['updateTime'] as String), delFlag: _boolConverter.decode(row['delFlag'] as int)), + arguments: [barCode]); + } + + @override + Future batchSave(List changeList) async { + await _supermarketCodeEntityInsertionAdapter.insertList( + changeList, OnConflictStrategy.abort); + } +} + +class _$ShopCardDao extends ShopCardDao { + _$ShopCardDao( + this.database, + this.changeListener, + ) : _queryAdapter = QueryAdapter(database), + _shopCardEntityInsertionAdapter = InsertionAdapter( + database, + 'shop_card', + (ShopCardEntity item) => { + 'id': item.id, + 'orderId': item.orderId, + 'productServiceUniqueCode': item.productServiceUniqueCode, + 'productName': item.productName, + 'productPic': item.productPic, + 'productPrice': item.productPrice, + 'nature': _supermarketNatureConverter.encode(item.nature), + 'productNumber': item.productNumber, + 'productWidget': item.productWidget, + 'totalPrice': item.totalPrice, + 'isPriceIncreased': + _boolConverter.encode(item.isPriceIncreased) + }), + _shopCardEntityUpdateAdapter = UpdateAdapter( + database, + 'shop_card', + ['id'], + (ShopCardEntity item) => { + 'id': item.id, + 'orderId': item.orderId, + 'productServiceUniqueCode': item.productServiceUniqueCode, + 'productName': item.productName, + 'productPic': item.productPic, + 'productPrice': item.productPrice, + 'nature': _supermarketNatureConverter.encode(item.nature), + 'productNumber': item.productNumber, + 'productWidget': item.productWidget, + 'totalPrice': item.totalPrice, + 'isPriceIncreased': + _boolConverter.encode(item.isPriceIncreased) + }), + _shopCardEntityDeletionAdapter = DeletionAdapter( + database, + 'shop_card', + ['id'], + (ShopCardEntity item) => { + 'id': item.id, + 'orderId': item.orderId, + 'productServiceUniqueCode': item.productServiceUniqueCode, + 'productName': item.productName, + 'productPic': item.productPic, + 'productPrice': item.productPrice, + 'nature': _supermarketNatureConverter.encode(item.nature), + 'productNumber': item.productNumber, + 'productWidget': item.productWidget, + 'totalPrice': item.totalPrice, + 'isPriceIncreased': + _boolConverter.encode(item.isPriceIncreased) + }); + + final sqflite.DatabaseExecutor database; + + final StreamController changeListener; + + final QueryAdapter _queryAdapter; + + final InsertionAdapter _shopCardEntityInsertionAdapter; + + final UpdateAdapter _shopCardEntityUpdateAdapter; + + final DeletionAdapter _shopCardEntityDeletionAdapter; + + @override + Future> findByOrderId(int orderId) async { + return _queryAdapter.queryList( + 'SELECT * FROM shop_card WHERE orderId = ?1', + mapper: (Map row) => ShopCardEntity( + id: row['id'] as int?, + orderId: row['orderId'] as int, + productServiceUniqueCode: row['productServiceUniqueCode'] as String, + productName: row['productName'] as String, + productPic: row['productPic'] as String, + productPrice: row['productPrice'] as int, + nature: _supermarketNatureConverter.decode(row['nature'] as String), + productNumber: row['productNumber'] as int, + productWidget: row['productWidget'] as double, + totalPrice: row['totalPrice'] as int, + isPriceIncreased: + _boolConverter.decode(row['isPriceIncreased'] as int)), + arguments: [orderId]); + } + + @override + Future save(ShopCardEntity entity) async { + await _shopCardEntityInsertionAdapter.insert( + entity, OnConflictStrategy.abort); + } + + @override + Future batchSave(List entitys) async { + await _shopCardEntityInsertionAdapter.insertList( + entitys, OnConflictStrategy.abort); + } + + @override + Future edit(ShopCardEntity entity) async { + await _shopCardEntityUpdateAdapter.update(entity, OnConflictStrategy.abort); + } + + @override + Future batchEdit(List entitys) async { + await _shopCardEntityUpdateAdapter.updateList( + entitys, OnConflictStrategy.abort); + } + + @override + Future del(ShopCardEntity entity) async { + await _shopCardEntityDeletionAdapter.delete(entity); + } + + @override + Future batchDel(List entitys) async { + await _shopCardEntityDeletionAdapter.deleteList(entitys); + } +} + +class _$ClassifyDao extends ClassifyDao { + _$ClassifyDao( + this.database, + this.changeListener, + ) : _queryAdapter = QueryAdapter(database), + _classifyEntityInsertionAdapter = InsertionAdapter( + database, + 'classify', + (ClassifyEntity item) => { + 'id': item.id, + 'serviceUniqueCode': item.serviceUniqueCode, + 'sysType': _sysTypeConverter.encode(item.sysType), + 'name': item.name, + 'categoryType': item.categoryType, + 'sort': item.sort, + 'createTime': _dateTimeConverter.encode(item.createTime), + 'updateTime': _dateTimeConverter.encode(item.updateTime), + 'delFlag': _boolConverter.encode(item.delFlag) + }), + _classifyEntityUpdateAdapter = UpdateAdapter( + database, + 'classify', + ['id'], + (ClassifyEntity item) => { + 'id': item.id, + 'serviceUniqueCode': item.serviceUniqueCode, + 'sysType': _sysTypeConverter.encode(item.sysType), + 'name': item.name, + 'categoryType': item.categoryType, + 'sort': item.sort, + 'createTime': _dateTimeConverter.encode(item.createTime), + 'updateTime': _dateTimeConverter.encode(item.updateTime), + 'delFlag': _boolConverter.encode(item.delFlag) + }); + + final sqflite.DatabaseExecutor database; + + final StreamController changeListener; + + final QueryAdapter _queryAdapter; + + final InsertionAdapter _classifyEntityInsertionAdapter; + + final UpdateAdapter _classifyEntityUpdateAdapter; + + @override + Future delAllForce() async { + await _queryAdapter.queryNoReturn('DELETE FROM classify'); + } + + @override + Future delBySysType(String sysType) async { + return _queryAdapter.query( + 'UPDATE classify SET delFlag = 1 WHERE delFlag = 0 AND (?1 = \'-1\' OR sysType = ?1)', + mapper: (Map row) => row.values.first as int, + arguments: [sysType]); + } + + @override + Future getCount() async { + return _queryAdapter.query( + 'SELECT DISTINCT COUNT(0) FROM classify WHERE delFlag = 0', + mapper: (Map row) => row.values.first as int); + } + + @override + Future getByCode(String code) async { + return _queryAdapter.query( + 'SELECT * FROM classify WHERE delFlag = 0 AND serviceUniqueCode = ?1', + mapper: (Map row) => ClassifyEntity( + id: row['id'] as int?, + serviceUniqueCode: row['serviceUniqueCode'] as String, + sysType: _sysTypeConverter.decode(row['sysType'] as String), + name: row['name'] as String, + categoryType: row['categoryType'] as String, + sort: row['sort'] as int, + createTime: _dateTimeConverter.decode(row['createTime'] as String), + updateTime: _dateTimeConverter.decode(row['updateTime'] as String), + delFlag: _boolConverter.decode(row['delFlag'] as int)), + arguments: [code]); + } + + @override + Future> getAll( + String sysType, + String categoryType, + ) async { + return _queryAdapter.queryList( + 'SELECT classify.* FROM classify WHERE delFlag = 0 AND (?1 = \'-1\' OR sysType = ?1) AND (?2 = \'-1\' OR INSTR(\',\' || categoryType || \',\', \',\' || ?2 || \',\') > 0) ORDER BY classify.sort', + mapper: (Map row) => ClassifyEntity(id: row['id'] as int?, serviceUniqueCode: row['serviceUniqueCode'] as String, sysType: _sysTypeConverter.decode(row['sysType'] as String), name: row['name'] as String, categoryType: row['categoryType'] as String, sort: row['sort'] as int, createTime: _dateTimeConverter.decode(row['createTime'] as String), updateTime: _dateTimeConverter.decode(row['updateTime'] as String), delFlag: _boolConverter.decode(row['delFlag'] as int)), + arguments: [sysType, categoryType]); + } + + @override + Future save(ClassifyEntity entity) async { + await _classifyEntityInsertionAdapter.insert( + entity, OnConflictStrategy.abort); + } + + @override + Future batchSave(List entitys) async { + await _classifyEntityInsertionAdapter.insertList( + entitys, OnConflictStrategy.abort); + } + + @override + Future edit(ClassifyEntity entity) async { + await _classifyEntityUpdateAdapter.update(entity, OnConflictStrategy.abort); + } + + @override + Future batchEdit(List entitys) async { + await _classifyEntityUpdateAdapter.updateList( + entitys, OnConflictStrategy.abort); + } +} + +class _$PrintHistoryDao extends PrintHistoryDao { + _$PrintHistoryDao( + this.database, + this.changeListener, + ) : _queryAdapter = QueryAdapter(database), + _printHistoryEntityInsertionAdapter = InsertionAdapter( + database, + 'print_history', + (PrintHistoryEntity item) => { + 'id': item.id, + 'orderId': item.orderId, + 'serviceOrderId': item.serviceOrderId, + 'servicePrintId': item.servicePrintId, + 'invoiceUrl': item.invoiceUrl, + 'status': _printStatusConverter.encode(item.status), + 'printTime': item.printTime, + 'syncTime': _dateTimeEmptyConverter.encode(item.syncTime), + 'createTime': _dateTimeConverter.encode(item.createTime) + }), + _printHistoryEntityUpdateAdapter = UpdateAdapter( + database, + 'print_history', + ['id'], + (PrintHistoryEntity item) => { + 'id': item.id, + 'orderId': item.orderId, + 'serviceOrderId': item.serviceOrderId, + 'servicePrintId': item.servicePrintId, + 'invoiceUrl': item.invoiceUrl, + 'status': _printStatusConverter.encode(item.status), + 'printTime': item.printTime, + 'syncTime': _dateTimeEmptyConverter.encode(item.syncTime), + 'createTime': _dateTimeConverter.encode(item.createTime) + }), + _printHistoryEntityDeletionAdapter = DeletionAdapter( + database, + 'print_history', + ['id'], + (PrintHistoryEntity item) => { + 'id': item.id, + 'orderId': item.orderId, + 'serviceOrderId': item.serviceOrderId, + 'servicePrintId': item.servicePrintId, + 'invoiceUrl': item.invoiceUrl, + 'status': _printStatusConverter.encode(item.status), + 'printTime': item.printTime, + 'syncTime': _dateTimeEmptyConverter.encode(item.syncTime), + 'createTime': _dateTimeConverter.encode(item.createTime) + }); + + final sqflite.DatabaseExecutor database; + + final StreamController changeListener; + + final QueryAdapter _queryAdapter; + + final InsertionAdapter + _printHistoryEntityInsertionAdapter; + + final UpdateAdapter _printHistoryEntityUpdateAdapter; + + final DeletionAdapter _printHistoryEntityDeletionAdapter; + + @override + Future delAll() async { + await _queryAdapter.queryNoReturn('DELETE FROM print_history'); + } + + @override + Future> getAll(String status) async { + return _queryAdapter.queryList( + 'SELECT * FROM print_history WHERE (?1 = \'-1\' OR status = ?1)', + mapper: (Map row) => PrintHistoryEntity(id: row['id'] as int?, orderId: row['orderId'] as String, serviceOrderId: row['serviceOrderId'] as String, servicePrintId: row['servicePrintId'] as String, invoiceUrl: row['invoiceUrl'] as String, status: _printStatusConverter.decode(row['status'] as String), printTime: row['printTime'] as String?, syncTime: _dateTimeEmptyConverter.decode(row['syncTime'] as String), createTime: _dateTimeConverter.decode(row['createTime'] as String)), + arguments: [status]); + } + + @override + Future save(PrintHistoryEntity entity) async { + await _printHistoryEntityInsertionAdapter.insert( + entity, OnConflictStrategy.abort); + } + + @override + Future batchSave(List entitys) async { + await _printHistoryEntityInsertionAdapter.insertList( + entitys, OnConflictStrategy.abort); + } + + @override + Future edit(PrintHistoryEntity entity) async { + await _printHistoryEntityUpdateAdapter.update( + entity, OnConflictStrategy.abort); + } + + @override + Future batchEdit(List entitys) async { + await _printHistoryEntityUpdateAdapter.updateList( + entitys, OnConflictStrategy.abort); + } + + @override + Future del(PrintHistoryEntity entity) async { + await _printHistoryEntityDeletionAdapter.delete(entity); + } + + @override + Future batchDel(List entitys) async { + await _printHistoryEntityDeletionAdapter.deleteList(entitys); + } +} + +class _$UserDao extends UserDao { + _$UserDao( + this.database, + this.changeListener, + ) : _queryAdapter = QueryAdapter(database), + _userEntityInsertionAdapter = InsertionAdapter( + database, + 'user_record', + (UserEntity item) => { + 'id': item.id, + 'userId': item.userId, + 'nickname': item.nickname, + 'mobile': item.mobile, + 'avatar': item.avatar, + 'walletCardNumber': item.walletCardNumber, + 'walletId': item.walletId, + 'walletTypeId': item.walletTypeId, + 'walletBalance': item.walletBalance, + 'canteenId': item.canteenId, + 'discountsList': + _listDiscountsConverter.encode(item.discountsList), + 'discountsUpdateTime': + _dateTimeEmptyConverter.encode(item.discountsUpdateTime), + 'createTime': _dateTimeEmptyConverter.encode(item.createTime), + 'updateTime': _dateTimeEmptyConverter.encode(item.updateTime) + }), + _userEntityUpdateAdapter = UpdateAdapter( + database, + 'user_record', + ['id'], + (UserEntity item) => { + 'id': item.id, + 'userId': item.userId, + 'nickname': item.nickname, + 'mobile': item.mobile, + 'avatar': item.avatar, + 'walletCardNumber': item.walletCardNumber, + 'walletId': item.walletId, + 'walletTypeId': item.walletTypeId, + 'walletBalance': item.walletBalance, + 'canteenId': item.canteenId, + 'discountsList': + _listDiscountsConverter.encode(item.discountsList), + 'discountsUpdateTime': + _dateTimeEmptyConverter.encode(item.discountsUpdateTime), + 'createTime': _dateTimeEmptyConverter.encode(item.createTime), + 'updateTime': _dateTimeEmptyConverter.encode(item.updateTime) + }), + _userEntityDeletionAdapter = DeletionAdapter( + database, + 'user_record', + ['id'], + (UserEntity item) => { + 'id': item.id, + 'userId': item.userId, + 'nickname': item.nickname, + 'mobile': item.mobile, + 'avatar': item.avatar, + 'walletCardNumber': item.walletCardNumber, + 'walletId': item.walletId, + 'walletTypeId': item.walletTypeId, + 'walletBalance': item.walletBalance, + 'canteenId': item.canteenId, + 'discountsList': + _listDiscountsConverter.encode(item.discountsList), + 'discountsUpdateTime': + _dateTimeEmptyConverter.encode(item.discountsUpdateTime), + 'createTime': _dateTimeEmptyConverter.encode(item.createTime), + 'updateTime': _dateTimeEmptyConverter.encode(item.updateTime) + }); + + final sqflite.DatabaseExecutor database; + + final StreamController changeListener; + + final QueryAdapter _queryAdapter; + + final InsertionAdapter _userEntityInsertionAdapter; + + final UpdateAdapter _userEntityUpdateAdapter; + + final DeletionAdapter _userEntityDeletionAdapter; + + @override + Future findByUserId(String userId) async { + return _queryAdapter.query( + 'SELECT * FROM user_record WHERE userId = ?1', + mapper: (Map row) => UserEntity( + id: row['id'] as int?, + userId: row['userId'] as String, + nickname: row['nickname'] as String, + mobile: row['mobile'] as String?, + avatar: row['avatar'] as String?, + walletCardNumber: row['walletCardNumber'] as String?, + walletId: row['walletId'] as String?, + walletTypeId: row['walletTypeId'] as String?, + walletBalance: row['walletBalance'] as int?, + canteenId: row['canteenId'] as String?, + createTime: + _dateTimeEmptyConverter.decode(row['createTime'] as String), + updateTime: + _dateTimeEmptyConverter.decode(row['updateTime'] as String), + discountsUpdateTime: _dateTimeEmptyConverter + .decode(row['discountsUpdateTime'] as String), + discountsList: + _listDiscountsConverter.decode(row['discountsList'] as String)), + arguments: [userId]); + } + + @override + Future findByCardNumber(String walletCardNumber) async { + return _queryAdapter.query( + 'SELECT * FROM user_record WHERE walletCardNumber = ?1', + mapper: (Map row) => UserEntity( + id: row['id'] as int?, + userId: row['userId'] as String, + nickname: row['nickname'] as String, + mobile: row['mobile'] as String?, + avatar: row['avatar'] as String?, + walletCardNumber: row['walletCardNumber'] as String?, + walletId: row['walletId'] as String?, + walletTypeId: row['walletTypeId'] as String?, + walletBalance: row['walletBalance'] as int?, + canteenId: row['canteenId'] as String?, + createTime: + _dateTimeEmptyConverter.decode(row['createTime'] as String), + updateTime: + _dateTimeEmptyConverter.decode(row['updateTime'] as String), + discountsUpdateTime: _dateTimeEmptyConverter + .decode(row['discountsUpdateTime'] as String), + discountsList: + _listDiscountsConverter.decode(row['discountsList'] as String)), + arguments: [walletCardNumber]); + } + + @override + Future save(UserEntity entity) async { + await _userEntityInsertionAdapter.insert(entity, OnConflictStrategy.abort); + } + + @override + Future batchSave(List entitys) async { + await _userEntityInsertionAdapter.insertList( + entitys, OnConflictStrategy.abort); + } + + @override + Future edit(UserEntity entity) async { + await _userEntityUpdateAdapter.update(entity, OnConflictStrategy.abort); + } + + @override + Future batchEdit(List entitys) async { + await _userEntityUpdateAdapter.updateList( + entitys, OnConflictStrategy.abort); + } + + @override + Future del(UserEntity entity) async { + await _userEntityDeletionAdapter.delete(entity); + } + + @override + Future batchDel(List entitys) async { + await _userEntityDeletionAdapter.deleteList(entitys); + } +} + +class _$FaceDao extends FaceDao { + _$FaceDao( + this.database, + this.changeListener, + ) : _queryAdapter = QueryAdapter(database), + _faceEntityInsertionAdapter = InsertionAdapter( + database, + 'face', + (FaceEntity item) => { + 'id': item.id, + 'userId': item.userId, + 'sampleId': item.sampleId, + 'userName': item.userName, + 'deptId': item.deptId, + 'deptName': item.deptName, + 'imagePath': item.imagePath, + 'path': item.path, + 'faceModel': _listIntConverter.encode(item.faceModel), + 'createTime': _dateTimeEmptyConverter.encode(item.createTime), + 'updateTime': _dateTimeEmptyConverter.encode(item.updateTime) + }), + _faceEntityUpdateAdapter = UpdateAdapter( + database, + 'face', + ['id'], + (FaceEntity item) => { + 'id': item.id, + 'userId': item.userId, + 'sampleId': item.sampleId, + 'userName': item.userName, + 'deptId': item.deptId, + 'deptName': item.deptName, + 'imagePath': item.imagePath, + 'path': item.path, + 'faceModel': _listIntConverter.encode(item.faceModel), + 'createTime': _dateTimeEmptyConverter.encode(item.createTime), + 'updateTime': _dateTimeEmptyConverter.encode(item.updateTime) + }), + _faceEntityDeletionAdapter = DeletionAdapter( + database, + 'face', + ['id'], + (FaceEntity item) => { + 'id': item.id, + 'userId': item.userId, + 'sampleId': item.sampleId, + 'userName': item.userName, + 'deptId': item.deptId, + 'deptName': item.deptName, + 'imagePath': item.imagePath, + 'path': item.path, + 'faceModel': _listIntConverter.encode(item.faceModel), + 'createTime': _dateTimeEmptyConverter.encode(item.createTime), + 'updateTime': _dateTimeEmptyConverter.encode(item.updateTime) + }); + + final sqflite.DatabaseExecutor database; + + final StreamController changeListener; + + final QueryAdapter _queryAdapter; + + final InsertionAdapter _faceEntityInsertionAdapter; + + final UpdateAdapter _faceEntityUpdateAdapter; + + final DeletionAdapter _faceEntityDeletionAdapter; + + @override + Future delAll() async { + await _queryAdapter.queryNoReturn('DELETE FROM face'); + } + + @override + Future delById(int id) async { + await _queryAdapter + .queryNoReturn('DELETE FROM face WHERE id = ?1', arguments: [id]); + } + + @override + Future delByUserId(String userId) async { + await _queryAdapter.queryNoReturn('DELETE FROM face WHERE userId = ?1', + arguments: [userId]); + } + + @override + Future delBySampleId(String sampleId) async { + await _queryAdapter.queryNoReturn('DELETE FROM face WHERE sampleId = ?1', + arguments: [sampleId]); + } + + @override + Future findCount(String userName) async { + return _queryAdapter.query( + 'SELECT DISTINCT COUNT(0) FROM face WHERE (?1 = \'-1\' OR userName LIKE \'%\' || ?1 || \'%\')', + mapper: (Map row) => row.values.first as int, + arguments: [userName]); + } + + @override + Future getById(int id) async { + return _queryAdapter.query('SELECT * FROM face WHERE id = ?1', + mapper: (Map row) => FaceEntity( + id: row['id'] as int?, + userId: row['userId'] as String, + sampleId: row['sampleId'] as String, + userName: row['userName'] as String, + deptId: row['deptId'] as String?, + deptName: row['deptName'] as String?, + imagePath: row['imagePath'] as String, + path: row['path'] as String?, + faceModel: _listIntConverter.decode(row['faceModel'] as String), + createTime: + _dateTimeEmptyConverter.decode(row['createTime'] as String), + updateTime: + _dateTimeEmptyConverter.decode(row['updateTime'] as String)), + arguments: [id]); + } + + @override + Future findBySampleId(String sampleId) async { + return _queryAdapter.query('SELECT * FROM face WHERE sampleId = ?1', + mapper: (Map row) => FaceEntity( + id: row['id'] as int?, + userId: row['userId'] as String, + sampleId: row['sampleId'] as String, + userName: row['userName'] as String, + deptId: row['deptId'] as String?, + deptName: row['deptName'] as String?, + imagePath: row['imagePath'] as String, + path: row['path'] as String?, + faceModel: _listIntConverter.decode(row['faceModel'] as String), + createTime: + _dateTimeEmptyConverter.decode(row['createTime'] as String), + updateTime: + _dateTimeEmptyConverter.decode(row['updateTime'] as String)), + arguments: [sampleId]); + } + + @override + Future?> findByUserId(String userId) async { + return _queryAdapter.queryList('SELECT * FROM face WHERE userId = ?1', + mapper: (Map row) => FaceEntity( + id: row['id'] as int?, + userId: row['userId'] as String, + sampleId: row['sampleId'] as String, + userName: row['userName'] as String, + deptId: row['deptId'] as String?, + deptName: row['deptName'] as String?, + imagePath: row['imagePath'] as String, + path: row['path'] as String?, + faceModel: _listIntConverter.decode(row['faceModel'] as String), + createTime: + _dateTimeEmptyConverter.decode(row['createTime'] as String), + updateTime: + _dateTimeEmptyConverter.decode(row['updateTime'] as String)), + arguments: [userId]); + } + + @override + Future> findAll( + String userName, + int pageSize, + int offset, + ) async { + return _queryAdapter.queryList( + 'SELECT * FROM face WHERE (?1 = \'-1\' OR userName LIKE \'%\' || ?1 || \'%\') LIMIT ?2 OFFSET ?3', + mapper: (Map row) => FaceEntity(id: row['id'] as int?, userId: row['userId'] as String, sampleId: row['sampleId'] as String, userName: row['userName'] as String, deptId: row['deptId'] as String?, deptName: row['deptName'] as String?, imagePath: row['imagePath'] as String, path: row['path'] as String?, faceModel: _listIntConverter.decode(row['faceModel'] as String), createTime: _dateTimeEmptyConverter.decode(row['createTime'] as String), updateTime: _dateTimeEmptyConverter.decode(row['updateTime'] as String)), + arguments: [userName, pageSize, offset]); + } + + @override + Future> findAllUserIds() async { + return _queryAdapter.queryList( + 'SELECT DISTINCT a.deptId FROM face a WHERE a.deptId IS NOT NULL', + mapper: (Map row) => row.values.first as String); + } + + @override + Future?> getEmptyFaceList() async { + return _queryAdapter.queryList( + 'SELECT * FROM face WHERE faceModel = \'\' OR faceModel IS NULL', + mapper: (Map row) => FaceEntity( + id: row['id'] as int?, + userId: row['userId'] as String, + sampleId: row['sampleId'] as String, + userName: row['userName'] as String, + deptId: row['deptId'] as String?, + deptName: row['deptName'] as String?, + imagePath: row['imagePath'] as String, + path: row['path'] as String?, + faceModel: _listIntConverter.decode(row['faceModel'] as String), + createTime: + _dateTimeEmptyConverter.decode(row['createTime'] as String), + updateTime: + _dateTimeEmptyConverter.decode(row['updateTime'] as String))); + } + + @override + Future save(FaceEntity entity) { + return _faceEntityInsertionAdapter.insertAndReturnId( + entity, OnConflictStrategy.abort); + } + + @override + Future batchSave(List entitys) async { + await _faceEntityInsertionAdapter.insertList( + entitys, OnConflictStrategy.abort); + } + + @override + Future edit(FaceEntity entity) async { + await _faceEntityUpdateAdapter.update(entity, OnConflictStrategy.abort); + } + + @override + Future batchEdit(List entitys) async { + await _faceEntityUpdateAdapter.updateList( + entitys, OnConflictStrategy.abort); + } + + @override + Future del(FaceEntity entity) async { + await _faceEntityDeletionAdapter.delete(entity); + } + + @override + Future batchDel(List entitys) async { + await _faceEntityDeletionAdapter.deleteList(entitys); + } +} + +class _$SettingDao extends SettingDao { + _$SettingDao( + this.database, + this.changeListener, + ) : _queryAdapter = QueryAdapter(database), + _settingEntityInsertionAdapter = InsertionAdapter( + database, + 'setting', + (SettingEntity item) => { + 'id': item.id, + 'userJson': item.userJson, + 'sysJson': item.sysJson, + 'canteenExtraPriceConfigJson': + item.canteenExtraPriceConfigJson + }), + _settingEntityUpdateAdapter = UpdateAdapter( + database, + 'setting', + ['id'], + (SettingEntity item) => { + 'id': item.id, + 'userJson': item.userJson, + 'sysJson': item.sysJson, + 'canteenExtraPriceConfigJson': + item.canteenExtraPriceConfigJson + }), + _settingEntityDeletionAdapter = DeletionAdapter( + database, + 'setting', + ['id'], + (SettingEntity item) => { + 'id': item.id, + 'userJson': item.userJson, + 'sysJson': item.sysJson, + 'canteenExtraPriceConfigJson': + item.canteenExtraPriceConfigJson + }); + + final sqflite.DatabaseExecutor database; + + final StreamController changeListener; + + final QueryAdapter _queryAdapter; + + final InsertionAdapter _settingEntityInsertionAdapter; + + final UpdateAdapter _settingEntityUpdateAdapter; + + final DeletionAdapter _settingEntityDeletionAdapter; + + @override + Future get() async { + return _queryAdapter.query('SELECT * FROM setting LIMIT 1', + mapper: (Map row) => SettingEntity( + id: row['id'] as int?, + userJson: row['userJson'] as String, + sysJson: row['sysJson'] as String, + canteenExtraPriceConfigJson: + row['canteenExtraPriceConfigJson'] as String?)); + } + + @override + Future save(SettingEntity entity) { + return _settingEntityInsertionAdapter.insertAndReturnId( + entity, OnConflictStrategy.abort); + } + + @override + Future edit(SettingEntity entity) async { + await _settingEntityUpdateAdapter.update(entity, OnConflictStrategy.abort); + } + + @override + Future del(SettingEntity entity) async { + await _settingEntityDeletionAdapter.delete(entity); + } +} + +// ignore_for_file: unused_element +final _dateTimeConverter = DateTimeConverter(); +final _dateTimeEmptyConverter = DateTimeEmptyConverter(); +final _orderStatusConverter = OrderStatusConverter(); +final _orderTypeConverter = OrderTypeConverter(); +final _orderPayTypeConverter = OrderPayTypeConverter(); +final _sysTypeConverter = SysTypeConverter(); +final _printStatusConverter = PrintStatusConverter(); +final _productHourTypeConverter = ProductHourTypeConverter(); +final _supermarketNatureConverter = SupermarketNatureConverter(); +final _discountsTypeConverter = DiscountsTypeConverter(); +final _listDiscountsConverter = ListDiscountsConverter(); +final _listIntConverter = ListIntConverter(); +final _boolConverter = BoolConverter(); diff --git a/lib/common/services/database/database_migration.dart b/lib/common/services/database/database_migration.dart new file mode 100644 index 0000000..c68bd69 --- /dev/null +++ b/lib/common/services/database/database_migration.dart @@ -0,0 +1,196 @@ +import 'package:floor/floor.dart'; + +abstract class DatabaseMigration { + /// 从版本1到版本2的迁移:添加productNamePy字段 + static Migration migration1To2 = Migration(1, 2, (database) async { + // 先检查列是否存在,不存在才添加 + final result = await database.rawQuery( + "PRAGMA table_info(supermarket_spu)", + ); + bool columnExists = false; + for (var column in result) { + if (column['name'] == 'productNamePy') { + columnExists = true; + break; + } + } + if (!columnExists) { + await database + .execute('ALTER TABLE supermarket_spu ADD COLUMN productNamePy TEXT'); + } + }); + + /// 从版本2到版本3的迁移:添加orderListJson字段 + static Migration migration2To3 = Migration(2, 3, (database) async { + // 如果表不存在则创建,存在则校验列并补齐缺失项 + final tables = await database.rawQuery( + "SELECT name FROM sqlite_master WHERE type='table' AND name='order_hold_bill'", + ); + if (tables.isEmpty) { + await database.execute(''' + CREATE TABLE IF NOT EXISTS order_hold_bill ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + sysType TEXT NOT NULL, + carListJson TEXT, + userJson TEXT, + price INTEGER DEFAULT 0 NOT NULL, + extraPrice INTEGER DEFAULT 0 NOT NULL, + payPrice INTEGER DEFAULT 0 NOT NULL, + createTime TEXT NOT NULL + ) + '''); + await database.execute( + "CREATE INDEX IF NOT EXISTS idx_order_hold_bill_sysType ON order_hold_bill(sysType)", + ); + await database.execute( + "CREATE INDEX IF NOT EXISTS idx_order_hold_bill_createTime ON order_hold_bill(createTime)", + ); + } else { + final columns = + await database.rawQuery("PRAGMA table_info(order_hold_bill)"); + bool hasSysType = false; + bool hasCarListJson = false; + bool hasOrderListJson = false; + bool hasUserJson = false; + bool hasPrice = false; + bool hasExtraPrice = false; + bool hasPayPrice = false; + bool hasCreateTime = false; + for (final c in columns) { + final name = c['name']; + if (name == 'sysType') hasSysType = true; + if (name == 'carListJson') hasCarListJson = true; + if (name == 'orderListJson') hasOrderListJson = true; + if (name == 'userJson') hasUserJson = true; + if (name == 'price') hasPrice = true; + if (name == 'extraPrice') hasExtraPrice = true; + if (name == 'payPrice') hasPayPrice = true; + if (name == 'createTime') hasCreateTime = true; + } + if (!hasSysType) { + await database.execute( + 'ALTER TABLE order_hold_bill ADD COLUMN sysType TEXT', + ); + } + if (!hasCarListJson) { + await database.execute( + 'ALTER TABLE order_hold_bill ADD COLUMN carListJson TEXT', + ); + if (hasOrderListJson) { + // 旧列迁移到新列 + await database.execute( + "UPDATE order_hold_bill SET carListJson = orderListJson WHERE carListJson IS NULL", + ); + } + } + if (!hasUserJson) { + await database.execute( + 'ALTER TABLE order_hold_bill ADD COLUMN userJson TEXT', + ); + } + if (!hasPrice) { + await database.execute( + 'ALTER TABLE order_hold_bill ADD COLUMN price INTEGER DEFAULT 0 NOT NULL', + ); + } + if (!hasExtraPrice) { + await database.execute( + 'ALTER TABLE order_hold_bill ADD COLUMN extraPrice INTEGER DEFAULT 0 NOT NULL', + ); + } + if (!hasPayPrice) { + await database.execute( + 'ALTER TABLE order_hold_bill ADD COLUMN payPrice INTEGER DEFAULT 0 NOT NULL', + ); + } + if (!hasCreateTime) { + await database.execute( + 'ALTER TABLE order_hold_bill ADD COLUMN createTime TEXT DEFAULT "" NOT NULL', + ); + } + await database.execute( + "CREATE INDEX IF NOT EXISTS idx_order_hold_bill_sysType ON order_hold_bill(sysType)", + ); + await database.execute( + "CREATE INDEX IF NOT EXISTS idx_order_hold_bill_createTime ON order_hold_bill(createTime)", + ); + } + }); + + /// 从版本3到版本4的迁移:添加为user_record表添加discountsList字段 + static Migration migration3To4 = Migration(3, 4, (database) async { + final columns = await database.rawQuery("PRAGMA table_info(user_record)"); + bool hasDiscountsList = false; + for (final c in columns) { + if (c['name'] == 'discountsList') { + hasDiscountsList = true; + break; + } + } + if (!hasDiscountsList) { + await database + .execute("ALTER TABLE user_record ADD COLUMN discountsList TEXT"); + await database.execute( + "UPDATE user_record SET discountsList = '[]' WHERE discountsList IS NULL", + ); + } + }); + + static Migration migration4To5 = Migration(4, 5, (database) async { + final columns = await database.rawQuery("PRAGMA table_info(user_record)"); + bool hasDiscountsUpdateTime = false; + for (final c in columns) { + if (c['name'] == 'discountsUpdateTime') { + hasDiscountsUpdateTime = true; + break; + } + } + if (!hasDiscountsUpdateTime) { + await database.execute( + "ALTER TABLE user_record ADD COLUMN discountsUpdateTime TEXT"); + } + }); + + static Migration migration5To6 = Migration(5, 6, (database) async { + final columns = await database.rawQuery("PRAGMA table_info(canteen_spu)"); + bool hasIsPriceIncreased = false; + for (final c in columns) { + if (c['name'] == 'isPriceIncreased') { + hasIsPriceIncreased = true; + break; + } + } + if (!hasIsPriceIncreased) { + await database.execute( + "ALTER TABLE canteen_spu ADD COLUMN isPriceIncreased INTEGER NOT NULL DEFAULT 0", + ); + } + }); + + static Migration migration6To7 = Migration(6, 7, (database) async { + final columns = await database.rawQuery("PRAGMA table_info(shop_card)"); + bool hasIsPriceIncreased = false; + for (final c in columns) { + if (c['name'] == 'isPriceIncreased') { + hasIsPriceIncreased = true; + break; + } + } + if (!hasIsPriceIncreased) { + await database.execute( + "ALTER TABLE shop_card ADD COLUMN isPriceIncreased INTEGER NOT NULL DEFAULT 0", + ); + } + }); + + static List getDatabaseMigrations() { + return [ + migration1To2, + migration2To3, + migration3To4, + migration4To5, + migration5To6, + migration6To7, + ]; + } +} diff --git a/lib/common/services/database/entity/broadcast_entity.dart b/lib/common/services/database/entity/broadcast_entity.dart new file mode 100644 index 0000000..a3939a1 --- /dev/null +++ b/lib/common/services/database/entity/broadcast_entity.dart @@ -0,0 +1,85 @@ +import 'package:floor/floor.dart'; +import 'package:json_annotation/json_annotation.dart'; +import 'package:pj_supermarket_cloud/common/index.dart'; + +part 'broadcast_entity.g.dart'; + +@JsonSerializable() +@Entity(tableName: "broadcast") +class BroadcastEntity { + /// 自增ID + @PrimaryKey(autoGenerate: true) + int? id; + + /// 广播内容 + String content; + + /// 当前广播次数 + int broadcastCount; + + /// 最大广播次数 + int broadcastMaxCount; + + /// 是否广播语音 + bool isBroadcast; + + /// 是否完成 + bool isDone; + + /// 完成时间 + DateTime? isDoneTime; + + /// 服务单号 + String serviceCode; + + /// 服务订单号 + String serviceOrderCode; + + /// 用户id + String userId; + + /// 用户姓名 + String username; + + /// 用户手机号 + String userMobile; + + /// 商品介绍 + String productDesc; + + /// 支付价格 + int payPrice; + + /// 菜品时间类型 + ProductHourType? hourType; + + /// 手动开关 + bool enable; + + /// 创建时间 + DateTime createTime; + + BroadcastEntity({ + this.id, + required this.content, + required this.broadcastCount, + required this.broadcastMaxCount, + required this.isBroadcast, + required this.isDone, + required this.createTime, + required this.serviceCode, + required this.serviceOrderCode, + required this.userId, + required this.username, + required this.userMobile, + required this.productDesc, + required this.payPrice, + this.hourType, + required this.enable, + }); + + factory BroadcastEntity.fromJson(Map json) => + _$BroadcastEntityFromJson(json); + + Map toJson() => _$BroadcastEntityToJson(this); +} diff --git a/lib/common/services/database/entity/broadcast_entity.g.dart b/lib/common/services/database/entity/broadcast_entity.g.dart new file mode 100644 index 0000000..c8dc359 --- /dev/null +++ b/lib/common/services/database/entity/broadcast_entity.g.dart @@ -0,0 +1,57 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'broadcast_entity.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +BroadcastEntity _$BroadcastEntityFromJson(Map json) => + BroadcastEntity( + id: (json['id'] as num?)?.toInt(), + content: json['content'] as String, + broadcastCount: (json['broadcastCount'] as num).toInt(), + broadcastMaxCount: (json['broadcastMaxCount'] as num).toInt(), + isBroadcast: json['isBroadcast'] as bool, + isDone: json['isDone'] as bool, + createTime: DateTime.parse(json['createTime'] as String), + serviceCode: json['serviceCode'] as String, + serviceOrderCode: json['serviceOrderCode'] as String, + userId: json['userId'] as String, + username: json['username'] as String, + userMobile: json['userMobile'] as String, + productDesc: json['productDesc'] as String, + payPrice: (json['payPrice'] as num).toInt(), + hourType: $enumDecodeNullable(_$ProductHourTypeEnumMap, json['hourType']), + enable: json['enable'] as bool, + )..isDoneTime = json['isDoneTime'] == null + ? null + : DateTime.parse(json['isDoneTime'] as String); + +Map _$BroadcastEntityToJson(BroadcastEntity instance) => + { + 'id': instance.id, + 'content': instance.content, + 'broadcastCount': instance.broadcastCount, + 'broadcastMaxCount': instance.broadcastMaxCount, + 'isBroadcast': instance.isBroadcast, + 'isDone': instance.isDone, + 'isDoneTime': instance.isDoneTime?.toIso8601String(), + 'serviceCode': instance.serviceCode, + 'serviceOrderCode': instance.serviceOrderCode, + 'userId': instance.userId, + 'username': instance.username, + 'userMobile': instance.userMobile, + 'productDesc': instance.productDesc, + 'payPrice': instance.payPrice, + 'hourType': _$ProductHourTypeEnumMap[instance.hourType], + 'enable': instance.enable, + 'createTime': instance.createTime.toIso8601String(), + }; + +const _$ProductHourTypeEnumMap = { + ProductHourType.TOTAL: 'TOTAL', + ProductHourType.MORNING: 'MORNING', + ProductHourType.NOON: 'NOON', + ProductHourType.NIGHT: 'NIGHT', +}; diff --git a/lib/common/services/database/entity/canteen_spu_entity.dart b/lib/common/services/database/entity/canteen_spu_entity.dart new file mode 100644 index 0000000..13c4731 --- /dev/null +++ b/lib/common/services/database/entity/canteen_spu_entity.dart @@ -0,0 +1,88 @@ +import 'package:floor/floor.dart'; +import 'package:equatable/equatable.dart'; +import 'package:json_annotation/json_annotation.dart'; + +import 'package:pj_supermarket_cloud/common/models/index.dart'; + +part 'canteen_spu_entity.g.dart'; + +@JsonSerializable() +@Entity(tableName: "canteen_spu") +class CanteenSpuEntity extends Equatable { + /// 自增ID + @PrimaryKey(autoGenerate: true) + int? id; + + /// 服务端唯一标识 + String serviceUniqueCode; + + /// 分类 serviceID + String classifyServiceUniqueCode; + + /// 菜品时间类型 + ProductHourType? hourType; + + /// 菜品上架日期 + DateTime saleDate; + + /// 商品名 + String name; + + /// 商品价格(分) + int price; + + /// 商品图片 + String? image; + + /// 是否为默认 + bool isDefault; + + /// 商品状态 + int status; + + /// 排序 倒序排列 + int sort; + + /// 是否涨价 + bool isPriceIncreased; + + /// 创建时间 + DateTime createTime; + + /// 同步时间 + DateTime updateTime; + + CanteenSpuEntity({ + this.id, + required this.serviceUniqueCode, + required this.classifyServiceUniqueCode, + this.hourType, + required this.saleDate, + required this.name, + required this.price, + this.image, + required this.isDefault, + required this.status, + required this.sort, + required this.isPriceIncreased, + required this.createTime, + required this.updateTime, + }); + + factory CanteenSpuEntity.fromJson(Map json) => + _$CanteenSpuEntityFromJson(json); + + Map toJson() => _$CanteenSpuEntityToJson(this); + + @override + List get props => [ + serviceUniqueCode, + classifyServiceUniqueCode, + hourType, + name, + price, + image, + status, + sort, + ]; +} diff --git a/lib/common/services/database/entity/canteen_spu_entity.g.dart b/lib/common/services/database/entity/canteen_spu_entity.g.dart new file mode 100644 index 0000000..a244d5b --- /dev/null +++ b/lib/common/services/database/entity/canteen_spu_entity.g.dart @@ -0,0 +1,50 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'canteen_spu_entity.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +CanteenSpuEntity _$CanteenSpuEntityFromJson(Map json) => + CanteenSpuEntity( + id: (json['id'] as num?)?.toInt(), + serviceUniqueCode: json['serviceUniqueCode'] as String, + classifyServiceUniqueCode: json['classifyServiceUniqueCode'] as String, + hourType: $enumDecodeNullable(_$ProductHourTypeEnumMap, json['hourType']), + saleDate: DateTime.parse(json['saleDate'] as String), + name: json['name'] as String, + price: (json['price'] as num).toInt(), + image: json['image'] as String?, + isDefault: json['isDefault'] as bool, + status: (json['status'] as num).toInt(), + sort: (json['sort'] as num).toInt(), + isPriceIncreased: json['isPriceIncreased'] as bool, + createTime: DateTime.parse(json['createTime'] as String), + updateTime: DateTime.parse(json['updateTime'] as String), + ); + +Map _$CanteenSpuEntityToJson(CanteenSpuEntity instance) => + { + 'id': instance.id, + 'serviceUniqueCode': instance.serviceUniqueCode, + 'classifyServiceUniqueCode': instance.classifyServiceUniqueCode, + 'hourType': _$ProductHourTypeEnumMap[instance.hourType], + 'saleDate': instance.saleDate.toIso8601String(), + 'name': instance.name, + 'price': instance.price, + 'image': instance.image, + 'isDefault': instance.isDefault, + 'status': instance.status, + 'sort': instance.sort, + 'isPriceIncreased': instance.isPriceIncreased, + 'createTime': instance.createTime.toIso8601String(), + 'updateTime': instance.updateTime.toIso8601String(), + }; + +const _$ProductHourTypeEnumMap = { + ProductHourType.TOTAL: 'TOTAL', + ProductHourType.MORNING: 'MORNING', + ProductHourType.NOON: 'NOON', + ProductHourType.NIGHT: 'NIGHT', +}; diff --git a/lib/common/services/database/entity/classify_entity.dart b/lib/common/services/database/entity/classify_entity.dart new file mode 100644 index 0000000..02bc605 --- /dev/null +++ b/lib/common/services/database/entity/classify_entity.dart @@ -0,0 +1,65 @@ +import 'package:equatable/equatable.dart'; +import 'package:floor/floor.dart'; +import 'package:json_annotation/json_annotation.dart'; + +import 'package:pj_supermarket_cloud/common/models/index.dart'; + +part 'classify_entity.g.dart'; + +@JsonSerializable() +@Entity(tableName: "classify") +class ClassifyEntity extends Equatable { + /// 自增ID + @PrimaryKey(autoGenerate: true) + int? id; + + /// 服务端唯一标识 + final String serviceUniqueCode; + + /// 平台类型 + final SysType sysType; + + /// 分类名 + final String name; + + /// 用餐类型,早餐、中餐、晚餐 + final String categoryType; + + /// 排序 倒序排列 + final int sort; + + /// 创建时间 + final DateTime createTime; + + /// 同步时间 + final DateTime updateTime; + + /// 是否删除 + final bool delFlag; + + ClassifyEntity({ + this.id, + required this.serviceUniqueCode, + required this.sysType, + required this.name, + required this.categoryType, + required this.sort, + required this.createTime, + required this.updateTime, + required this.delFlag, + }); + + factory ClassifyEntity.fromJson(Map json) => + _$ClassifyEntityFromJson(json); + + Map toJson() => _$ClassifyEntityToJson(this); + + @override + List get props => [ + serviceUniqueCode, + sysType, + name, + categoryType, + sort, + ]; +} diff --git a/lib/common/services/database/entity/classify_entity.g.dart b/lib/common/services/database/entity/classify_entity.g.dart new file mode 100644 index 0000000..b9fb695 --- /dev/null +++ b/lib/common/services/database/entity/classify_entity.g.dart @@ -0,0 +1,38 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'classify_entity.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +ClassifyEntity _$ClassifyEntityFromJson(Map json) => + ClassifyEntity( + id: (json['id'] as num?)?.toInt(), + serviceUniqueCode: json['serviceUniqueCode'] as String, + sysType: $enumDecode(_$SysTypeEnumMap, json['sysType']), + name: json['name'] as String, + categoryType: json['categoryType'] as String, + sort: (json['sort'] as num).toInt(), + createTime: DateTime.parse(json['createTime'] as String), + updateTime: DateTime.parse(json['updateTime'] as String), + delFlag: json['delFlag'] as bool, + ); + +Map _$ClassifyEntityToJson(ClassifyEntity instance) => + { + 'id': instance.id, + 'serviceUniqueCode': instance.serviceUniqueCode, + 'sysType': _$SysTypeEnumMap[instance.sysType]!, + 'name': instance.name, + 'categoryType': instance.categoryType, + 'sort': instance.sort, + 'createTime': instance.createTime.toIso8601String(), + 'updateTime': instance.updateTime.toIso8601String(), + 'delFlag': instance.delFlag, + }; + +const _$SysTypeEnumMap = { + SysType.SUPERMARKET: 'SUPERMARKET', + SysType.CANTEEN: 'CANTEEN', +}; diff --git a/lib/common/services/database/entity/face_entity.dart b/lib/common/services/database/entity/face_entity.dart new file mode 100644 index 0000000..0cdfaa0 --- /dev/null +++ b/lib/common/services/database/entity/face_entity.dart @@ -0,0 +1,65 @@ +import 'package:floor/floor.dart'; +import 'package:equatable/equatable.dart'; +import 'package:json_annotation/json_annotation.dart'; + +part 'face_entity.g.dart'; + +@JsonSerializable() +@Entity(tableName: "face") +class FaceEntity extends Equatable { + /// 自增ID + @PrimaryKey(autoGenerate: true) + int? id; + + /// 用户id + String userId; + + /// 样本id + String sampleId; + + /// 用户名 + String userName; + + /// 部门id + String? deptId; + + /// 部门名 + String? deptName; + + /// 图片路径 + String imagePath; + + /// 本地位置 + String? path; + + /// 人脸模型数据 + List? faceModel; + + /// 创建时间 + DateTime? createTime; + + /// 同步时间 + DateTime? updateTime; + + FaceEntity({ + this.id, + required this.userId, + required this.sampleId, + required this.userName, + this.deptId, + this.deptName, + required this.imagePath, + this.path, + this.faceModel, + this.createTime, + this.updateTime, + }); + + factory FaceEntity.fromJson(Map json) => + _$FaceEntityFromJson(json); + + Map toJson() => _$FaceEntityToJson(this); + + @override + List get props => [userId, sampleId, userName, imagePath]; +} diff --git a/lib/common/services/database/entity/face_entity.g.dart b/lib/common/services/database/entity/face_entity.g.dart new file mode 100644 index 0000000..b9816fa --- /dev/null +++ b/lib/common/services/database/entity/face_entity.g.dart @@ -0,0 +1,40 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'face_entity.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +FaceEntity _$FaceEntityFromJson(Map json) => FaceEntity( + id: (json['id'] as num?)?.toInt(), + userId: json['userId'] as String, + sampleId: json['sampleId'] as String, + userName: json['userName'] as String, + deptId: json['deptId'] as String?, + deptName: json['deptName'] as String?, + imagePath: json['imagePath'] as String, + path: json['path'] as String?, + faceModel: json['faceModel'] as List?, + createTime: json['createTime'] == null + ? null + : DateTime.parse(json['createTime'] as String), + updateTime: json['updateTime'] == null + ? null + : DateTime.parse(json['updateTime'] as String), + ); + +Map _$FaceEntityToJson(FaceEntity instance) => + { + 'id': instance.id, + 'userId': instance.userId, + 'sampleId': instance.sampleId, + 'userName': instance.userName, + 'deptId': instance.deptId, + 'deptName': instance.deptName, + 'imagePath': instance.imagePath, + 'path': instance.path, + 'faceModel': instance.faceModel, + 'createTime': instance.createTime?.toIso8601String(), + 'updateTime': instance.updateTime?.toIso8601String(), + }; diff --git a/lib/common/services/database/entity/order_entity.dart b/lib/common/services/database/entity/order_entity.dart new file mode 100644 index 0000000..9ec762c --- /dev/null +++ b/lib/common/services/database/entity/order_entity.dart @@ -0,0 +1,99 @@ +import 'package:floor/floor.dart'; +import 'package:json_annotation/json_annotation.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +part 'order_entity.g.dart'; + +@JsonSerializable() +@Entity(tableName: "order_record") +class OrderEntity { + /// 自增ID + @PrimaryKey(autoGenerate: true) + int? id; + + /// 唯一code + String uniqueCode; + + /// 服务端唯一标识 + String serviceUniqueCode; + + /// 平台类型 + SysType sysType; + + /// 用户唯一标识 + String userCode; + + /// 用户名 + String? username; + + /// 订单用餐类型:早、中、晚 + String? hourType; + + /// 购物车的总金额 + int price; + + /// 购物车额外金额 + int extraPrice; + + /// 结算总金额 + int payPrice; + + /// 钱包id + String? walletId; + + /// 订单类型渠道id + String? orderSourceTypeId; + + /// 订单类型 + OrderType type; + + /// 订单状态 + OrderStatus status; + + /// 支付方式 + OrderPayType payType; + + /// 商品描述 + String? productDesc; + + /// 商品总数量 食堂特有 + int count; + + /// 重量 超市特有 + double weight; + + /// 支付时间 + DateTime? payTime; + + /// 创建时间 + DateTime createTime; + + OrderEntity({ + this.id, + required this.uniqueCode, + required this.serviceUniqueCode, + required this.sysType, + required this.userCode, + this.username, + this.hourType, + required this.price, + required this.extraPrice, + required this.payPrice, + this.walletId, + this.orderSourceTypeId, + required this.type, + required this.status, + required this.payType, + this.productDesc, + required this.count, + required this.weight, + this.payTime, + required this.createTime, + }); + + factory OrderEntity.fromJson(Map json) => + _$OrderEntityFromJson(json); + + Map toJson() => _$OrderEntityToJson(this); +} diff --git a/lib/common/services/database/entity/order_entity.g.dart b/lib/common/services/database/entity/order_entity.g.dart new file mode 100644 index 0000000..0b7af90 --- /dev/null +++ b/lib/common/services/database/entity/order_entity.g.dart @@ -0,0 +1,97 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'order_entity.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +OrderEntity _$OrderEntityFromJson(Map json) => OrderEntity( + id: (json['id'] as num?)?.toInt(), + uniqueCode: json['uniqueCode'] as String, + serviceUniqueCode: json['serviceUniqueCode'] as String, + sysType: $enumDecode(_$SysTypeEnumMap, json['sysType']), + userCode: json['userCode'] as String, + username: json['username'] as String?, + hourType: json['hourType'] as String?, + price: (json['price'] as num).toInt(), + extraPrice: (json['extraPrice'] as num).toInt(), + payPrice: (json['payPrice'] as num).toInt(), + walletId: json['walletId'] as String?, + orderSourceTypeId: json['orderSourceTypeId'] as String?, + type: $enumDecode(_$OrderTypeEnumMap, json['type']), + status: $enumDecode(_$OrderStatusEnumMap, json['status']), + payType: $enumDecode(_$OrderPayTypeEnumMap, json['payType']), + productDesc: json['productDesc'] as String?, + count: (json['count'] as num).toInt(), + weight: (json['weight'] as num).toDouble(), + payTime: json['payTime'] == null + ? null + : DateTime.parse(json['payTime'] as String), + createTime: DateTime.parse(json['createTime'] as String), + ); + +Map _$OrderEntityToJson(OrderEntity instance) => + { + 'id': instance.id, + 'uniqueCode': instance.uniqueCode, + 'serviceUniqueCode': instance.serviceUniqueCode, + 'sysType': _$SysTypeEnumMap[instance.sysType]!, + 'userCode': instance.userCode, + 'username': instance.username, + 'hourType': instance.hourType, + 'price': instance.price, + 'extraPrice': instance.extraPrice, + 'payPrice': instance.payPrice, + 'walletId': instance.walletId, + 'orderSourceTypeId': instance.orderSourceTypeId, + 'type': _$OrderTypeEnumMap[instance.type]!, + 'status': _$OrderStatusEnumMap[instance.status]!, + 'payType': _$OrderPayTypeEnumMap[instance.payType]!, + 'productDesc': instance.productDesc, + 'count': instance.count, + 'weight': instance.weight, + 'payTime': instance.payTime?.toIso8601String(), + 'createTime': instance.createTime.toIso8601String(), + }; + +const _$SysTypeEnumMap = { + SysType.SUPERMARKET: 'SUPERMARKET', + SysType.CANTEEN: 'CANTEEN', +}; + +const _$OrderTypeEnumMap = { + OrderType.BELOW: 'BELOW', + OrderType.RESERVE: 'RESERVE', + OrderType.FOREIGN_TICKET: 'FOREIGN_TICKET', + OrderType.STRIDE_DEPT: 'STRIDE_DEPT', + OrderType.ZY: 'ZY', +}; + +const _$OrderStatusEnumMap = { + OrderStatus.TOTAL: 'TOTAL', + OrderStatus.CREATE: 'CREATE', + OrderStatus.PAY: 'PAY', + OrderStatus.OFFLINE_CREATE: 'OFFLINE_CREATE', + OrderStatus.OFFLINE_PAY: 'OFFLINE_PAY', + OrderStatus.REFUND: 'REFUND', + OrderStatus.CANCEL: 'CANCEL', +}; + +const _$OrderPayTypeEnumMap = { + OrderPayType.WALLET_DEFAULT: 'WALLET_DEFAULT', + OrderPayType.WALLET_QR_CODE: 'WALLET_QR_CODE', + OrderPayType.WALLET_QR_CODE_SCAN: 'WALLET_QR_CODE_SCAN', + OrderPayType.WALLET_CARD: 'WALLET_CARD', + OrderPayType.FACE: 'FACE', + OrderPayType.STRIDE_DEPT_QR_CODE: 'STRIDE_DEPT_QR_CODE', + OrderPayType.FOREIGN_TICKET_QR_CODE: 'FOREIGN_TICKET_QR_CODE', + OrderPayType.RESERVE_QR_CODE: 'RESERVE_QR_CODE', + OrderPayType.CASH: 'CASH', + OrderPayType.WECHANT_SELF_QR_CODE_PAY: 'WECHANT_SELF_QR_CODE_PAY', + OrderPayType.ALI_SELF_QR_CODE_PAY: 'ALI_SELF_QR_CODE_PAY', + OrderPayType.ZYY: 'ZYY', + OrderPayType.RMYY: 'RMYY', + OrderPayType.CSXFK: 'CSXFK', + OrderPayType.ACT_CANCEL: 'ACT_CANCEL', +}; diff --git a/lib/common/services/database/entity/order_hold_bill_entity.dart b/lib/common/services/database/entity/order_hold_bill_entity.dart new file mode 100644 index 0000000..e126833 --- /dev/null +++ b/lib/common/services/database/entity/order_hold_bill_entity.dart @@ -0,0 +1,51 @@ +import 'package:floor/floor.dart'; +import 'package:json_annotation/json_annotation.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +part 'order_hold_bill_entity.g.dart'; + +@JsonSerializable() +@Entity(tableName: "order_hold_bill") +class OrderHoldBillEntity { + /// 自增ID + @PrimaryKey(autoGenerate: true) + int? id; + + /// 平台类型 + SysType sysType; + + /// 订单列表json + String? carListJson; + + /// 支付订单json + String? userJson; + + /// 购物车的总金额 + int price; + + /// 购物车额外金额 + int extraPrice; + + /// 结算总金额 + int payPrice; + + /// 创建时间 + DateTime createTime; + + OrderHoldBillEntity({ + this.id, + required this.sysType, + this.carListJson, + this.userJson, + required this.price, + required this.extraPrice, + required this.payPrice, + required this.createTime, + }); + + factory OrderHoldBillEntity.fromJson(Map json) => + _$OrderHoldBillEntityFromJson(json); + + Map toJson() => _$OrderHoldBillEntityToJson(this); +} diff --git a/lib/common/services/database/entity/order_hold_bill_entity.g.dart b/lib/common/services/database/entity/order_hold_bill_entity.g.dart new file mode 100644 index 0000000..b34a8ff --- /dev/null +++ b/lib/common/services/database/entity/order_hold_bill_entity.g.dart @@ -0,0 +1,37 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'order_hold_bill_entity.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +OrderHoldBillEntity _$OrderHoldBillEntityFromJson(Map json) => + OrderHoldBillEntity( + id: (json['id'] as num?)?.toInt(), + sysType: $enumDecode(_$SysTypeEnumMap, json['sysType']), + carListJson: json['carListJson'] as String?, + userJson: json['userJson'] as String?, + price: (json['price'] as num).toInt(), + extraPrice: (json['extraPrice'] as num).toInt(), + payPrice: (json['payPrice'] as num).toInt(), + createTime: DateTime.parse(json['createTime'] as String), + ); + +Map _$OrderHoldBillEntityToJson( + OrderHoldBillEntity instance) => + { + 'id': instance.id, + 'sysType': _$SysTypeEnumMap[instance.sysType]!, + 'carListJson': instance.carListJson, + 'userJson': instance.userJson, + 'price': instance.price, + 'extraPrice': instance.extraPrice, + 'payPrice': instance.payPrice, + 'createTime': instance.createTime.toIso8601String(), + }; + +const _$SysTypeEnumMap = { + SysType.SUPERMARKET: 'SUPERMARKET', + SysType.CANTEEN: 'CANTEEN', +}; diff --git a/lib/common/services/database/entity/print_history_entity.dart b/lib/common/services/database/entity/print_history_entity.dart new file mode 100644 index 0000000..dcc44c7 --- /dev/null +++ b/lib/common/services/database/entity/print_history_entity.dart @@ -0,0 +1,55 @@ +import 'package:floor/floor.dart'; +import 'package:json_annotation/json_annotation.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +part 'print_history_entity.g.dart'; + +@JsonSerializable() +@Entity(tableName: "print_history") +class PrintHistoryEntity { + /// 自增ID + @PrimaryKey(autoGenerate: true) + int? id; + + /// 对应的订单id + String orderId; + + /// 对应的服务端id + String serviceOrderId; + + /// 服务端的打印id + String servicePrintId; + + /// 发票图片地址 + String invoiceUrl; + + /// 打印状态 + PrintStatus status; + + /// 打印时间 + String? printTime; + + /// 同步时间 + DateTime? syncTime; + + /// 创建时间 + DateTime createTime; + + PrintHistoryEntity({ + this.id, + required this.orderId, + required this.serviceOrderId, + required this.servicePrintId, + required this.invoiceUrl, + required this.status, + this.printTime, + this.syncTime, + required this.createTime, + }); + + factory PrintHistoryEntity.fromJson(Map json) => + _$PrintHistoryEntityFromJson(json); + + Map toJson() => _$PrintHistoryEntityToJson(this); +} diff --git a/lib/common/services/database/entity/print_history_entity.g.dart b/lib/common/services/database/entity/print_history_entity.g.dart new file mode 100644 index 0000000..6c5fac9 --- /dev/null +++ b/lib/common/services/database/entity/print_history_entity.g.dart @@ -0,0 +1,42 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'print_history_entity.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +PrintHistoryEntity _$PrintHistoryEntityFromJson(Map json) => + PrintHistoryEntity( + id: (json['id'] as num?)?.toInt(), + orderId: json['orderId'] as String, + serviceOrderId: json['serviceOrderId'] as String, + servicePrintId: json['servicePrintId'] as String, + invoiceUrl: json['invoiceUrl'] as String, + status: $enumDecode(_$PrintStatusEnumMap, json['status']), + printTime: json['printTime'] as String?, + syncTime: json['syncTime'] == null + ? null + : DateTime.parse(json['syncTime'] as String), + createTime: DateTime.parse(json['createTime'] as String), + ); + +Map _$PrintHistoryEntityToJson(PrintHistoryEntity instance) => + { + 'id': instance.id, + 'orderId': instance.orderId, + 'serviceOrderId': instance.serviceOrderId, + 'servicePrintId': instance.servicePrintId, + 'invoiceUrl': instance.invoiceUrl, + 'status': _$PrintStatusEnumMap[instance.status]!, + 'printTime': instance.printTime, + 'syncTime': instance.syncTime?.toIso8601String(), + 'createTime': instance.createTime.toIso8601String(), + }; + +const _$PrintStatusEnumMap = { + PrintStatus.NONE: 'NONE', + PrintStatus.SUCCESS: 'SUCCESS', + PrintStatus.SYNC: 'SYNC', + PrintStatus.ERROR: 'ERROR', +}; diff --git a/lib/common/services/database/entity/setting_entity.dart b/lib/common/services/database/entity/setting_entity.dart new file mode 100644 index 0000000..0d02919 --- /dev/null +++ b/lib/common/services/database/entity/setting_entity.dart @@ -0,0 +1,33 @@ +import 'package:floor/floor.dart'; +import 'package:json_annotation/json_annotation.dart'; + +part 'setting_entity.g.dart'; + +@JsonSerializable() +@Entity(tableName: "setting") +class SettingEntity { + /// 自增ID + @PrimaryKey(autoGenerate: true) + int? id; + + /// 用户信息 + String userJson; + + /// 设备信息 + String sysJson; + + /// 食堂额外金额信息 + String? canteenExtraPriceConfigJson; + + SettingEntity({ + this.id, + required this.userJson, + required this.sysJson, + this.canteenExtraPriceConfigJson, + }); + + factory SettingEntity.fromJson(Map json) => + _$SettingEntityFromJson(json); + + Map toJson() => _$SettingEntityToJson(this); +} diff --git a/lib/common/services/database/entity/setting_entity.g.dart b/lib/common/services/database/entity/setting_entity.g.dart new file mode 100644 index 0000000..2b7ab91 --- /dev/null +++ b/lib/common/services/database/entity/setting_entity.g.dart @@ -0,0 +1,24 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'setting_entity.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +SettingEntity _$SettingEntityFromJson(Map json) => + SettingEntity( + id: (json['id'] as num?)?.toInt(), + userJson: json['userJson'] as String, + sysJson: json['sysJson'] as String, + canteenExtraPriceConfigJson: + json['canteenExtraPriceConfigJson'] as String?, + ); + +Map _$SettingEntityToJson(SettingEntity instance) => + { + 'id': instance.id, + 'userJson': instance.userJson, + 'sysJson': instance.sysJson, + 'canteenExtraPriceConfigJson': instance.canteenExtraPriceConfigJson, + }; diff --git a/lib/common/services/database/entity/shop_card_entity.dart b/lib/common/services/database/entity/shop_card_entity.dart new file mode 100644 index 0000000..6793bdd --- /dev/null +++ b/lib/common/services/database/entity/shop_card_entity.dart @@ -0,0 +1,62 @@ +import 'package:floor/floor.dart'; +import 'package:json_annotation/json_annotation.dart'; +import 'package:pj_supermarket_cloud/common/index.dart'; + +part 'shop_card_entity.g.dart'; + +@JsonSerializable() +@Entity(tableName: "shop_card") +class ShopCardEntity { + /// 自增ID + @PrimaryKey(autoGenerate: true) + int? id; + + /// 购物车的id字段 + int orderId; + + /// 商品code + String productServiceUniqueCode; + + /// 商品名 + String productName; + + /// 商品图片 + String productPic; + + /// 商品价格(分) + int productPrice; + + /// 商品性质 + ProductNature nature; + + /// 数量 食堂特有 + int productNumber; + + /// 重量 超市特有 + double productWidget; + + /// 单商品总价(分) + int totalPrice; + + /// 是否涨价 + bool isPriceIncreased; + + ShopCardEntity({ + this.id, + required this.orderId, + required this.productServiceUniqueCode, + required this.productName, + required this.productPic, + required this.productPrice, + required this.nature, + required this.productNumber, + required this.productWidget, + required this.totalPrice, + required this.isPriceIncreased, + }); + + factory ShopCardEntity.fromJson(Map json) => + _$ShopCardEntityFromJson(json); + + Map toJson() => _$ShopCardEntityToJson(this); +} diff --git a/lib/common/services/database/entity/shop_card_entity.g.dart b/lib/common/services/database/entity/shop_card_entity.g.dart new file mode 100644 index 0000000..abee0f8 --- /dev/null +++ b/lib/common/services/database/entity/shop_card_entity.g.dart @@ -0,0 +1,42 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'shop_card_entity.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +ShopCardEntity _$ShopCardEntityFromJson(Map json) => + ShopCardEntity( + id: (json['id'] as num?)?.toInt(), + orderId: (json['orderId'] as num).toInt(), + productServiceUniqueCode: json['productServiceUniqueCode'] as String, + productName: json['productName'] as String, + productPic: json['productPic'] as String, + productPrice: (json['productPrice'] as num).toInt(), + nature: $enumDecode(_$ProductNatureEnumMap, json['nature']), + productNumber: (json['productNumber'] as num).toInt(), + productWidget: (json['productWidget'] as num).toDouble(), + totalPrice: (json['totalPrice'] as num).toInt(), + isPriceIncreased: json['isPriceIncreased'] as bool, + ); + +Map _$ShopCardEntityToJson(ShopCardEntity instance) => + { + 'id': instance.id, + 'orderId': instance.orderId, + 'productServiceUniqueCode': instance.productServiceUniqueCode, + 'productName': instance.productName, + 'productPic': instance.productPic, + 'productPrice': instance.productPrice, + 'nature': _$ProductNatureEnumMap[instance.nature]!, + 'productNumber': instance.productNumber, + 'productWidget': instance.productWidget, + 'totalPrice': instance.totalPrice, + 'isPriceIncreased': instance.isPriceIncreased, + }; + +const _$ProductNatureEnumMap = { + ProductNature.DEFAULT: 'DEFAULT', + ProductNature.WEIGHT: 'WEIGHT', +}; diff --git a/lib/common/services/database/entity/supermarket_code_entity.dart b/lib/common/services/database/entity/supermarket_code_entity.dart new file mode 100644 index 0000000..8f7a982 --- /dev/null +++ b/lib/common/services/database/entity/supermarket_code_entity.dart @@ -0,0 +1,37 @@ +import 'package:floor/floor.dart'; +import 'package:json_annotation/json_annotation.dart'; + +part 'supermarket_code_entity.g.dart'; + +@JsonSerializable() +@Entity(tableName: "supermarket_code") +class SupermarketCodeEntity { + /// 自增ID + @PrimaryKey(autoGenerate: true) + int? id; + + /// 服务端唯一标识 + String serviceUniqueCode; + + /// spu id + String spuId; + + /// 包装单位id + String unitId; + + /// 条码 + String barCode; + + SupermarketCodeEntity({ + this.id, + required this.serviceUniqueCode, + required this.spuId, + required this.unitId, + required this.barCode, + }); + + factory SupermarketCodeEntity.fromJson(Map json) => + _$SupermarketCodeEntityFromJson(json); + + Map toJson() => _$SupermarketCodeEntityToJson(this); +} diff --git a/lib/common/services/database/entity/supermarket_code_entity.g.dart b/lib/common/services/database/entity/supermarket_code_entity.g.dart new file mode 100644 index 0000000..5e0dd49 --- /dev/null +++ b/lib/common/services/database/entity/supermarket_code_entity.g.dart @@ -0,0 +1,27 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'supermarket_code_entity.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +SupermarketCodeEntity _$SupermarketCodeEntityFromJson( + Map json) => + SupermarketCodeEntity( + id: (json['id'] as num?)?.toInt(), + serviceUniqueCode: json['serviceUniqueCode'] as String, + spuId: json['spuId'] as String, + unitId: json['unitId'] as String, + barCode: json['barCode'] as String, + ); + +Map _$SupermarketCodeEntityToJson( + SupermarketCodeEntity instance) => + { + 'id': instance.id, + 'serviceUniqueCode': instance.serviceUniqueCode, + 'spuId': instance.spuId, + 'unitId': instance.unitId, + 'barCode': instance.barCode, + }; diff --git a/lib/common/services/database/entity/supermarket_sku_entity.dart b/lib/common/services/database/entity/supermarket_sku_entity.dart new file mode 100644 index 0000000..d9557a9 --- /dev/null +++ b/lib/common/services/database/entity/supermarket_sku_entity.dart @@ -0,0 +1,57 @@ +import 'package:floor/floor.dart'; +import 'package:json_annotation/json_annotation.dart'; + +part 'supermarket_sku_entity.g.dart'; + +@JsonSerializable() +@Entity(tableName: "supermarket_sku") +class SupermarketSkuEntity { + /// 自增ID + @PrimaryKey(autoGenerate: true) + int? id; + + /// 服务端唯一标识 + String serviceUniqueCode; + + /// spu id + String spuId; + + /// 条码 + String barCode; + + /// 规格 + String spec; + + /// 单位 + String unit; + + /// 内装数量 + int packageNum; + + /// 进价/单位分 + int purchasePrice; + + /// 零售价/单位分 + int retailPrice; + + /// 促销价/单位分 + int promotionPrice; + + SupermarketSkuEntity({ + this.id, + required this.serviceUniqueCode, + required this.spuId, + required this.barCode, + required this.spec, + required this.unit, + required this.packageNum, + required this.purchasePrice, + required this.retailPrice, + required this.promotionPrice, + }); + + factory SupermarketSkuEntity.fromJson(Map json) => + _$SupermarketSkuEntityFromJson(json); + + Map toJson() => _$SupermarketSkuEntityToJson(this); +} diff --git a/lib/common/services/database/entity/supermarket_sku_entity.g.dart b/lib/common/services/database/entity/supermarket_sku_entity.g.dart new file mode 100644 index 0000000..5a0ef07 --- /dev/null +++ b/lib/common/services/database/entity/supermarket_sku_entity.g.dart @@ -0,0 +1,37 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'supermarket_sku_entity.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +SupermarketSkuEntity _$SupermarketSkuEntityFromJson( + Map json) => + SupermarketSkuEntity( + id: (json['id'] as num?)?.toInt(), + serviceUniqueCode: json['serviceUniqueCode'] as String, + spuId: json['spuId'] as String, + barCode: json['barCode'] as String, + spec: json['spec'] as String, + unit: json['unit'] as String, + packageNum: (json['packageNum'] as num).toInt(), + purchasePrice: (json['purchasePrice'] as num).toInt(), + retailPrice: (json['retailPrice'] as num).toInt(), + promotionPrice: (json['promotionPrice'] as num).toInt(), + ); + +Map _$SupermarketSkuEntityToJson( + SupermarketSkuEntity instance) => + { + 'id': instance.id, + 'serviceUniqueCode': instance.serviceUniqueCode, + 'spuId': instance.spuId, + 'barCode': instance.barCode, + 'spec': instance.spec, + 'unit': instance.unit, + 'packageNum': instance.packageNum, + 'purchasePrice': instance.purchasePrice, + 'retailPrice': instance.retailPrice, + 'promotionPrice': instance.promotionPrice, + }; diff --git a/lib/common/services/database/entity/supermarket_spu_entity.dart b/lib/common/services/database/entity/supermarket_spu_entity.dart new file mode 100644 index 0000000..d9348f7 --- /dev/null +++ b/lib/common/services/database/entity/supermarket_spu_entity.dart @@ -0,0 +1,120 @@ +import 'package:equatable/equatable.dart'; +import 'package:floor/floor.dart'; +import 'package:json_annotation/json_annotation.dart'; +import 'package:pj_supermarket_cloud/common/index.dart'; + +part 'supermarket_spu_entity.g.dart'; + +@JsonSerializable() +@Entity(tableName: "supermarket_spu") +class SupermarketSpuEntity extends Equatable { + /// 自增ID + @PrimaryKey(autoGenerate: true) + int? id; + + /// 服务端唯一标识 + String serviceUniqueCode; + + /// 分类 serviceID + String classifyServiceUniqueCode; + + /// 货号 + String productCode; + + /// 商品名称 + String productName; + + /// 商品名称拼音 + String? productNamePy; + + /// 商品性质(字典) + ProductNature productNature; + + /// 条码 + String? barCode; + + /// 内装数量 + int packageNum; + + /// 单位 + String unit; + + /// 规格 + String spec; + + /// 商品封面图 + String? picUrl; + + /// 库存 + double? stock; + + /// 销量 + double? salesCount; + + /// 进价/单位分 + int purchasePrice; + + /// 零售价/单位分 + int retailPrice; + + /// 促销价/单位分 + int promotionPrice; + + /// 排序 倒序排列 + int sort; + + /// 创建时间 + DateTime createTime; + + /// 同步时间 + DateTime updateTime; + + /// 是否删除 + bool delFlag; + + SupermarketSpuEntity({ + this.id, + required this.serviceUniqueCode, + required this.classifyServiceUniqueCode, + required this.productCode, + required this.productName, + required this.productNature, + this.barCode, + required this.packageNum, + required this.unit, + required this.spec, + required this.picUrl, + required this.stock, + required this.salesCount, + required this.purchasePrice, + required this.retailPrice, + required this.promotionPrice, + required this.sort, + required this.createTime, + required this.updateTime, + required this.delFlag, + }); + + factory SupermarketSpuEntity.fromJson(Map json) => + _$SupermarketSpuEntityFromJson(json); + + Map toJson() => _$SupermarketSpuEntityToJson(this); + + @override + List get props => [ + serviceUniqueCode, + classifyServiceUniqueCode, + productCode, + productName, + productNature, + barCode, + packageNum, + unit, + spec, + picUrl, + stock, + purchasePrice, + retailPrice, + promotionPrice, + ]; +} diff --git a/lib/common/services/database/entity/supermarket_spu_entity.g.dart b/lib/common/services/database/entity/supermarket_spu_entity.g.dart new file mode 100644 index 0000000..b958e3f --- /dev/null +++ b/lib/common/services/database/entity/supermarket_spu_entity.g.dart @@ -0,0 +1,63 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'supermarket_spu_entity.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +SupermarketSpuEntity _$SupermarketSpuEntityFromJson( + Map json) => + SupermarketSpuEntity( + id: (json['id'] as num?)?.toInt(), + serviceUniqueCode: json['serviceUniqueCode'] as String, + classifyServiceUniqueCode: json['classifyServiceUniqueCode'] as String, + productCode: json['productCode'] as String, + productName: json['productName'] as String, + productNature: $enumDecode(_$ProductNatureEnumMap, json['productNature']), + barCode: json['barCode'] as String?, + packageNum: (json['packageNum'] as num).toInt(), + unit: json['unit'] as String, + spec: json['spec'] as String, + picUrl: json['picUrl'] as String?, + stock: (json['stock'] as num?)?.toDouble(), + salesCount: (json['salesCount'] as num?)?.toDouble(), + purchasePrice: (json['purchasePrice'] as num).toInt(), + retailPrice: (json['retailPrice'] as num).toInt(), + promotionPrice: (json['promotionPrice'] as num).toInt(), + sort: (json['sort'] as num).toInt(), + createTime: DateTime.parse(json['createTime'] as String), + updateTime: DateTime.parse(json['updateTime'] as String), + delFlag: json['delFlag'] as bool, + )..productNamePy = json['productNamePy'] as String?; + +Map _$SupermarketSpuEntityToJson( + SupermarketSpuEntity instance) => + { + 'id': instance.id, + 'serviceUniqueCode': instance.serviceUniqueCode, + 'classifyServiceUniqueCode': instance.classifyServiceUniqueCode, + 'productCode': instance.productCode, + 'productName': instance.productName, + 'productNamePy': instance.productNamePy, + 'productNature': _$ProductNatureEnumMap[instance.productNature]!, + 'barCode': instance.barCode, + 'packageNum': instance.packageNum, + 'unit': instance.unit, + 'spec': instance.spec, + 'picUrl': instance.picUrl, + 'stock': instance.stock, + 'salesCount': instance.salesCount, + 'purchasePrice': instance.purchasePrice, + 'retailPrice': instance.retailPrice, + 'promotionPrice': instance.promotionPrice, + 'sort': instance.sort, + 'createTime': instance.createTime.toIso8601String(), + 'updateTime': instance.updateTime.toIso8601String(), + 'delFlag': instance.delFlag, + }; + +const _$ProductNatureEnumMap = { + ProductNature.DEFAULT: 'DEFAULT', + ProductNature.WEIGHT: 'WEIGHT', +}; diff --git a/lib/common/services/database/entity/user_entity.dart b/lib/common/services/database/entity/user_entity.dart new file mode 100644 index 0000000..01e2171 --- /dev/null +++ b/lib/common/services/database/entity/user_entity.dart @@ -0,0 +1,167 @@ +import 'package:common_utils/common_utils.dart'; +import 'package:equatable/equatable.dart'; +import 'package:floor/floor.dart'; +import 'package:json_annotation/json_annotation.dart'; +import 'package:pj_supermarket_cloud/common/index.dart'; + +part 'user_entity.g.dart'; + +// 优惠 +@JsonSerializable() +class DiscountsItem { + /// 优惠活动名称 + String name; + + /// 用餐时间段类型 + @JsonKey( + fromJson: JsonTool.stringToProductHourType, + toJson: JsonTool.productHourTypeToString, + ) + ProductHourType? hourType; + + /// 优惠类型 + @JsonKey( + fromJson: JsonTool.stringToDiscountsType, + toJson: JsonTool.discountsTypeToString, + ) + DiscountsType? type; + + /// 基础金额(分),超过了这个基数`extraPer`就生效 + int basePrice; + + /// 特色菜是否额外收费 + @JsonKey( + fromJson: JsonTool.stringToBoolean, + toJson: JsonTool.booleanToString, + ) + bool specialDishEnable; + + /// 打折(8折->0.8) + double? extraPer; + + DiscountsItem({ + required this.name, + required this.hourType, + required this.type, + required this.basePrice, + required this.specialDishEnable, + required this.extraPer, + }); + + /// 计算优惠后的价格 + /// [totalPrice] 总金额(分) + /// [return] 优惠的价格(分) + int computedDiscountsExtraPrice(int totalPrice) { + if (totalPrice <= basePrice) return 0; + final rate = extraPer ?? 1.0; + if (rate == 1.0) return 0; + return (totalPrice * (rate - 1)).round(); + } + + factory DiscountsItem.fromJson(Map json) => + _$DiscountsItemFromJson(json); + + Map toJson() => _$DiscountsItemToJson(this); +} + +@JsonSerializable() +@Entity(tableName: "user_record") +class UserEntity extends Equatable { + /// 自增ID + @PrimaryKey(autoGenerate: true) + int? id; + + /// 用户id + String userId; + + /// 用户名 + String nickname; + + /// 手机号码 + String? mobile; + + /// 用户头像 + String? avatar; + + /// 钱包卡号 + String? walletCardNumber; + + /// 钱包id + String? walletId; + + /// 钱包类型id + String? walletTypeId; + + /// 钱包余额 + int? walletBalance; + + /// 用户所属的商户id(主要用于机器的离线模式) + String? canteenId; + + /// 优惠活动 + List? discountsList; + + /// 优惠活动更新时间 + DateTime? discountsUpdateTime; + + /// 创建时间 + DateTime? createTime; + + /// 同步时间 + DateTime? updateTime; + + UserEntity({ + this.id, + required this.userId, + required this.nickname, + this.mobile, + this.avatar, + this.walletCardNumber, + this.walletId, + this.walletTypeId, + this.walletBalance, + this.canteenId, + this.createTime, + this.updateTime, + this.discountsUpdateTime, + this.discountsList, + }); + + /// 计算优惠后的价格 + /// [totalPrice] 总金额(分) + /// [return] 优惠的价格(分) + int computedDiscountsPrice(int totalPrice) { + if (ObjectUtil.isEmpty(discountsList)) { + return 0; + } + var discountsPrice = 0; + for (final item in discountsList!) { + discountsPrice += item.computedDiscountsExtraPrice(totalPrice); + } + return discountsPrice; + } + + /// 根据`discountsUpdateTime`来计算,最小更新时间30s + bool isUpdateDiscounts() { + if (ObjectUtil.isEmpty(discountsUpdateTime)) { + return true; + } + return DateTime.now().difference(discountsUpdateTime!).inSeconds > 30; + } + + factory UserEntity.fromJson(Map json) => + _$UserEntityFromJson(json); + + Map toJson() => _$UserEntityToJson(this); + + @override + List get props => [ + userId, + nickname, + mobile, + walletCardNumber, + walletId, + walletTypeId, + walletBalance, + ]; +} diff --git a/lib/common/services/database/entity/user_entity.g.dart b/lib/common/services/database/entity/user_entity.g.dart new file mode 100644 index 0000000..185c3a6 --- /dev/null +++ b/lib/common/services/database/entity/user_entity.g.dart @@ -0,0 +1,71 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'user_entity.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +DiscountsItem _$DiscountsItemFromJson(Map json) => + DiscountsItem( + name: json['name'] as String, + hourType: JsonTool.stringToProductHourType(json['hourType'] as String?), + type: JsonTool.stringToDiscountsType(json['type'] as String?), + basePrice: (json['basePrice'] as num).toInt(), + specialDishEnable: + JsonTool.stringToBoolean(json['specialDishEnable'] as String?), + extraPer: (json['extraPer'] as num?)?.toDouble(), + ); + +Map _$DiscountsItemToJson(DiscountsItem instance) => + { + 'name': instance.name, + 'hourType': JsonTool.productHourTypeToString(instance.hourType), + 'type': JsonTool.discountsTypeToString(instance.type), + 'basePrice': instance.basePrice, + 'specialDishEnable': JsonTool.booleanToString(instance.specialDishEnable), + 'extraPer': instance.extraPer, + }; + +UserEntity _$UserEntityFromJson(Map json) => UserEntity( + id: (json['id'] as num?)?.toInt(), + userId: json['userId'] as String, + nickname: json['nickname'] as String, + mobile: json['mobile'] as String?, + avatar: json['avatar'] as String?, + walletCardNumber: json['walletCardNumber'] as String?, + walletId: json['walletId'] as String?, + walletTypeId: json['walletTypeId'] as String?, + walletBalance: (json['walletBalance'] as num?)?.toInt(), + canteenId: json['canteenId'] as String?, + createTime: json['createTime'] == null + ? null + : DateTime.parse(json['createTime'] as String), + updateTime: json['updateTime'] == null + ? null + : DateTime.parse(json['updateTime'] as String), + discountsUpdateTime: json['discountsUpdateTime'] == null + ? null + : DateTime.parse(json['discountsUpdateTime'] as String), + discountsList: (json['discountsList'] as List?) + ?.map((e) => DiscountsItem.fromJson(e as Map)) + .toList(), + ); + +Map _$UserEntityToJson(UserEntity instance) => + { + 'id': instance.id, + 'userId': instance.userId, + 'nickname': instance.nickname, + 'mobile': instance.mobile, + 'avatar': instance.avatar, + 'walletCardNumber': instance.walletCardNumber, + 'walletId': instance.walletId, + 'walletTypeId': instance.walletTypeId, + 'walletBalance': instance.walletBalance, + 'canteenId': instance.canteenId, + 'discountsList': instance.discountsList, + 'discountsUpdateTime': instance.discountsUpdateTime?.toIso8601String(), + 'createTime': instance.createTime?.toIso8601String(), + 'updateTime': instance.updateTime?.toIso8601String(), + }; diff --git a/lib/common/services/database/type_converter/type_converter.dart b/lib/common/services/database/type_converter/type_converter.dart new file mode 100644 index 0000000..30b45a4 --- /dev/null +++ b/lib/common/services/database/type_converter/type_converter.dart @@ -0,0 +1,187 @@ +import 'dart:convert'; + +import 'package:floor/floor.dart'; +import 'package:common_utils/common_utils.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +class DateTimeConverter extends TypeConverter { + @override + DateTime decode(String databaseValue) { + return DateUtil.getDateTime(databaseValue)!; + } + + @override + String encode(DateTime value) { + return DateUtil.formatDate(value); + } +} + +class DateTimeEmptyConverter extends TypeConverter { + @override + DateTime? decode(String databaseValue) { + if (ObjectUtil.isEmpty(databaseValue)) { + return null; + } + return DateUtil.getDateTime(databaseValue); + } + + @override + String encode(DateTime? value) { + if (ObjectUtil.isEmpty(value)) { + return ""; + } + return DateUtil.formatDate(value); + } +} + +class SysTypeConverter extends TypeConverter { + @override + SysType decode(String databaseValue) { + return SysType.values.byName(databaseValue); + } + + @override + String encode(SysType value) { + return value.name; + } +} + +class OrderTypeConverter extends TypeConverter { + @override + OrderType decode(String databaseValue) { + return OrderType.values.byName(databaseValue); + } + + @override + String encode(OrderType value) { + return value.name; + } +} + +class OrderPayTypeConverter extends TypeConverter { + @override + OrderPayType decode(String databaseValue) { + return OrderPayType.values.byName(databaseValue); + } + + @override + String encode(OrderPayType value) { + return value.name; + } +} + +class OrderStatusConverter extends TypeConverter { + @override + OrderStatus decode(String databaseValue) { + return OrderStatus.values.byName(databaseValue); + } + + @override + String encode(OrderStatus value) { + return value.name; + } +} + +class PrintStatusConverter extends TypeConverter { + @override + PrintStatus decode(String databaseValue) { + return PrintStatus.values.byName(databaseValue); + } + + @override + String encode(PrintStatus value) { + return value.name; + } +} + +class ProductHourTypeConverter extends TypeConverter { + @override + ProductHourType decode(String databaseValue) { + return ProductHourType.values.byName(databaseValue); + } + + @override + String encode(ProductHourType value) { + return value.name; + } +} + +class SupermarketNatureConverter extends TypeConverter { + @override + ProductNature decode(String databaseValue) { + return ProductNature.values.byName(databaseValue); + } + + @override + String encode(ProductNature value) { + return value.name; + } +} + +class DiscountsTypeConverter extends TypeConverter { + @override + DiscountsType decode(String databaseValue) { + return DiscountsType.values.byName(databaseValue); + } + + @override + String encode(DiscountsType value) { + return value.name; + } +} + +class ListIntConverter extends TypeConverter?, String> { + @override + List decode(String databaseValue) { + if (databaseValue.isEmpty) { + return []; + } + return json.decode(databaseValue); + } + + @override + String encode(List? value) { + if (ObjectUtil.isEmptyList(value)) { + return ''; + } + return json.encode(value); + } +} + +class ListDiscountsConverter + extends TypeConverter?, String> { + @override + List decode(String databaseValue) { + if (databaseValue.isEmpty) return []; + final raw = json.decode(databaseValue); + if (raw is List) { + return raw + .map( + (e) => DiscountsItem.fromJson( + Map.from(e as Map), + ), + ) + .toList(); + } + return []; + } + + @override + String encode(List? value) { + if (ObjectUtil.isEmptyList(value)) return '[]'; + return json.encode(value!.map((e) => e.toJson()).toList()); + } +} + +class BoolConverter extends TypeConverter { + @override + bool decode(int databaseValue) { + return databaseValue == 1 ? true : false; + } + + @override + int encode(bool value) { + return value ? 1 : 0; + } +} diff --git a/lib/common/services/index.dart b/lib/common/services/index.dart new file mode 100644 index 0000000..5f8f7a9 --- /dev/null +++ b/lib/common/services/index.dart @@ -0,0 +1,33 @@ +library; + +export './database/database.dart'; + +export './database/type_converter/type_converter.dart'; + +export './database/dao/broadcast_dao.dart'; +export './database/dao/classify_dao.dart'; +export './database/dao/face_dao.dart'; +export './database/dao/order_hold_bill_dao.dart'; +export './database/dao/order_dao.dart'; +export './database/dao/print_history_dao.dart'; +export './database/dao/canteen_spu_dao.dart'; +export './database/dao/setting_dao.dart'; +export './database/dao/shop_card_dao.dart'; +export './database/dao/supermarket_code_dao.dart'; +export './database/dao/supermarket_sku_dao.dart'; +export './database/dao/supermarket_spu_dao.dart'; +export './database/dao/user_dao.dart'; + +export './database/entity/broadcast_entity.dart'; +export './database/entity/classify_entity.dart'; +export './database/entity/face_entity.dart'; +export './database/entity/order_entity.dart'; +export './database/entity/order_hold_bill_entity.dart'; +export './database/entity/print_history_entity.dart'; +export './database/entity/canteen_spu_entity.dart'; +export './database/entity/setting_entity.dart'; +export './database/entity/shop_card_entity.dart'; +export './database/entity/supermarket_code_entity.dart'; +export './database/entity/supermarket_sku_entity.dart'; +export './database/entity/supermarket_spu_entity.dart'; +export './database/entity/user_entity.dart'; diff --git a/lib/common/style/index.dart b/lib/common/style/index.dart new file mode 100644 index 0000000..73ad53f --- /dev/null +++ b/lib/common/style/index.dart @@ -0,0 +1,3 @@ +library; + +export './xfj_design_tokens.dart'; diff --git a/lib/common/style/xfj_design_tokens.dart b/lib/common/style/xfj_design_tokens.dart new file mode 100644 index 0000000..cf80561 --- /dev/null +++ b/lib/common/style/xfj_design_tokens.dart @@ -0,0 +1,53 @@ +import 'package:flutter/material.dart'; + +/// 与 `ui/UI_DESIGN.md`、React 原型共用的终端视觉令牌。 +/// +/// Flutter 页面只能通过语义令牌表达颜色,避免页面实现与 5188 原型再次偏离。 +abstract final class XfjDesignTokens { + static const Color primary = Color(0xFF0094FF); + static const Color primarySoft = Color(0xFFEAF6FF); + static const Color danger = Color(0xFFF63A36); + static const Color warning = Color(0xFFF2A817); + static const Color success = Color(0xFF22A06B); + static const Color canvas = Color(0xFFF5F5F7); + static const Color surface = Color(0xFFFFFFFF); + static const Color ink = Color(0xFF111827); + static const Color muted = Color(0xFF667085); + static const Color line = Color(0xFFE5E7EB); + static const Color control = Color(0xFFF1F3F6); + static const Color controlHover = Color(0xFFEEF2F7); + + static double _cardRadius = 16; + static double _controlRadius = 12; + static double _buttonSizeScale = 1; + + static double get cardRadius => _cardRadius; + static double get controlRadius => _controlRadius; + static double get buttonSizeScale => _buttonSizeScale; + + /// 同层级独立卡片只保留轻微分隔,避免横屏终端被无效留白挤占操作空间。 + static const double cardGap = 6; + + /// 工具栏控件共享同一高度,并保留 Android 终端所需的最小触控区域。 + static double get controlHeight => + (64 * _buttonSizeScale).clamp(60, 76).toDouble(); + + /// 自定义终端组件与 Forui 使用同一套运行时指标,避免只改一类组件造成视觉割裂。 + static void applyThemeMetrics({ + required double cardRadius, + required double controlRadius, + required double buttonSizeScale, + }) { + _cardRadius = cardRadius; + _controlRadius = controlRadius; + _buttonSizeScale = buttonSizeScale; + } + + static const List surfaceShadow = [ + BoxShadow( + color: Color(0x0A111827), + blurRadius: 10, + offset: Offset(0, 2), + ), + ]; +} diff --git a/lib/common/utils/app/app_face_tool.dart b/lib/common/utils/app/app_face_tool.dart new file mode 100644 index 0000000..b60de75 --- /dev/null +++ b/lib/common/utils/app/app_face_tool.dart @@ -0,0 +1,102 @@ +import 'dart:convert'; + +import 'package:common_utils/common_utils.dart'; +import 'package:get/get.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +abstract class AppFaceTool { + static const String _tag = "app 人脸工具"; + + // 跳转到人脸识别页面 + static Future jumpFacePage() async { + if (AppRuntimeTool.useManualHardwareInput) return false; + final isJump = await FaceDiscernPlugs.jumpFaceDiscernPage(); + if (!isJump) return false; + Future.delayed(const Duration(seconds: 2), () { + AppSubScreenTool.hide(); + }); + return true; + } + + // 检查人脸激活参数 + static bool _validateSysFaceParams(SysSetting sysSetting) { + if (ObjectUtil.isEmpty(sysSetting.faceAppId)) { + return false; + } + if (ObjectUtil.isEmpty(sysSetting.faceSdkKey)) { + return false; + } + if (ObjectUtil.isEmpty(sysSetting.faceActiveKey)) { + return false; + } + return true; + } + + // 批量注册人脸 + static Future faceRegister({ + String? userId, + bool forceRegister = false, + }) async { + if (AppRuntimeTool.useManualHardwareInput) return true; + var faceCount = await FaceDiscernPlugs.getFaceCount(); + if (faceCount == 0 || forceRegister) { + final list = await FaceDiscernPlugs.registerFaceByDatabase( + userId: userId, + ); + if (!ObjectUtil.isEmptyList(list)) { + final app = Get.find(); + final faceDao = app.dataBase.getFaceDao; + logger.i("|$_tag|批量注册人脸,错误的人脸:${json.encode(list)}"); + for (final v in list) { + final sampleId = v.toString(); + final userFace = await faceDao.findBySampleId(sampleId); + if (ObjectUtil.isNotEmpty(userFace)) { + if (ObjectUtil.isNotEmpty(userFace?.path)) { + await SyncFaceTool.delFaceByStorage(filePath: userFace!.path!); + } + await faceDao.delById(userFace!.id!); + await AppFaceTool.removeFace(sampleId: sampleId); + } + } + } + } + faceCount = await FaceDiscernPlugs.getFaceCount(); + if (faceCount == 0) return false; + return true; + } + + // 是否激活人脸sdk + static Future isActiveSdk() async { + if (AppRuntimeTool.useManualHardwareInput) return true; + final isNetwork = AppNetworkTool.isAutoNetwork(); + if (!isNetwork) return true; + var faceConfig = await FaceDiscernPlugs.getConfig(); + if (faceConfig == null || faceConfig.isActive == false) { + final sysSetting = await AppSettingTool.getSysSetting(); + final valid = AppFaceTool._validateSysFaceParams(sysSetting); + if (!valid) return false; + final isActive = await FaceDiscernPlugs.active( + appId: sysSetting.faceAppId!, + sdkKey: sysSetting.faceSdkKey!, + activeKey: sysSetting.faceActiveKey!, + ); + if (isActive) { + faceConfig = await FaceDiscernPlugs.getConfig(); + } + } + if (faceConfig == null) { + return false; + } + return faceConfig.isActive; + } + + // 删除人脸 + static Future removeFace({String? sampleId}) async { + if (sampleId == null) return false; + if (AppRuntimeTool.useManualHardwareInput) return true; + final isActive = await AppFaceTool.isActiveSdk(); + if (!isActive) return false; + return await FaceDiscernPlugs.removeFace(sampleId: sampleId); + } +} diff --git a/lib/common/utils/app/app_file_tool.dart b/lib/common/utils/app/app_file_tool.dart new file mode 100644 index 0000000..628c55a --- /dev/null +++ b/lib/common/utils/app/app_file_tool.dart @@ -0,0 +1,228 @@ +import 'dart:io'; + +import 'package:common_utils/common_utils.dart'; +import 'package:external_path/external_path.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; +import 'package:sqflite/sqflite.dart'; + +abstract class AppFileTool { + static const _tag = "app文件工具"; + static const _baseDir = "pj_cashier"; + static const _baseBackupDir = "backup"; + static const _backupDatabaseDir = "database"; + static const _backupLoggerDir = "logger"; + static const _backupFaceDir = "face"; + + /// 检查并创建目录 + static Future _checkAndcreateDir(String dir) async { + Directory targetDir = Directory(dir); + if (!await targetDir.exists()) { + await targetDir.create(recursive: true); + } + return dir; + } + + /// 获取外部路径 + static Future getExternalPath({ + bool backupExternalPath = true, + }) async { + final documentsDir = await ExternalPath.getExternalStoragePublicDirectory( + ExternalPath.DIRECTORY_DOCUMENTS, + ); + final backupDir = "$documentsDir/$_baseDir"; + if (backupExternalPath) { + final sysSetting = await AppSettingTool.getSysSetting(); + sysSetting.externalPath = documentsDir; + await AppSettingTool.setSysSetting(sysSetting); + } + return backupDir; + } + + /// 创建数据库文件地址 + static Future getDatabasePath() async { + String backupDir = await getExternalPath(backupExternalPath: false); + String dir = "$backupDir/$_backupDatabaseDir"; + dir = await _checkAndcreateDir(dir); + return dir; + } + + /// 创建数据库文件备份地址 + static Future getDatabaseBackupPath() async { + String backupDir = await getExternalPath(backupExternalPath: false); + String dir = "$backupDir/$_baseBackupDir/$_backupDatabaseDir"; + DateTime now = DateTime.now(); + int year = now.year; + int month = now.month; + int day = now.day; + dir = "$dir/$year-$month"; + dir = await _checkAndcreateDir(dir); + dir = "$dir/$day.db"; + return dir; + } + + /// 创建日志地址 + static Future getLoggerPath() async { + final backupDir = await getExternalPath(); + return _checkAndcreateDir("$backupDir/$_backupLoggerDir"); + } + + /// 创建日志备份地址 + static Future getLoggerBackupPath() async { + final backupDir = await getExternalPath(backupExternalPath: false); + String dir = "$backupDir/$_baseBackupDir/$_backupLoggerDir"; + DateTime now = DateTime.now(); + int year = now.year; + int month = now.month; + int day = now.day; + dir = "$dir/$year-$month"; + dir = await _checkAndcreateDir(dir); + dir = "$dir/$day.log"; + return dir; + } + + /// 创建一次性日志快照地址,避免手动导出覆盖当前仍在写入的实时日志文件。 + static Future getLoggerSnapshotPath() async { + final backupDir = await getExternalPath(backupExternalPath: false); + String dir = "$backupDir/$_baseBackupDir/$_backupLoggerDir/manual"; + dir = await _checkAndcreateDir(dir); + final now = DateTime.now(); + final stamp = now.toIso8601String().replaceAll(':', '-'); + return "$dir/log-$stamp.log"; + } + + /// 创建人脸地址 + static Future getFacePath() async { + final backupDir = await getExternalPath(); + return _checkAndcreateDir("$backupDir/$_backupFaceDir"); + } + + /// 创建人脸备份地址 + static Future getFaceBackupPath() async { + final backupDir = await getExternalPath(); + return _checkAndcreateDir("$backupDir/$_baseBackupDir/$_backupFaceDir"); + } + + /// 备份数据库并返回生成文件,页面据此展示真实结果和最近备份时间。 + static Future backupSqlFilePath() async { + final sourcePath = await getDatabasePath(); + if (ObjectUtil.isEmpty(sourcePath)) { + logger.e("|$_tag|备份sql文件失败,无效的sourcePath路径path:|$sourcePath|"); + return null; + } + final databaseFilePath = "$sourcePath/${Constants.databaseName}"; + if (!await File(databaseFilePath).exists()) { + logger.w("|$_tag|备份sql文件失败,无效的sourcePath路径path|$databaseFilePath|"); + return null; + } + String? backupPath = await getDatabaseBackupPath(); + if (ObjectUtil.isEmpty(backupPath)) { + logger.e("|$_tag|备份sql文件失败,无效的backupPath路径path:|$backupPath|"); + return null; + } + try { + await File(databaseFilePath).copy(backupPath!); + return backupPath; + } catch (e, stack) { + logger.e("|$_tag|备份sql文件失败,意外的错误error|$e|stack|$stack|"); + return null; + } + } + + // 保留旧接口,应用启动时仍可按原方式进行自动备份。 + static Future backupSqlFile() async { + return await backupSqlFilePath() != null; + } + + /// 将当前实时日志复制为独立快照,不中断 Logger 正在持有的文件句柄。 + static Future backupLogFile() async { + final sourcePath = await getLoggerBackupPath(); + final targetPath = await getLoggerSnapshotPath(); + if (ObjectUtil.isEmpty(sourcePath) || ObjectUtil.isEmpty(targetPath)) { + return null; + } + final source = File(sourcePath!); + if (!await source.exists()) { + logger.w("|$_tag|备份日志失败,实时日志不存在path|$sourcePath|"); + return null; + } + try { + await source.copy(targetPath!); + return targetPath; + } catch (e, stack) { + logger.e("|$_tag|备份日志失败error|$e|stack|$stack|"); + return null; + } + } + + /// 获取目录中最近生成文件的修改时间。 + static Future getLatestModifiedTime(String? path) async { + if (ObjectUtil.isEmpty(path)) return null; + final entity = FileSystemEntity.typeSync(path!); + if (entity == FileSystemEntityType.file) { + return File(path).stat().then((value) => value.modified); + } + final directory = Directory(path); + if (!await directory.exists()) return null; + DateTime? latest; + await for (final file in directory.list(recursive: true)) { + if (file is! File) continue; + final modified = (await file.stat()).modified; + if (latest == null || modified.isAfter(latest)) latest = modified; + } + return latest; + } + + // 如果没有数据库将最新的数据库移动为使用的数据库 + static Future useLastBackDatabase() async { + final databasePath = getDatabasesPath(); + final databaseFilePath = "$databasePath/${Constants.databaseName}"; + if (await File(databaseFilePath).exists()) { + logger.w("|$_tag|替换数据库错误,数据库文件已存在path|$databaseFilePath|"); + return; + } + final path = await getDatabaseBackupPath(); + if (ObjectUtil.isEmpty(path)) { + logger.w("|$_tag|替换数据库错误,无效的数据库备份path|$path|"); + return; + } + final dir = Directory(path!).parent; + if (!await dir.exists()) { + logger.w("|$_tag|替换数据库错误,无效的数据库备份目录path|$dir|"); + return; + } + final files = dir.listSync(); + files.sort( + (a, b) => b.statSync().modified.compareTo(a.statSync().modified), + ); + if (files.isEmpty) { + logger.w("|$_tag|替换数据库错误,数据库备份目录为空path|$dir|"); + return; + } + final lastFile = files.first; + try { + if (lastFile is File) { + final sourcePath = lastFile.path; + await File(sourcePath).copy(databaseFilePath); + logger.i( + "|$_tag|替换数据库成功,sourcePath|$sourcePath|targetPath|$databaseFilePath|", + ); + } + } catch (e, stack) { + logger.e("|$_tag|替换数据库错误,意外的错误error|$e|stack|$stack|"); + } + } + + // 获取文件后缀 + static String getFileExtension(String url) { + int lastDotIndex = url.lastIndexOf('.'); + if (lastDotIndex == -1) { + return ''; + } + int lastSlashIndex = url.lastIndexOf('/'); + if (lastDotIndex > lastSlashIndex) { + return url.substring(lastDotIndex); + } + return ''; + } +} diff --git a/lib/common/utils/app/app_mq_tool.dart b/lib/common/utils/app/app_mq_tool.dart new file mode 100644 index 0000000..ed52b3f --- /dev/null +++ b/lib/common/utils/app/app_mq_tool.dart @@ -0,0 +1,162 @@ +import 'dart:convert'; + +import 'package:common_utils/common_utils.dart'; +import 'package:get/get.dart'; +import 'package:mqtt_client/mqtt_client.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +class AppMqTool { + static const String _tag = "app mq 工具"; + + static Future mqRefresh() async { + final isNetwork = AppNetworkTool.isAutoNetwork(); + if (!isNetwork) { + logger.e("|$_tag|mqtt监听消息错误没有网络"); + return; + } + final app = Get.find(); + var mqListen = app.mqListen; + + final sysInfo = await AppSettingTool.getSysSetting(); + // mqtt + if (ObjectUtil.isEmpty(sysInfo.serviceId)) return; + await MqManager.refresh(); + if (MqManager.state != MqttConnectionState.connected) { + return; + } + if (ObjectUtil.isNotEmpty(mqListen)) AppMqTool.mqListenClose(); + final appMerchantDeptId = sysInfo.merchantDeptId!; + final appHardwareId = sysInfo.serviceId!; + final appHardGroupId = sysInfo.hardGroupId!; + mqListen = MqManager.mqttClient?.updates?.listen((event) { + String topic = event[0].topic; + final msg = event[0].payload as MqttPublishMessage; + topic = topic.replaceAll('topic/android/', ''); + if (ObjectUtil.isEmpty(topic)) return; + final splitList = topic.split("/"); + String? merchantDeptId; + String? hardwareId; + String? hardGroupId; + if (splitList.isEmpty) { + return; + } + if (splitList.length == 1) { + hardGroupId = splitList[0].toString(); + if (appHardGroupId != hardGroupId) return; + } + if (splitList.length == 2) { + hardGroupId = splitList[0].toString(); + merchantDeptId = splitList[1].toString(); + if (appHardGroupId != hardGroupId) return; + if (appMerchantDeptId != merchantDeptId) return; + } + if (splitList.length == 3) { + hardGroupId = splitList[0].toString(); + merchantDeptId = splitList[1].toString(); + hardwareId = splitList[2].toString(); + if (appHardGroupId != hardGroupId) return; + if (appMerchantDeptId != merchantDeptId) return; + if (appHardwareId != hardwareId) return; + } + try { + final message = msg.payload.message; + String str = MqttPublishPayload.bytesToStringAsString(message); + logger.i("|$_tag|mqtt 接收到的消息:$str;"); + final mqMsg = MqBaseMsg.fromJson(json.decode(str)); + dispatchMqttMessage(mqMsg, str); + } catch (e) { + logger.e("|$_tag|mqtt监听消息错误error:|$e|"); + } + }); + } + + // 根据 msg action 来处理 mq 消息 + static void dispatchMqttMessage(MqBaseMsg msg, String orgStr) async { + if (ObjectUtil.isEmpty(msg.action)) return; + final app = Get.find(); + final mqUpdateEventBus = app.mqUpdateEventBus; + switch (msg.action!) { + case MqtActionEnum.CONFIG: + await SyncConfigTool.autoSyncSysInfo(); + mqUpdateEventBus.fire(MqtActionEnum.CONFIG); + break; + case MqtActionEnum.CANTEEN_PRODUCT: + await SyncProductTool.syncProduct(); + mqUpdateEventBus.fire(MqtActionEnum.CANTEEN_PRODUCT); + break; + case MqtActionEnum.SUPERMARKET_PRODUCT: + await SyncProductTool.syncProduct(); + mqUpdateEventBus.fire(MqtActionEnum.SUPERMARKET_PRODUCT); + break; + case MqtActionEnum.SUPERMARKET: + await SyncProductTool.syncSupermarket(); + mqUpdateEventBus.fire(MqtActionEnum.SUPERMARKET); + break; + case MqtActionEnum.CANTEEN_CLASSIFY: + await SyncProductTool.syncClassify(); + mqUpdateEventBus.fire(MqtActionEnum.CANTEEN_CLASSIFY); + break; + case MqtActionEnum.SUPERMARKET_CLASSIFY: + await SyncProductTool.syncClassify(); + mqUpdateEventBus.fire(MqtActionEnum.SUPERMARKET_CLASSIFY); + break; + case MqtActionEnum.PRINT: + mqUpdateEventBus.fire(MqtActionEnum.PRINT); + final msg = MqttInvoiceMsg.fromJson(json.decode(orgStr)); + if (ObjectUtil.isNotEmpty(msg.historyId) && + ObjectUtil.isNotEmpty(msg.orderId) && + ObjectUtil.isNotEmpty(msg.androidOrderCode) && + ObjectUtil.isNotEmpty(msg.pic)) { + SyncPrintTool.addTask( + orderId: msg.orderId!, + androidOrderId: msg.androidOrderCode!, + historyId: msg.historyId!, + pic: msg.pic!, + ); + } + break; + case MqtActionEnum.TAKE_FOOD_BROADCAST: + mqUpdateEventBus.fire(MqtActionEnum.TAKE_FOOD_BROADCAST); + try { + final msg = MqttTakeFoodBroadcastMsg.fromJson(json.decode(orgStr)); + SyncTakeFoodBroadcastTool.addTask(msg); + } catch (e, stack) { + logger.e("|$_tag|定餐广播执行错误orgStr|$orgStr|error|$e|stack|$stack|"); + } + return; + case MqtActionEnum.FACE: + return; + // final msg = MqttInvoiceMsg.fromJson(json.decode(orgStr)); + // final storagePath = await AppFileTool.getFacePath(); + // if (ObjectUtil.isEmpty(storagePath)) { + // logger.e("|$_tag|mq同步人脸错误,创建人脸存储路径错误"); + // return; + // } + // final sysSetting = await AppSettingTool.getSysSetting(); + // var merchantDeptId = sysSetting.merchantDeptId; + // if (ObjectUtil.isEmpty(storagePath)) { + // logger.e("|$_tag|mq同步人脸错误,merchantId为空"); + // return; + // } + // await SyncFaceTool.syncGroupFace( + // pageNo: 1, + // userId: msg.userId, + // merchantDeptId: merchantDeptId!, + // storagePath: storagePath!, + // isGlobalFace: msg.isGlobalFace, + // ); + // mqUpdateEventBus.fire(MqtActionEnum.FACE); + // break; + default: + break; + } + } + + static void mqListenClose() { + final app = Get.find(); + var mqListen = app.mqListen; + mqListen?.cancel(); + mqListen = null; + } +} diff --git a/lib/common/utils/app/app_network_tool.dart b/lib/common/utils/app/app_network_tool.dart new file mode 100644 index 0000000..c8f461b --- /dev/null +++ b/lib/common/utils/app/app_network_tool.dart @@ -0,0 +1,150 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:connectivity_plus/connectivity_plus.dart'; +import 'package:flutter_easyloading/flutter_easyloading.dart'; +import 'package:get/get.dart'; +import 'package:http/http.dart' as http; +import 'package:image/image.dart' as imglib; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +abstract class AppNetworkTool { + static const _tag = "网络工具类"; + + static StreamSubscription>? subscription; + + static bool isNetworkOssImage(String imagePath) { + return imagePath.contains("?fileName="); + } + + // 是否是网络图片 + static bool isNetworkImage(String imagePath) { + if (isNetworkOssImage(imagePath)) return true; + final networkImagePattern = RegExp(r'^(http|https)://'); + return networkImagePattern.hasMatch(imagePath); + } + + // 手动判断有没有要网络 todo 不建议使用 + static Future isHandNetwork() async { + Stopwatch apiStopwatch = Stopwatch(); + apiStopwatch.start(); + final List list = + await Connectivity().checkConnectivity(); + final bool = !list.contains(ConnectivityResult.none); + apiStopwatch.stop(); + logger.d('|$_tag|判断是否有网络的耗时: ${apiStopwatch.elapsedMilliseconds} 毫秒'); + return bool; + } + + /// 仅用于状态展示的轻量探测,不输出周期性日志。 + static Future isConnectedNow() async { + final list = await Connectivity().checkConnectivity(); + return list.any((item) => item != ConnectivityResult.none); + } + + /// 原生通道无法识别模拟器网络时,使用 connectivity_plus 补充连接类型。 + static Future getNetworkTypeNow() async { + final list = await Connectivity().checkConnectivity(); + if (list.contains(ConnectivityResult.wifi)) return 'wifi'; + if (list.contains(ConnectivityResult.ethernet)) return 'ethernet'; + if (list.contains(ConnectivityResult.mobile)) return 'mobile'; + if (list.any((item) => item != ConnectivityResult.none)) return 'unknown'; + return 'offline'; + } + + // 自动判断是否有网络 + static bool isAutoNetwork() { + final app = Get.find(); + return app.isNetwork.value; + } + + // 自动判断是否有网络加msg + static bool isAutoNetworkMsg() { + final isNetwork = AppNetworkTool.isAutoNetwork(); + if (!isNetwork) { + Toast.error(msg: "当前没有网络"); + return false; + } + return true; + } + + // 自动监听网络变化 + static void listenNetwork() async { + final initial = await Connectivity().checkConnectivity(); + _applyConnectivity(initial); + subscription = + Connectivity().onConnectivityChanged.listen(_applyConnectivity); + } + + static void _applyConnectivity(List list) { + final connected = list.any((item) => item != ConnectivityResult.none); + final app = Get.find(); + if (connected == app.isNetwork.value) return; + app.isNetwork.value = connected; + EasyLoading.dismiss(); + AppMqTool.mqRefresh(); + logger.w("|$_tag|监听网络变化,当前网络已经${connected ? '连接' : '断开'}"); + if (connected) SyncOrderTool.syncOfflineOrder(); + } + + // 关闭监听网络 + static void closeListenNetwork() { + subscription?.cancel(); + } + + /// 从网络中获取图片 + static Future loadImageFromNetwork(String url) async { + try { + String networkImage = url; + if (AppNetworkTool.isNetworkOssImage(networkImage)) { + networkImage = "${Constants.ossUrl}$networkImage"; + } + final response = await http.get(Uri.parse(networkImage)); + if (response.statusCode == 200) { + final Uint8List imageData = response.bodyBytes; + final decodeImage = imglib.decodeImage(imageData); + return decodeImage; + } else { + logger.e( + '|$_tag|从网络中获取图片statusCode错误statusCode:|${response.statusCode}|'); + return null; + } + } catch (e) { + logger.e('|$_tag|从网络中获取图片错误:|$e|'); + return null; + } + } + + // 从网络中下载图片 + static Future downloadImage(String url) async { + String networkImage = url; + if (AppNetworkTool.isNetworkOssImage(networkImage)) { + networkImage = "${Constants.ossUrl}$networkImage"; + } + try { + final client = http.Client(); + try { + final request = http.Request('GET', Uri.parse(networkImage)); + final streamedResponse = await client.send(request).timeout( + const Duration(seconds: 30), + ); + if (streamedResponse.statusCode != 200) return null; + final List bytes = []; + await for (final chunk in streamedResponse.stream) { + bytes.addAll(chunk); + } + return Uint8List.fromList(bytes); + } finally { + client.close(); + } + } catch (e) { + logger.e('|$_tag|从网络中下载图片错误:|$e|'); + return null; + } + } + + static String ossImage(String src) { + return "${Constants.ossUrl}$src"; + } +} diff --git a/lib/common/utils/app/app_price_tool.dart b/lib/common/utils/app/app_price_tool.dart new file mode 100644 index 0000000..79a2cf5 --- /dev/null +++ b/lib/common/utils/app/app_price_tool.dart @@ -0,0 +1,8 @@ +class AppPriceTool { + static const int _fractionDigits = 2; + + static String fenToYuan(num? price) { + if (price == null) return "0"; + return (price / 100).toStringAsFixed(_fractionDigits); + } +} diff --git a/lib/common/utils/app/app_runtime_tool.dart b/lib/common/utils/app/app_runtime_tool.dart new file mode 100644 index 0000000..4917696 --- /dev/null +++ b/lib/common/utils/app/app_runtime_tool.dart @@ -0,0 +1,104 @@ +import 'package:flutter/services.dart'; + +/// 当前 Android 设备的运行环境。 +class AppRuntimeEnvironment { + final bool isEmulator; + final int displayCount; + + const AppRuntimeEnvironment({ + required this.isEmulator, + required this.displayCount, + }); + + factory AppRuntimeEnvironment.fromMap(Map? data) { + return AppRuntimeEnvironment( + isEmulator: data?['isEmulator'] == true, + displayCount: (data?['displayCount'] as num?)?.toInt() ?? 1, + ); + } +} + +class AppNetworkSnapshot { + final bool connected; + final String type; + final int receivedBytes; + final int transmittedBytes; + + const AppNetworkSnapshot({ + required this.connected, + required this.type, + required this.receivedBytes, + required this.transmittedBytes, + }); + + factory AppNetworkSnapshot.fromMap(Map? data) { + return AppNetworkSnapshot( + connected: data?['connected'] == true, + type: data?['type']?.toString() ?? 'offline', + receivedBytes: (data?['rxBytes'] as num?)?.toInt() ?? 0, + transmittedBytes: (data?['txBytes'] as num?)?.toInt() ?? 0, + ); + } +} + +/// 统一管理虚拟机识别和硬件手动输入模式,避免各业务页面自行猜测设备能力。 +abstract class AppRuntimeTool { + static const MethodChannel _channel = MethodChannel( + 'biz_xfj/runtime_environment', + ); + + static AppRuntimeEnvironment _environment = const AppRuntimeEnvironment( + isEmulator: false, + displayCount: 1, + ); + static bool _initialized = false; + static bool _manualHardwareInput = false; + + static bool get isEmulator => _environment.isEmulator; + + static int get displayCount => _environment.displayCount; + + /// 虚拟机始终使用手动输入;真机可在设置中主动开启手动输入模式。 + static bool get useManualHardwareInput => isEmulator || _manualHardwareInput; + + static bool get canUsePhysicalHardware => !useManualHardwareInput; + + static bool get hasMultipleDisplays => displayCount >= 2; + + static Future initializePlatform() async { + if (_initialized) return; + try { + final data = await _channel.invokeMapMethod( + 'getRuntimeEnvironment', + ); + _environment = AppRuntimeEnvironment.fromMap(data); + } on PlatformException { + // 原生通道异常时按单屏处理,避免错误地启动副屏。 + _environment = const AppRuntimeEnvironment( + isEmulator: false, + displayCount: 1, + ); + } + _initialized = true; + } + + static Future getNetworkSnapshot() async { + try { + final data = await _channel.invokeMapMethod( + 'getNetworkSnapshot', + ); + return AppNetworkSnapshot.fromMap(data); + } on PlatformException { + return const AppNetworkSnapshot( + connected: false, + type: 'offline', + receivedBytes: 0, + transmittedBytes: 0, + ); + } + } + + static void applyManualHardwareInput(bool enabled) { + _manualHardwareInput = enabled; + } +} diff --git a/lib/common/utils/app/app_setting_tool.dart b/lib/common/utils/app/app_setting_tool.dart new file mode 100644 index 0000000..f005aae --- /dev/null +++ b/lib/common/utils/app/app_setting_tool.dart @@ -0,0 +1,287 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:common_utils/common_utils.dart'; +import 'package:get/get.dart'; +import 'package:flutter_cache_manager/flutter_cache_manager.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +abstract class AppSettingTool { + static const String _tag = "app配置工具"; + + static const String _settingKey = "setting"; + static const String _terminalMenuOrderKey = "terminalMenuOrder"; + static const String _hiddenTerminalMenusKey = "hiddenTerminalMenus"; + + // 自定义缓存管理器 + static final CacheManager _settingCache = CacheManager( + Config( + 'setting_cache', + stalePeriod: const Duration(days: 7), + maxNrOfCacheObjects: 10, + ), + ); + + static Future removeSettingCache() async { + await _settingCache.emptyCache(); + } + + static Future _getSetting() async { + // 尝试从缓存获取 + final fileInfo = await _settingCache.getFileFromCache(_settingKey); + if (fileInfo != null) { + final content = await fileInfo.file.readAsString(); + if (ObjectUtil.isNotEmpty(content)) { + return SettingEntity.fromJson(json.decode(content)); + } + } + final app = Get.find(); + final settingDao = app.dataBase.getSettingDao; + final setting = await settingDao.get(); + if (setting == null) { + final initSysInfo = SysSetting( + sysType: SysType.CANTEEN, + androidId: await AppSysTool.getAndroidId(), + sqlAddress: "", + ); + final initUserInfo = UserSetting( + username: "", + password: "", + ); + SettingEntity addObj = SettingEntity( + userJson: json.encode(initUserInfo.toJson()), + sysJson: json.encode(initSysInfo.toJson()), + ); + final addId = await settingDao.save(addObj); + if (ObjectUtil.isEmpty(addId)) { + throw Exception("|$_tag|_getSetting新增错误"); + } + addObj.id = addId; + return addObj; + } + return setting; + } + + static Future _setSetting({ + SysSetting? sysJson, + UserSetting? userJson, + CanteenExtraPriceSetting? canteenExtraPriceConfig, + }) async { + if (ObjectUtil.isEmpty(sysJson) && + ObjectUtil.isEmpty(userJson) && + ObjectUtil.isEmpty(canteenExtraPriceConfig)) { + return false; + } + final app = Get.find(); + final settingDao = app.dataBase.getSettingDao; + SettingEntity? setting = await settingDao.get(); + if (setting == null) { + logger.e("|$_tag|_setSetting新增错误settingDao.get()方法返回为null"); + return false; + } + if (sysJson != null) { + final current = Map.from(json.decode(setting.sysJson)); + // sysJson 允许保存不涉及数据库结构的终端 UI 扩展配置,更新业务设置时必须保留这些键。 + setting.sysJson = json.encode({...current, ...sysJson.toJson()}); + } + if (userJson != null) { + setting.userJson = json.encode(userJson.toJson()); + } + if (canteenExtraPriceConfig != null) { + setting.canteenExtraPriceConfigJson = json.encode( + canteenExtraPriceConfig.toJson(), + ); + } + await settingDao.edit(setting); + await _settingCache.putFile( + _settingKey, + Uint8List.fromList(utf8.encode(json.encode(setting.toJson()))), + fileExtension: 'json', + ); + return true; + } + + static Future getSysSetting() async { + final setting = await _getSetting(); + return SysSetting.fromJson(json.decode(setting.sysJson)); + } + + static Future getUserSetting() async { + final setting = await _getSetting(); + return UserSetting.fromJson(json.decode(setting.userJson)); + } + + static Future getCanteenExtraPriceConfig() async { + final setting = await _getSetting(); + if (ObjectUtil.isEmpty(setting.canteenExtraPriceConfigJson)) { + return null; + } + return CanteenExtraPriceSetting.fromJson( + json.decode(setting.canteenExtraPriceConfigJson!), + ); + } + + static Future setSysSetting(SysSetting sysInfo) async { + await _setSetting(sysJson: sysInfo); + } + + static Future setUserSetting(UserSetting userInfo) async { + final globalUserInfo = await getUserSetting(); + if (globalUserInfo == userInfo) return; + if (ObjectUtil.isEmpty(userInfo.tenantId) && + ObjectUtil.isNotEmpty(globalUserInfo.tenantId)) { + userInfo.tenantId = globalUserInfo.tenantId; + } + await _setSetting(userJson: userInfo); + } + + static Future setCanteenExtraPriceConfig( + CanteenExtraPriceSetting canteenExtraPriceConfig, + ) async { + final config = await getCanteenExtraPriceConfig(); + if (config == canteenExtraPriceConfig) return; + await _setSetting(canteenExtraPriceConfig: canteenExtraPriceConfig); + } + + /// 读取左侧菜单的顺序和隐藏状态,未知或缺失项由控制器按默认菜单补齐。 + static Future<({List order, Set hidden})> + getTerminalMenuPreferences() async { + final setting = await _getSetting(); + final sysJson = Map.from(json.decode(setting.sysJson)); + final order = (sysJson[_terminalMenuOrderKey] as List?) + ?.whereType() + .toList() ?? + const []; + final hidden = (sysJson[_hiddenTerminalMenusKey] as List?) + ?.whereType() + .toSet() ?? + const {}; + return (order: order, hidden: hidden); + } + + /// 菜单布局属于设备级 UI 设置,直接复用 setting 表中的 sysJson,避免增加数据库字段和迁移。 + static Future setTerminalMenuPreferences({ + required List order, + required Set hidden, + }) async { + final app = Get.find(); + final settingDao = app.dataBase.getSettingDao; + final setting = await settingDao.get(); + if (setting == null) { + throw StateError('终端设置尚未初始化'); + } + final sysJson = Map.from(json.decode(setting.sysJson)); + sysJson[_terminalMenuOrderKey] = order; + sysJson[_hiddenTerminalMenusKey] = hidden.toList(); + setting.sysJson = json.encode(sysJson); + await settingDao.edit(setting); + await _settingCache.putFile( + _settingKey, + Uint8List.fromList(utf8.encode(json.encode(setting.toJson()))), + fileExtension: 'json', + ); + } + + // 根据后台返回的数据更新到缓存 + static Future updateSysInfoByService(ServiceHardware data) async { + if (ObjectUtil.isEmpty(data)) { + final setting = SysSetting( + sysType: SysType.CANTEEN, + androidId: await AppSysTool.getAndroidId(), + sqlAddress: await AppFileTool.getDatabaseBackupPath(), + ); + await _setSetting(sysJson: setting); + return; + } + final sysSetting = await AppSettingTool.getSysSetting(); + final id = data.id; + if (ObjectUtil.isNotEmpty(id)) { + sysSetting.serviceId = id; + } + final name = data.name; + if (ObjectUtil.isNotEmpty(name)) { + sysSetting.serviceName = name; + } + final type = data.type.toString(); + if (ObjectUtil.isNotEmpty(type)) { + final t = SysType.values.fromCode(type); + if (ObjectUtil.isNotEmpty(t)) { + sysSetting.sysType = t!; + } + } + final merchantId = data.merchantId; + if (ObjectUtil.isNotEmpty(merchantId)) { + sysSetting.serviceMerchantId = merchantId; + } + final hardGroupId = data.hardGroupId; + if (ObjectUtil.isNotEmpty(hardGroupId)) { + sysSetting.hardGroupId = hardGroupId; + } + final merchantDeptId = data.merchantDeptId; + if (ObjectUtil.isNotEmpty(merchantDeptId)) { + sysSetting.merchantDeptId = merchantDeptId; + } + final mqClientId = data.mqClientId; + if (ObjectUtil.isNotEmpty(mqClientId)) { + sysSetting.mqClientId = mqClientId; + } + final mainTopic = data.mainTopic; + if (ObjectUtil.isNotEmpty(mainTopic)) { + sysSetting.mainTopic = mainTopic; + } + final mqSocketAddress = data.mqSocketAddress; + if (ObjectUtil.isNotEmpty(mqSocketAddress)) { + sysSetting.mqSocketAddress = mqSocketAddress; + } + final mqSocketPort = data.mqSocketPort; + if (ObjectUtil.isNotEmpty(mqSocketPort)) { + sysSetting.mqSocketPort = mqSocketPort; + } + final mqUsername = data.mqUsername; + if (ObjectUtil.isNotEmpty(mqUsername)) { + sysSetting.mqUsername = mqUsername; + } + final mqPassword = data.mqPassword; + if (ObjectUtil.isNotEmpty(mqPassword)) { + sysSetting.mqPassword = mqPassword; + } + final faceAppId = data.faceAppId; + if (ObjectUtil.isNotEmpty(faceAppId)) { + sysSetting.faceAppId = faceAppId; + } + final faceSdkKey = data.faceSdkKey; + if (ObjectUtil.isNotEmpty(faceSdkKey)) { + sysSetting.faceSdkKey = faceSdkKey; + } + final faceActiveKey = data.faceActiveKey; + if (ObjectUtil.isNotEmpty(faceActiveKey)) { + sysSetting.faceActiveKey = faceActiveKey; + } + final android = data.uniqueCode; + if (ObjectUtil.isNotEmpty(android)) { + sysSetting.androidId = android; + } + sysSetting.syncTime = DateTime.now(); + await _setSetting(sysJson: sysSetting); + } + + /// 绑定设备信息 + static Future bindSys(int serviceId) async { + var androidId = await AppSysTool.getAndroidId(); + if (ObjectUtil.isEmpty(androidId)) return false; + final pa = {"id": serviceId, "uniqueCode": androidId}; + try { + final data = await HardApi.bind(pa); + if (data == null) { + logger.e("绑定设备信息pa:|${jsonEncode(pa)}|;res:$data;"); + return false; + } + await AppSettingTool.updateSysInfoByService(data); + return true; + } catch (e, stack) { + logger.e("|$_tag|绑定信息错误error:|$e|stack:$stack"); + } + return false; + } +} diff --git a/lib/common/utils/app/app_sub_screen_tool.dart b/lib/common/utils/app/app_sub_screen_tool.dart new file mode 100644 index 0000000..e51af50 --- /dev/null +++ b/lib/common/utils/app/app_sub_screen_tool.dart @@ -0,0 +1,49 @@ +import 'package:flutter_subscreen_plugin/flutter_subscreen_plugin.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +/// 统一控制副屏,确保虚拟机和单屏设备不会创建副屏 Presentation。 +abstract class AppSubScreenTool { + static bool _available = false; + + static bool get isAvailable => _available; + + static Future refreshAvailability() async { + await AppRuntimeTool.initializePlatform(); + if (AppRuntimeTool.isEmulator || !AppRuntimeTool.hasMultipleDisplays) { + _available = false; + return false; + } + try { + final hasViceScreen = await SubScreenPlugin.isMultipleScreen; + final hasOverlayPermission = + await SubScreenPlugin.checkOverlayPermission; + _available = hasViceScreen && hasOverlayPermission; + } catch (e) { + _available = false; + logger.w("|副屏工具|检测副屏失败:$e"); + } + return _available; + } + + static Future show() async { + final available = await refreshAvailability(); + if (!available) return false; + try { + SubScreenPlugin.doubleScreenShow(); + return true; + } catch (e) { + logger.w("|副屏工具|打开副屏失败:$e"); + return false; + } + } + + static void hide() { + if (!_available) return; + try { + SubScreenPlugin.doubleScreenCancel(); + } catch (e) { + logger.w("|副屏工具|关闭副屏失败:$e"); + } + } +} diff --git a/lib/common/utils/app/app_sys_tool.dart b/lib/common/utils/app/app_sys_tool.dart new file mode 100644 index 0000000..24847e1 --- /dev/null +++ b/lib/common/utils/app/app_sys_tool.dart @@ -0,0 +1,21 @@ +import 'package:flutter/foundation.dart'; + +import 'package:android_id/android_id.dart'; + +class AppSysTool { + static const String _tag = "app系统工具"; + + static Future getAndroidId() async { + const androidIdPlugin = AndroidId(); + final String? androidId = await androidIdPlugin.getId(); + return androidId ?? ""; + } + + static bool isProd() { + return !kDebugMode; + } + + static bool isDev() { + return kDebugMode; + } +} diff --git a/lib/common/utils/app/app_user_tool.dart b/lib/common/utils/app/app_user_tool.dart new file mode 100644 index 0000000..64533d8 --- /dev/null +++ b/lib/common/utils/app/app_user_tool.dart @@ -0,0 +1,43 @@ +import 'package:common_utils/common_utils.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +abstract class AppUserTool { + // 是否登陆 + static Future isLogin() async { + final userSetting = await AppSettingTool.getUserSetting(); + final accessToken = userSetting.accessToken; + return ObjectUtil.isNotEmpty(accessToken); + } + + // 生成离线网络用户 + static ServiceUser generateOfflinePayUser({ + String userId = "-1", + String userName = "离线用户", + String cardNumber = "-1", + }) { + return ServiceUser( + userId: userId, + username: userName, + walletCardNumber: cardNumber, + userMobile: "离线用户", + ); + } + + // 生成外部用户 + static ServiceUser generateExternalUser() { + return ServiceUser( + userId: "-1", + username: "外部用户", + userMobile: "外部用户", + walletIsFreeze: null, + ); + } + + // 是否为外部用户 + static bool isGenerateExternalUser(ServiceUser user) { + return user.userId == "-1" && + user.userMobile == "外部用户" && + user.username == "外部用户"; + } +} diff --git a/lib/common/utils/app/control/app_control.dart b/lib/common/utils/app/control/app_control.dart new file mode 100644 index 0000000..bc7f73d --- /dev/null +++ b/lib/common/utils/app/control/app_control.dart @@ -0,0 +1,64 @@ +import 'dart:async'; +import 'package:background_downloader/background_downloader.dart'; +import 'package:event_bus/event_bus.dart'; +import 'package:flutter/material.dart'; +import 'package:permission_handler/permission_handler.dart'; + +import 'package:get/get.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +class AppControl extends GetxController { + static const String _tag = "AppControl"; + + /// 打印机 + late PrintManage printManage; + + /// 读卡器串口 + late SerialCardManage serialCardManage; + + /// 称串口 + late SerialSteelyardManage srialSteelyardManage; + + /// mq + StreamSubscription? mqListen; + + EventBus mqUpdateEventBus = EventBus(); + + /// 数据库 + late final AppDatabase dataBase; + + // 是否有网络 + RxBool isNetwork = RxBool(false); + + @override + void onClose() { + super.onClose(); + printManage.close(); + serialCardManage.close(); + srialSteelyardManage.close(); + AppNetworkTool.closeListenNetwork(); + TtsManage.dispose(); + SyncCheckTool.instance.dispose(); + } + + Future initAsync() async { + await AppSettingTool.removeSettingCache(); + var granted = await Permission.manageExternalStorage.request().isGranted; + if (!granted) { + granted = await Permission.storage.request().isGranted; + } + dataBase = await AppDatabase.getDataBase(); + final setting = await AppSettingTool.getSysSetting(); + AppRuntimeTool.applyManualHardwareInput(setting.manualHardwareInput); + printManage = PrintManage(); + serialCardManage = SerialCardManage(); + srialSteelyardManage = SerialSteelyardManage(); + AppNetworkTool.listenNetwork(); + await AppFileTool.backupSqlFile(); + await TtsManage.initialize(); + await Future.delayed(const Duration(milliseconds: 500)); + SyncCheckTool.instance.startAll(); + Get.offAllNamed(RouteNames.login); + } +} diff --git a/lib/common/utils/app/control/app_theme_control.dart b/lib/common/utils/app/control/app_theme_control.dart new file mode 100644 index 0000000..1f47311 --- /dev/null +++ b/lib/common/utils/app/control/app_theme_control.dart @@ -0,0 +1,59 @@ +import 'package:forui/forui.dart'; +import 'package:get/get.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +class AppThemeControl extends GetxController { + /// 主题 + Rx theme = FThemes.zinc.light.obs; + + /// 字体大小 + RxDouble sizeScalar = SettingGlobalFontSize.xl3.fontSize.obs; + + final buttonRadius = SettingButtonRadius.standard.obs; + final cardRadius = SettingCardRadius.standard.obs; + final buttonSize = SettingButtonSize.standard.obs; + + @override + void onInit() async { + super.onInit(); + } + + Future initAsync() async { + final appSetting = await AppSettingTool.getSysSetting(); + if (appSetting.globalFontSize != null) { + sizeScalar.value = appSetting.globalFontSize!.fontSize; + } + buttonRadius.value = appSetting.buttonRadius; + cardRadius.value = appSetting.cardRadius; + buttonSize.value = appSetting.buttonSize; + _applyMetrics(); + } + + void setSizeScalar(SettingGlobalFontSize fontSize) { + sizeScalar.value = fontSize.fontSize; + } + + void setButtonRadius(SettingButtonRadius value) { + buttonRadius.value = value; + _applyMetrics(); + } + + void setCardRadius(SettingCardRadius value) { + cardRadius.value = value; + _applyMetrics(); + } + + void setButtonSize(SettingButtonSize value) { + buttonSize.value = value; + _applyMetrics(); + } + + void _applyMetrics() { + XfjDesignTokens.applyThemeMetrics( + cardRadius: cardRadius.value.radius, + controlRadius: buttonRadius.value.radius, + buttonSizeScale: buttonSize.value.scale, + ); + } +} diff --git a/lib/common/utils/app/control/terminal_admin_session_control.dart b/lib/common/utils/app/control/terminal_admin_session_control.dart new file mode 100644 index 0000000..0208077 --- /dev/null +++ b/lib/common/utils/app/control/terminal_admin_session_control.dart @@ -0,0 +1,70 @@ +import 'dart:async'; + +import 'package:get/get.dart'; + +/// 管理设置与退出共用的临时解锁会话。 +/// +/// 会话只保存在当前应用进程中,并使用绝对过期时间校验,避免切页、卡顿或应用进入后台 +/// 导致简单减秒计时失真。退出登录时会主动锁定,不把管理权限带到下一次登录。 +class TerminalAdminSessionControl extends GetxController { + static const Duration sessionDuration = Duration(minutes: 5); + + final remainingSeconds = 0.obs; + + DateTime? _expiresAt; + Timer? _timer; + + bool get isUnlocked { + final expiresAt = _expiresAt; + return expiresAt != null && DateTime.now().isBefore(expiresAt); + } + + String get remainingLabel { + final seconds = remainingSeconds.value.clamp( + 0, + sessionDuration.inSeconds, + ); + final minutesPart = (seconds ~/ 60).toString().padLeft(2, '0'); + final secondsPart = (seconds % 60).toString().padLeft(2, '0'); + return '$minutesPart:$secondsPart'; + } + + void unlock() { + _expiresAt = DateTime.now().add(sessionDuration); + _syncRemainingTime(); + _timer?.cancel(); + _timer = Timer.periodic( + const Duration(seconds: 1), + (_) => _syncRemainingTime(), + ); + } + + void lock() { + _expiresAt = null; + _timer?.cancel(); + _timer = null; + if (remainingSeconds.value != 0) remainingSeconds.value = 0; + } + + void _syncRemainingTime() { + final expiresAt = _expiresAt; + if (expiresAt == null) { + lock(); + return; + } + final milliseconds = expiresAt.difference(DateTime.now()).inMilliseconds; + if (milliseconds <= 0) { + lock(); + return; + } + // 向上取整可让刚完成验证的第一帧稳定显示 05:00。 + final seconds = (milliseconds / 1000).ceil(); + if (remainingSeconds.value != seconds) remainingSeconds.value = seconds; + } + + @override + void onClose() { + _timer?.cancel(); + super.onClose(); + } +} diff --git a/lib/common/utils/app/control/terminal_menu_control.dart b/lib/common/utils/app/control/terminal_menu_control.dart new file mode 100644 index 0000000..cd7101e --- /dev/null +++ b/lib/common/utils/app/control/terminal_menu_control.dart @@ -0,0 +1,106 @@ +import 'dart:async'; + +import 'package:get/get.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +/// 管理主屏左侧菜单的顺序和显隐状态。 +/// +/// 顶栏的设置与退出是安全兜底入口,不受本控制器影响。 +class TerminalMenuControl extends GetxController { + final menuOrder = [ + ...TerminalMenuItemType.configurableValues, + ].obs; + final hiddenMenus = {}.obs; + + bool _persisting = false; + bool _pendingPersist = false; + + bool get hasVisibleMenus => + menuOrder.any(isVisible) || isVisible(TerminalMenuItemType.logout); + + bool isVisible(TerminalMenuItemType type) => !hiddenMenus.contains(type); + + Future initAsync() async { + try { + final stored = await AppSettingTool.getTerminalMenuPreferences(); + final byCode = { + for (final item in TerminalMenuItemType.values) item.code: item + }; + final restored = []; + for (final code in stored.order) { + final item = byCode[code]; + if (item != null && + item != TerminalMenuItemType.logout && + !restored.contains(item)) { + restored.add(item); + } + } + for (final item in TerminalMenuItemType.configurableValues) { + if (!restored.contains(item)) restored.add(item); + } + menuOrder.assignAll(restored); + hiddenMenus.assignAll( + stored.hidden + .map((code) => byCode[code]) + .whereType(), + ); + } catch (e, stack) { + logger.e('|左侧菜单|恢复配置失败error:|$e|stack:|$stack|'); + Toast.error(msg: '左侧菜单配置加载失败,已使用默认布局'); + } + } + + void reorder(int oldIndex, int newIndex) { + if (oldIndex == newIndex || oldIndex < 0 || oldIndex >= menuOrder.length) { + return; + } + if (newIndex > oldIndex) newIndex -= 1; + if (newIndex < 0 || newIndex >= menuOrder.length) return; + // 先在普通列表完成移动再一次性发布,避免 remove/insert 两次通知造成短暂缺项和重复 Key。 + final updated = menuOrder.toList(); + final item = updated.removeAt(oldIndex); + updated.insert(newIndex, item); + menuOrder.value = updated; + _schedulePersist(); + } + + void setVisible(TerminalMenuItemType type, bool visible) { + final updated = hiddenMenus.toSet(); + visible ? updated.remove(type) : updated.add(type); + hiddenMenus.assignAll(updated); + _schedulePersist(); + } + + /// 一次恢复默认业务菜单顺序和全部入口,退出仍按固定底部规则展示。 + void reset() { + menuOrder.assignAll(TerminalMenuItemType.configurableValues); + hiddenMenus.clear(); + _schedulePersist(); + } + + /// 保存操作串行执行但不锁住界面;连续点击或拖动时总会再保存一次最新快照。 + void _schedulePersist() { + _pendingPersist = true; + if (!_persisting) unawaited(_flushPersist()); + } + + Future _flushPersist() async { + _persisting = true; + while (_pendingPersist) { + _pendingPersist = false; + final order = menuOrder.map((item) => item.code).toList(); + final hidden = hiddenMenus.map((item) => item.code).toSet(); + try { + await AppSettingTool.setTerminalMenuPreferences( + order: order, + hidden: hidden, + ); + } catch (e, stack) { + logger.e('|左侧菜单|保存配置失败error:|$e|stack:|$stack|'); + Toast.error(msg: '左侧菜单配置保存失败,请重试'); + } + } + _persisting = false; + } +} diff --git a/lib/common/utils/app/terminal_navigation_tool.dart b/lib/common/utils/app/terminal_navigation_tool.dart new file mode 100644 index 0000000..ecd53b9 --- /dev/null +++ b/lib/common/utils/app/terminal_navigation_tool.dart @@ -0,0 +1,85 @@ +import 'package:common_utils/common_utils.dart'; +import 'package:get/get.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +/// 新终端侧边栏的统一导航守卫。 +/// +/// 统一终端导航仍需保留设备绑定、联网限制和设置密码校验, +/// 否则视觉重构会意外放开原有业务入口。 +abstract final class TerminalNavigationTool { + static bool _verificationInProgress = false; + + static Future open(String route) async { + if (route == RouteNames.setting) { + if (!await _verifyAdminSession(actionLabel: '进入设置')) return; + // 一级菜单重建为单一路由,避免连续替换时 Navigator 短暂没有可展示页面。 + Get.offAllNamed(route); + return; + } + + if (!await _checkIsBind()) return; + if (_requiresNetwork(route) && !AppNetworkTool.isAutoNetwork()) { + Toast.error(msg: '请先连接网络'); + return; + } + Get.offAllNamed(route); + } + + /// 退出前统一执行管理密码校验,避免侧栏与顶栏出口出现不同的安全规则。 + static Future logout() async { + if (!await _verifyAdminSession(actionLabel: '退出登录')) return; + Get.find().lock(); + if (AppNetworkTool.isAutoNetwork()) { + await AppSettingTool.setUserSetting(UserSetting()); + } + Get.offAllNamed(RouteNames.login); + } + + /// 顶栏锁状态可主动发起验证,不需要先进入设置或退出流程。 + static Future unlockAdminSession() async { + await _verifyAdminSession(actionLabel: '解锁管理操作'); + } + + static Future _verifyAdminSession({ + required String actionLabel, + }) async { + final session = Get.find(); + if (session.isUnlocked) return true; + session.lock(); + // 快速连续点击设置与退出时只允许存在一个密码弹层,避免重复 Overlay 和 GlobalKey。 + if (_verificationInProgress) return false; + final context = Get.context; + if (context == null) return false; + + _verificationInProgress = true; + try { + final verified = await terminalAdminPasswordDialog( + context, + actionLabel: actionLabel, + ); + // 弹层取消与验证成功都要等退场结束,避免快速再次点击叠加两个 Overlay。 + await Future.delayed(const Duration(milliseconds: 200)); + if (!verified) return false; + session.unlock(); + return true; + } finally { + _verificationInProgress = false; + } + } + + static bool _requiresNetwork(String route) { + return route == RouteNames.canteenReserve || + route == RouteNames.takeFood || + route == RouteNames.canteenOrderTotal; + } + + static Future _checkIsBind() async { + final setting = await AppSettingTool.getSysSetting(); + if (ObjectUtil.isEmpty(setting.serviceMerchantId)) { + Toast.error(msg: '请前往设置绑定设备'); + return false; + } + return true; + } +} diff --git a/lib/common/utils/discounts/discounts_tool.dart b/lib/common/utils/discounts/discounts_tool.dart new file mode 100644 index 0000000..eae31c4 --- /dev/null +++ b/lib/common/utils/discounts/discounts_tool.dart @@ -0,0 +1 @@ +class DiscountsTool {} diff --git a/lib/common/utils/index.dart b/lib/common/utils/index.dart new file mode 100644 index 0000000..ff634ea --- /dev/null +++ b/lib/common/utils/index.dart @@ -0,0 +1,52 @@ +library; + +export './app/control/app_control.dart'; +export './app/control/app_theme_control.dart'; +export './app/control/terminal_admin_session_control.dart'; +export './app/control/terminal_menu_control.dart'; + +export './app/app_face_tool.dart'; +export './app/app_file_tool.dart'; +export './app/app_mq_tool.dart'; +export './app/app_network_tool.dart'; +export './app/app_price_tool.dart'; +export './app/app_runtime_tool.dart'; +export './app/app_setting_tool.dart'; +export './app/app_sub_screen_tool.dart'; +export './app/app_sys_tool.dart'; +export './app/app_user_tool.dart'; +export './app/terminal_navigation_tool.dart'; + +export './discounts/discounts_tool.dart'; + +export './json/json_tool.dart'; + +export './keyboard/keyboard_tool.dart'; + +export './logger/logger_tool.dart'; + +export './manage/barcode_scan_manage.dart'; +export './manage/cash_box_manage.dart'; +export './manage/base/serial_base.dart'; +export './manage/base/base_facility_manage.dart'; +export './manage/tool/serial_read_card_tool.dart'; +export './manage/tool/serial_tool.dart'; +export './manage/http_manage.dart'; +export './manage/mq_manager.dart'; +export './manage/print_manage.dart'; +export './manage/serial_card_manage.dart'; +export './manage/serial_steelyard_manage.dart'; +export './manage/tts_manage.dart'; + +export './plugs/face_discern_plugs.dart'; + +export './router/router_observer_tool.dart'; + +export './sync/sync_check_tool.dart'; +export './sync/sync_config_tool.dart'; +export './sync/sync_face_tool.dart'; +export './sync/sync_order_tool.dart'; +export './sync/sync_print_tool.dart'; +export './sync/sync_product_tool.dart'; +export './sync/sync_take_food_broadcast_tool.dart'; +export './sync/sync_user_tool.dart'; diff --git a/lib/common/utils/json/json_tool.dart b/lib/common/utils/json/json_tool.dart new file mode 100644 index 0000000..c3bb5fd --- /dev/null +++ b/lib/common/utils/json/json_tool.dart @@ -0,0 +1,98 @@ +import 'package:pj_supermarket_cloud/common/index.dart'; + +abstract class JsonTool { + static bool stringToBoolean(String? value) { + if (value == null) return false; + return value == '1'; + } + + static String? booleanToString(bool? value) { + if (value == null) return null; + return value ? '1' : '0'; + } + + static int? stringToInt(String? value) { + if (value == null) return null; + return int.tryParse(value); + } + + static String? intToString(int? value) { + if (value == null) return null; + return value.toString(); + } + + static String? anyToString(dynamic value) { + if (value == null) return null; + return value.toString(); + } + + static Map? anyToMap(dynamic value) { + if (value == null) return null; + return dynamic as Map?; + } + + static OrderType? stringToOrderType(String? value) { + final byCode = OrderType.values.fromCode(value); + if (byCode != null) return byCode; + if (value == null) return null; + final name = value.contains('.') ? value.split('.').last : value; + return OrderType.values.byName(name); + } + + static String? orderTypeToString(OrderType? value) { + if (value == null) return null; + return value.code; + } + + static OrderPayType? stringToOrderPayType(String? value) { + final byCode = OrderPayType.values.fromCode(value); + if (byCode != null) return byCode; + if (value == null) return null; + final name = value.contains('.') ? value.split('.').last : value; + return OrderPayType.values.byName(name); + } + + static String? orderPayTypeToString(OrderPayType? value) { + if (value == null) return null; + return value.code; + } + + static WalletActType? stringToWalletActType(String? value) { + final byCode = WalletActType.values.fromCode(value); + if (byCode != null) return byCode; + if (value == null) return null; + final name = value.contains('.') ? value.split('.').last : value; + return WalletActType.values.byName(name); + } + + static String? walletActTypeToString(WalletActType? value) { + if (value == null) return null; + return value.code; + } + + static DiscountsType? stringToDiscountsType(String? value) { + final byCode = DiscountsType.values.fromCode(value); + if (byCode != null) return byCode; + if (value == null) return null; + final name = value.contains('.') ? value.split('.').last : value; + return DiscountsType.values.byName(name); + } + + static String? discountsTypeToString(DiscountsType? value) { + if (value == null) return null; + return value.code; + } + + static ProductHourType? stringToProductHourType(String? value) { + final byCode = ProductHourType.values.fromCode(value); + if (byCode != null) return byCode; + if (value == null) return null; + final name = value.contains('.') ? value.split('.').last : value; + return ProductHourType.values.byName(name); + } + + static String? productHourTypeToString(ProductHourType? value) { + if (value == null) return null; + return value.code; + } +} diff --git a/lib/common/utils/keyboard/keyboard_tool.dart b/lib/common/utils/keyboard/keyboard_tool.dart new file mode 100644 index 0000000..156cab9 --- /dev/null +++ b/lib/common/utils/keyboard/keyboard_tool.dart @@ -0,0 +1,13 @@ +import 'package:common_utils/common_utils.dart'; +import 'package:flutter/services.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +class KeywordTool { + static Future copy(String? text) async { + if (ObjectUtil.isEmpty(text)) return; + Clipboard.setData(ClipboardData(text: text!)).then( + (v) => Toast.success(msg: "复制成功"), + ); + } +} diff --git a/lib/common/utils/logger/logger_tool.dart b/lib/common/utils/logger/logger_tool.dart new file mode 100644 index 0000000..349261a --- /dev/null +++ b/lib/common/utils/logger/logger_tool.dart @@ -0,0 +1,83 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:common_utils/common_utils.dart'; +import 'package:logger/logger.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +final logger = Logger( + printer: customPrinter, + output: ConsoleOutput(), +); +final customPrinter = PrettyPrinter( + lineLength: 0, + methodCount: 1, + errorMethodCount: 1, + colors: true, + printEmojis: false, + printTime: true, +); + +class ConsoleOutput extends LogOutput { + String? _filePath; + IOSink? _sink; + Timer? _timer; + + @override + Future init() async { + try { + // 关闭旧的sink并创建新的 + await _sink?.flush(); + await _sink?.close(); + _filePath = await AppFileTool.getLoggerBackupPath(); + if (ObjectUtil.isNotEmpty(_filePath)) { + _sink = File(_filePath!).openWrite( + mode: FileMode.writeOnlyAppend, + encoding: utf8, + ); + } + _timer = Timer.periodic(const Duration(seconds: 60 * 60 * 2), + (Timer timer) async { + final path = await AppFileTool.getLoggerBackupPath(); + if (path == null) return; + // 关闭旧的sink并创建新的 + await _sink?.flush(); + await _sink?.close(); + _filePath = path; + _sink = File(_filePath!).openWrite( + mode: FileMode.writeOnlyAppend, + encoding: utf8, + ); + }); + } catch (e, stack) { + print("初始化日志错误error:|$e|stack:|$stack|"); + } + } + + @override + Future destroy() async { + await _sink?.flush(); + await _sink?.close(); + _timer?.cancel(); + } + + @override + void output(OutputEvent event) { + try { + if (AppSysTool.isDev()) { + for (final v in event.lines) { + print(v); + } + } else { + if (_filePath == null) return; + if (_sink == null) return; + if (Level.debug == event.level) return; + _sink?.writeAll(event.lines, '\n'); + } + } catch (e, stack) { + print("写入日志错误error:|$e|stack:|$stack|"); + } + } +} diff --git a/lib/common/utils/manage/barcode_scan_manage.dart b/lib/common/utils/manage/barcode_scan_manage.dart new file mode 100644 index 0000000..8077674 --- /dev/null +++ b/lib/common/utils/manage/barcode_scan_manage.dart @@ -0,0 +1,244 @@ +import 'dart:async'; +import 'dart:convert'; +import 'package:common_utils/common_utils.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +class BarcodeScanManage { + static const String _tag = "条码扫描管理"; + + BarcodeScanManage._privateConstructor(); + + static final BarcodeScanManage _instance = + BarcodeScanManage._privateConstructor(); + + static BarcodeScanManage get instance => _instance; + + // 添加白名单焦点节点列表 + final Set _whitelistNodes = {}; + bool _isListening = false; + final _buffer = StringBuffer(); + Timer? _timer; + + // 二维码扫码结果 + StreamController? _qrcodeScanController; + Stream get qrcodeScanStream { + _qrcodeScanController ??= StreamController.broadcast(); + return _qrcodeScanController!.stream; + } + + // 扫码枪扫码结果 + StreamController? _gunScanController; + Stream get gunScanStream { + _gunScanController ??= StreamController.broadcast(); + return _gunScanController!.stream; + } + + // 添加白名单焦点 + void addWhitelistNode(FocusNode node) { + _whitelistNodes.add(node); + } + + // 移除白名单焦点 + void removeWhitelistNode(FocusNode node) { + _whitelistNodes.remove(node); + } + + // 开始监听USB读头输入 + void startListening() { + if (_isListening) return; + if (_qrcodeScanController?.isClosed ?? true) { + _qrcodeScanController = StreamController.broadcast(); + } + if (_gunScanController?.isClosed ?? true) { + _gunScanController = StreamController.broadcast(); + } + if (AppRuntimeTool.useManualHardwareInput) return; + _isListening = true; + // 只有当当前焦点不在白名单中时才取消焦点 + final currentFocus = FocusManager.instance.primaryFocus; + if (currentFocus != null && !_whitelistNodes.contains(currentFocus)) { + currentFocus.unfocus(); + } + HardwareKeyboard.instance.addHandler(_handleKeyEvent); + } + + /// 人工提交二维码结果,继续复用真实扫码后的业务订阅。 + void addManualQrCode(String code) { + if (code.trim().isEmpty) return; + _qrcodeScanController ??= StreamController.broadcast(); + _qrcodeScanController?.add(code.trim()); + } + + /// 人工提交商品条码或付款码,继续复用扫码枪业务订阅。 + void addManualGunCode(String code) { + if (code.trim().isEmpty) return; + _gunScanController ??= StreamController.broadcast(); + _gunScanController?.add(code.trim()); + } + + // 停止监听 + void stopListening() { + if (!_isListening) return; + HardwareKeyboard.instance.removeHandler(_handleKeyEvent); + _timer?.cancel(); + _isListening = false; + logger.i("|$_tag|停止监听USB读头输入"); + } + + // 处理键盘事件 + bool _handleKeyEvent(KeyEvent event) { + final currentFocus = FocusManager.instance.primaryFocus; + if (currentFocus != null && _whitelistNodes.contains(currentFocus)) { + return false; + } + FocusManager.instance.primaryFocus?.unfocus(); + if (event is! KeyDownEvent) return false; + try { + _timer?.cancel(); + // 处理回车键,表示扫码结束 + if (event.logicalKey.keyId == 0x10000000d) { + if (_buffer.isNotEmpty) { + final result = _buffer.toString(); + logger.i("|$_tag|扫码结果: $result"); + result.length == 32 + ? _qrcodeScanController?.add(result) + : _gunScanController?.add(result); + _buffer.clear(); + return true; + } + } + final character = _getCharacter(event); + if (character != null) { + _buffer.write(character); + // 设置超时定时器,如果超过一定时间没有新输入,则清空缓冲区 + _timer = Timer(Duration(milliseconds: 100), () { + if (_buffer.isNotEmpty) { + _buffer.clear(); + } + }); + return true; + } + return false; + } catch (e, stack) { + logger.e("|$_tag|处理键盘事件错误error|$e|stack|$stack|"); + _buffer.clear(); + _timer?.cancel(); + return false; + } + } + + // 从键盘事件中获取字符 + String? _getCharacter(KeyEvent event) { + // 处理数字键 + if (event.logicalKey.keyId >= LogicalKeyboardKey.digit0.keyId && + event.logicalKey.keyId <= LogicalKeyboardKey.digit9.keyId) { + return String.fromCharCode( + event.logicalKey.keyId - LogicalKeyboardKey.digit0.keyId + 48); + } + // 处理小写字母键 + if (event.logicalKey.keyId >= LogicalKeyboardKey.keyA.keyId && + event.logicalKey.keyId <= LogicalKeyboardKey.keyZ.keyId) { + final isShiftPressed = HardwareKeyboard.instance.isShiftPressed; + final charCode = event.logicalKey.keyId - + LogicalKeyboardKey.keyA.keyId + + (isShiftPressed ? 65 : 97); + return String.fromCharCode(charCode); + } + // 处理其他常见字符 + switch (event.logicalKey) { + case LogicalKeyboardKey.minus: + return HardwareKeyboard.instance.isShiftPressed ? '_' : '-'; + case LogicalKeyboardKey.equal: + return HardwareKeyboard.instance.isShiftPressed ? '+' : '='; + case LogicalKeyboardKey.bracketLeft: + return '['; + case LogicalKeyboardKey.bracketRight: + return ']'; + case LogicalKeyboardKey.backslash: + return '\\'; + case LogicalKeyboardKey.semicolon: + return ';'; + case LogicalKeyboardKey.quote: + return '\''; + case LogicalKeyboardKey.comma: + return ','; + case LogicalKeyboardKey.period: + return '.'; + case LogicalKeyboardKey.slash: + return HardwareKeyboard.instance.isShiftPressed ? '?' : '/'; + case LogicalKeyboardKey.add: + return '+'; + case LogicalKeyboardKey.underscore: + return '_'; + default: + return null; + } + } + + // 尝试解码 base64 字符串 + Future getCodeInfo(String str) async { + try { + // 根据短码查询 + final pa = {"shortCode": str}; + final encryptString = await WalletApi.getByShortCode(pa); + if (ObjectUtil.isEmpty(encryptString)) { + logger.e("|$_tag|getByShortCode错误shortCode|$str|"); + await TtsManage.speak("扫码失败请重试"); + return null; + } + final json = EncryptUtil.decodeBase64(encryptString!); + if (ObjectUtil.isEmpty(json)) { + logger.e("|$_tag|二维码base64解析错误|$json|"); + await TtsManage.speak("扫码失败请重试"); + return null; + } + final qrCode = ServiceQRCode.fromJson(jsonDecode(json)); + if (ObjectUtil.isEmpty(qrCode)) { + logger.e("|$_tag|无效的二维码,json|$json|"); + await TtsManage.speak("无效的二维码"); + return null; + } + // 支付方式 + if (ObjectUtil.isEmpty(qrCode.payType) || + ObjectUtil.isEmpty(qrCode.orderType)) { + logger.e("|$_tag|无效的二维码,支付方式或者订单类型错误,json|$json|"); + await TtsManage.speak("无效的二维码"); + return null; + } + TtsManage.speak("扫码成功"); + return qrCode; + } catch (e) { + logger.e("|$_tag|二维码解析意外的错误: $e"); + await TtsManage.speak("扫码失败请重试"); + return null; + } + } + + // 尝试解析 JSON + Map? parseJson(String jsonStr) { + try { + return json.decode(jsonStr) as Map; + } catch (e) { + logger.e("|$_tag|JSON解析错误: $e"); + return null; + } + } + + // 释放资源 + void dispose() { + stopListening(); + _qrcodeScanController?.close(); + _gunScanController?.close(); + } + + void disposeQrCodeScan() { + _qrcodeScanController?.close(); + } + + void disposeGunScan() { + _gunScanController?.close(); + } +} diff --git a/lib/common/utils/manage/base/base_facility_manage.dart b/lib/common/utils/manage/base/base_facility_manage.dart new file mode 100644 index 0000000..293fd7c --- /dev/null +++ b/lib/common/utils/manage/base/base_facility_manage.dart @@ -0,0 +1,63 @@ +import 'package:pj_supermarket_cloud/common/index.dart'; + +class BaseFacilityManage { + bool isPermission = false; + int applyPermissionCount = 0; + int maxApplyPermissionCount = 1; + + String setName() { + return "未知"; + } + + // 打开设备 + Future open() async { + isPermission = await checkPermission(); + if (!isPermission) { + logger.i("usb设备设备|${setName()}|的权限错误"); + await _applyPermission(); + if (!isPermission) { + return false; + } + } + return true; + } + + // 关闭设备 + Future close() async {} + + // 检查权限 + Future checkPermission() async { + return false; + } + + // 监听 + void listen(void Function(T event)? fc) {} + + // 申请权限 + Future applyPermission() async { + return false; + } + + // 申请权限 + Future _applyPermission() async { + logger.i("尝试申请权限,申请次数上线|$maxApplyPermissionCount|"); + while (true) { + if (applyPermissionCount >= maxApplyPermissionCount) { + applyPermissionCount = 0; + break; + } + final flag = await applyPermission(); + final str = + "尝试申请权限,第|$applyPermissionCount|次,申请次数上线|$maxApplyPermissionCount|,申请${flag ? "成功" : "失败"}"; + logger.i(str); + if (isPermission != flag) { + isPermission = flag; + } + if (flag) { + applyPermissionCount = 0; + break; + } + applyPermissionCount++; + } + } +} diff --git a/lib/common/utils/manage/base/serial_base.dart b/lib/common/utils/manage/base/serial_base.dart new file mode 100644 index 0000000..edb56d0 --- /dev/null +++ b/lib/common/utils/manage/base/serial_base.dart @@ -0,0 +1,83 @@ +import 'package:common_utils/common_utils.dart'; +import 'package:flutter_libserialport/flutter_libserialport.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +class SerialBase { + String _name = "未知"; + String _portName = ""; + int _baudRate = 9600; + int _readTimeout = 500; + + late SerialPort serialClient; + SerialPortReader? readPort; + + bool isConnect = false; + + set name(String name) { + _name = name; + } + + set portName(String portName) { + _portName = portName; + } + + set baudRate(int baudRate) { + _baudRate = baudRate; + } + + set readTimeout(int readTimeout) { + _readTimeout = readTimeout; + } + + Future connect() async { + try { + if (ObjectUtil.isEmpty(_portName)) return; + if (isConnect) return; + serialClient = SerialPort(_portName); + if (serialClient.isOpen) { + serialClient.close(); + } + bool isOpenRead = serialClient.openReadWrite(); + if (isOpenRead && serialClient.isOpen) { + SerialPortConfig config = serialClient.config; + config.baudRate = _baudRate; + serialClient.config = config; + isConnect = true; + logger.d(logMsg("连接成功")); + } + } catch (e) { + logger.e(logMsg("连接失败error:|$e|")); + } + } + + Future listen(Function(List) fc) async { + if (isConnect == false) return; + readPort = SerialPortReader(serialClient, timeout: _readTimeout); + if (ObjectUtil.isEmpty(readPort)) return; + logger.d(logMsg("开始监听串口数据")); + readPort!.stream.listen((event) async { + // logger.w(logMsg("监听串口数据:event:|$event|string:${bytesToString(event)}")); + fc(event); + }); + } + + void readerClose() { + if (ObjectUtil.isNotEmpty(readPort)) { + readPort!.close(); + readPort = null; + } + } + + void close() { + if (isConnect == true) { + serialClient.close(); + serialClient.dispose(); + isConnect = false; + } + } + + String logMsg(String msg) { + return "串口设备|$_name|$msg"; + } +} diff --git a/lib/common/utils/manage/cash_box_manage.dart b/lib/common/utils/manage/cash_box_manage.dart new file mode 100644 index 0000000..5696cf9 --- /dev/null +++ b/lib/common/utils/manage/cash_box_manage.dart @@ -0,0 +1,74 @@ +import 'dart:io'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +class CashBoxManage { + static const String _tag = "钱箱管理"; + + // 钱箱端口定义 + static const int portCT1 = 237; + static const int portCT2 = 227; + static const int portDET = 231; + + // 打开钱箱 CT1 + static Future openCT1() async { + try { + // 设置 GPIO 导出 + await _executeCommand('echo $portCT1 > /sys/class/gpio/export'); + // 设置输出方向 + await _executeCommand( + 'echo out > /sys/class/gpio/gpio$portCT1/direction', + ); + // 写入值 + await _executeCommand('echo 1 > /sys/class/gpio/gpio$portCT1/value'); + return true; + } catch (e) { + print('$_tag 打开钱箱CT1失败: $e'); + return false; + } + } + + // 打开钱箱 CT2 + static Future openCT2() async { + try { + await _executeCommand( + 'echo $portCT2 > /sys/class/gpio/export', + ); + await _executeCommand( + 'echo out > /sys/class/gpio/gpio$portCT2/direction', + ); + await _executeCommand( + 'echo 1 > /sys/class/gpio/gpio$portCT2/value', + ); + return true; + } catch (e) { + logger.e('|$_tag|打开钱箱CT2失败error|$e|'); + return false; + } + } + + // 检查钱箱状态 + static Future checkStatus() async { + try { + await _executeCommand( + 'echo $portDET > /sys/class/gpio/export', + ); + // 读取状态 + final result = await Process.run( + 'cat', + ['/sys/class/gpio/gpio$portDET/value'], + ); + return result.stdout.toString().trim() == '1'; + } catch (e) { + logger.e('|$_tag|检查钱箱状态失败error|$e|'); + return false; + } + } + + static Future _executeCommand(String command) async { + final result = await Process.run('sh', [command]); + if (result.exitCode != 0) { + throw Exception('Command failed: ${result.stderr}'); + } + } +} diff --git a/lib/common/utils/manage/http_manage.dart b/lib/common/utils/manage/http_manage.dart new file mode 100644 index 0000000..ca28c61 --- /dev/null +++ b/lib/common/utils/manage/http_manage.dart @@ -0,0 +1,170 @@ +import 'dart:io'; + +import 'package:common_utils/common_utils.dart'; +import 'package:dio/dio.dart'; +import 'package:dio/io.dart'; +import 'package:flutter_easyloading/flutter_easyloading.dart'; +import 'package:get/get.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +const ignoreMsg = ["账号未登录"]; + +class HttpManage { + static const _tag = "http管理"; + + static final HttpManage _instance = HttpManage._internal(); + + factory HttpManage() => _instance; + + late Dio dio; + + HttpManage._internal() { + BaseOptions globalOptions = BaseOptions( + baseUrl: Constants.apiUrl, + connectTimeout: const Duration(seconds: 10), + responseType: ResponseType.json, + ); + dio = Dio(globalOptions); + // 拦截 + dio.interceptors.add( + InterceptorsWrapper( + onRequest: (RequestOptions options, handler) async { + options.headers["CLIENT-TOC"] = "Y"; + final userInfo = await AppSettingTool.getUserSetting(); + if (!options.path.contains(Constants.oauth2AppApiUrl)) { + if (ObjectUtil.isNotEmpty(userInfo.accessToken)) { + options.headers['Authorization'] = + "Bearer ${userInfo.accessToken}"; + } + } + options.headers["TENANT-ID"] = userInfo.tenantId ?? 1; + return handler.next(options); + }, + onResponse: (e, handler) async { + // 先判断是不是登录接口 + late ServiceBase serviceData; + if (e.requestOptions.path.contains(Constants.oauth2AppApiUrl)) { + serviceData = ServiceBase(msg: "", code: 0, data: e.data); + } else { + serviceData = ServiceBase.fromJson(e.data); + } + if (serviceData.code == 401 && + ObjectUtil.isNotEmpty(serviceData.msg)) { + if (!ignoreMsg.contains(serviceData.msg)) { + Toast.error(msg: serviceData.msg ?? "服务器未知错误"); + } + final isLogin = await _autoLogin(); + if (!isLogin) { + Get.offAndToNamed(RouteNames.login); + } + } else if (serviceData.code != 0) { + Toast.error(msg: serviceData.msg ?? "服务器未知错误"); + } + // 续一下token + if (!e.realUri.path.contains("refresh-token")) { + // _refreshToken(); + } + e.data = serviceData.data; + handler.next(e); + }, + onError: (e, handler) async { + final response = e.response; + final statusCode = response?.statusCode; + if (statusCode != null) { + if (statusCode == 424) { + final isLogin = await _autoLogin(); + if (!isLogin) { + Get.offAndToNamed(RouteNames.login); + } + handler.next(e); + } + } + if (response != null && response.data != null) { + ServiceBase data = ServiceBase.fromJson(response.data); + if (data.code != 0) { + Toast.error(msg: data.msg ?? "服务器未知错误"); + EasyLoading.dismiss(); + handler.next(e); + return; + } + } + return handler.next(e); + }, + ), + ); + + dio.httpClientAdapter = IOHttpClientAdapter( + createHttpClient: () { + final HttpClient client = + HttpClient(context: SecurityContext(withTrustedRoots: false)); + client.badCertificateCallback = (cert, host, port) => true; + return client; + }, + ); + } + + Future _autoLogin() async { + final userSetting = await AppSettingTool.getUserSetting(); + final sysSetting = await AppSettingTool.getSysSetting(); + if (ObjectUtil.isEmpty(userSetting.username) || + ObjectUtil.isEmpty(userSetting.password) || + ObjectUtil.isEmpty(userSetting.accessToken)) { + return false; + } + final pa = { + 'username': userSetting.username, + 'password': userSetting.password, + 'sysType': sysSetting.sysType.name, + 'grant_type': 'password', + 'scope': 'server', + }; + try { + final data = await UserApi.login(pa); + if (data != null) throw Error(); + userSetting.userId = data['user_id']; + userSetting.accessToken = data['access_token']; + userSetting.refreshToken = data['refresh_token']; + userSetting.expiresTime = int.tryParse("${data['exp']}"); + await AppSettingTool.setUserSetting(userSetting); + return true; + } catch (e) { + await AppSettingTool.setUserSetting(UserSetting()); + logger.e("|$_tag|自动登录意外的错误error:|$e|"); + } + return false; + } + + Future _refreshToken() async { + final userSetting = await AppSettingTool.getUserSetting(); + if (ObjectUtil.isEmpty(userSetting.refreshToken) || + ObjectUtil.isEmpty(userSetting.expiresTime)) { + return; + } + final now = DateTime.now().millisecondsSinceEpoch; + final remain = userSetting.expiresTime! - now; + logger.i("|$_tag|token还有${remain / 1000 / 60}分钟${remain / 1000}秒过期"); + if (remain > 1000 * 60 * 20) return; + final pa = {"refreshToken": userSetting.refreshToken}; + try { + final res = await UserApi.refreshToken(pa); + if (res.code != 0) throw Error(); + final data = res.data as ServiceAuth; + if (ObjectUtil.isNotEmpty(data.userId)) { + userSetting.userId = data.userId; + } + if (ObjectUtil.isNotEmpty(data.accessToken)) { + userSetting.accessToken = data.accessToken; + } + if (ObjectUtil.isNotEmpty(data.refreshToken)) { + userSetting.refreshToken = data.refreshToken; + } + if (ObjectUtil.isNotEmpty(data.expiresTime)) { + userSetting.expiresTime = data.expiresTime; + } + await AppSettingTool.setUserSetting(userSetting); + } catch (e) { + logger.e("|$_tag|续存token意外的错误error:|$e|"); + } + } +} diff --git a/lib/common/utils/manage/mq_manager.dart b/lib/common/utils/manage/mq_manager.dart new file mode 100644 index 0000000..dbc834e --- /dev/null +++ b/lib/common/utils/manage/mq_manager.dart @@ -0,0 +1,159 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:common_utils/common_utils.dart'; +import 'package:flutter/services.dart'; +import 'package:mqtt_client/mqtt_client.dart'; +import 'package:mqtt_client/mqtt_server_client.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +/// mq +class MqManager { + static const String _tag = "mq管理"; + + static MqManager? _instance; + + // mq对象 + static MqttServerClient? mqttClient; + static MqttConnectionState state = MqttConnectionState.disconnected; + + // 当前设备的 topic + static late String mainTopic; + + MqManager._internal(); + + factory MqManager() { + _instance ??= MqManager._internal(); + return _instance!; + } + + static Future validate() async { + final config = await AppSettingTool.getSysSetting(); + logger.i("|$_tag||刷新mq|校验配置参数:config:|${json.encode(config)}|"); + if (ObjectUtil.isEmpty(config)) return null; + if (ObjectUtil.isEmpty(config.mqUsername)) return null; + if (ObjectUtil.isEmpty(config.mqPassword)) return null; + if (ObjectUtil.isEmpty(config.mqClientId)) return null; + if (ObjectUtil.isEmpty(config.mqSocketAddress)) return null; + if (ObjectUtil.isEmpty(config.mqSocketPort)) return null; + if (ObjectUtil.isEmpty(config.mainTopic)) return null; + return config; + } + + static Future closeMqtt() async { + if (MqManager.mqttClient != null) { + MqManager.mqttClient?.disconnect(); + MqManager.mqttClient = null; + } + MqManager.state = MqttConnectionState.disconnected; + } + + static Future initMqtt() async { + // 获取配置 + final config = await validate(); + if (ObjectUtil.isEmpty(config!)) return; + final port = int.tryParse(config.mqSocketPort!); + if (ObjectUtil.isEmpty(port)) return; + final client = MqttServerClient.withPort( + config.mqSocketAddress!, + config.mqClientId!, + port!, + ); + mqttClient = client; + + if (port == 8883) { + final context = SecurityContext.defaultContext; + final byte = await rootBundle.load(AssetsCrt.emqxCa); + context.setClientAuthoritiesBytes(byte.buffer.asUint8List()); + client.securityContext = context; + client.secure = true; + } + + client.onBadCertificate = (Object a) => true; + client.autoReconnect = false; + client.logging(on: false); + client.keepAlivePeriod = 60; + client.onConnected = _onConnected; + client.onDisconnected = _onDisconnected; + client.onSubscribed = _onSubscribed; + client.pongCallback = _pongCallback; + client.onAutoReconnect = _onAutoReconnect; + + final connMessage = + MqttConnectMessage().withProtocolName("MQTT").withProtocolVersion(4); + client.connectionMessage = connMessage; + + try { + final state = await client.connect(config.mqUsername, config.mqPassword); + if (ObjectUtil.isNotEmpty(state)) { + MqManager.state = state!.state; + if (ObjectUtil.isNotEmpty(MqManager.state)) { + if (MqManager.state == MqttConnectionState.connected) {} + } + } + } catch (e) { + logger.e("|$_tag|mqtt连接失败error:|$e|"); + } + } + + static Future refresh() async { + final config = await validate(); + if (ObjectUtil.isEmpty(config)) { + logger.e("|$_tag||刷新mq|错误mq参数校验不通过"); + return; + } + if (ObjectUtil.isEmpty(mqttClient)) { + await initMqtt(); + } else if (MqManager.state != MqttConnectionState.connected) { + final res = await mqttClient!.connect( + config!.mqUsername, + config.mqPassword, + ); + if (res != null && res.state != MqManager.state) { + MqManager.state = res.state; + } + } else { + logger.e("|$_tag||刷新mq|错误mq已连接"); + } + } + + static Future subscribeCurrentTopic() async { + if (ObjectUtil.isEmpty(mqttClient)) return; + final config = await validate(); + if (ObjectUtil.isEmpty(config)) return; + if (ObjectUtil.isEmpty(config?.mainTopic)) return; + mqttClient!.subscribe(config!.mainTopic!, MqttQos.atMostOnce); + } + + static void _onConnected() async { + logger.w("|$_tag|mqtt连接成功"); + final config = await AppSettingTool.getSysSetting(); + if (ObjectUtil.isNotEmpty(config)) { + config.mqSyncTime = DateTime.now(); + await AppSettingTool.setSysSetting(config); + } + await subscribeCurrentTopic(); + } + + static void _onDisconnected() async { + logger.w("|$_tag|mqtt断开链接"); + AppMqTool.mqListenClose(); + state = MqttConnectionState.disconnected; + } + + static void _onSubscribed(String topic) { + logger.w('|$_tag|mqtt 订阅主题成功:$topic'); + mainTopic = topic; + } + + static void _pongCallback() { + logger.w("|$_tag|mqtt ping"); + } + + // 自动重连接 + static void _onAutoReconnect() async { + logger.i("|$_tag|启动自动重连"); + await refresh(); + } +} diff --git a/lib/common/utils/manage/print_manage.dart b/lib/common/utils/manage/print_manage.dart new file mode 100644 index 0000000..02ca50e --- /dev/null +++ b/lib/common/utils/manage/print_manage.dart @@ -0,0 +1,95 @@ +import 'package:common_utils/common_utils.dart'; +import 'package:drago_pos_printer/drago_pos_printer.dart'; +import 'package:get/get.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +class PrintManage extends BaseFacilityManage { + static const String _tag = "打印机管理"; + + USBPrinterManager? manager; + CapabilityProfile? profile; + int paperSizeWidthMM = PaperSizeWidth.mm80; + int maxPerLine = PaperSizeMaxPerLine.mm80; + + static const List _printVendorIds = [ + "19267", + "4070", + "10473", + ]; + + @override + String setName() => UsbFacilityNames.print.title; + + @override + Future open() async { + final check = await super.open(); + if (!check) return false; + final usbPrinter = await USBPrinterManager.discover(); + USBPrinter? target = usbPrinter.firstWhereOrNull((v) { + return _printVendorIds.contains(v.vendorId ?? "-1"); + }); + if (ObjectUtil.isEmpty(target)) { + if (usbPrinter.length == 1) { + target = usbPrinter[0]; + } + } + if (ObjectUtil.isEmpty(target)) return false; + profile = await CapabilityProfile.load(); + manager = USBPrinterManager(target!); + try { + await manager?.connect(); + return true; + } catch (e) { + logger.e("|$_tag|打开打印机失败error|$e|"); + return false; + } + } + + Future printImg(String img) async { + if (ObjectUtil.isEmpty(manager)) return false; + // 图片 + final image = await AppNetworkTool.loadImageFromNetwork(img); + if (ObjectUtil.isEmpty(image)) { + Toast.error(msg: "找不到发票图片"); + return false; + } + EscGenerator generator = EscGenerator( + paperSizeWidthMM, + maxPerLine, + profile!, + ); + List bytes = []; + bytes += generator.feed(1); + bytes += generator.image(image!); + bytes += generator.feed(1); + bytes += generator.cut(); + try { + await manager!.writeBytes(bytes); + return true; + } catch (e) { + logger.e("|$_tag|打印机失败error|$e|"); + return false; + } + } + + @override + Future close() async { + if (ObjectUtil.isNotEmpty(manager)) { + if (manager != null) { + manager?.disconnect(); + } + manager = null; + } + } + + @override + Future checkPermission() async { + return true; + } + + @override + Future applyPermission() async { + return true; + } +} diff --git a/lib/common/utils/manage/serial_card_manage.dart b/lib/common/utils/manage/serial_card_manage.dart new file mode 100644 index 0000000..dabee9a --- /dev/null +++ b/lib/common/utils/manage/serial_card_manage.dart @@ -0,0 +1,158 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:common_utils/common_utils.dart'; +import 'package:pj_supermarket_cloud/common/index.dart'; + +class SerialCardManage extends SerialBase { + SerialCardCmdEnum? currentCmd; + Timer? errorTimer; + Timer? mqTimer; + DateTime sendDate = DateTime.now(); + + Function(SerialCardCmdEnum, SerialCardResStatus, dynamic)? callback; + + SerialCardManage() { + name = "读卡器"; + portName = "/dev/ttyS4"; + baudRate = 115200; + } + + @override + void readerClose() { + super.readerClose(); + if (ObjectUtil.isNotEmpty(errorTimer)) { + errorTimer!.cancel(); + errorTimer = null; + } + if (ObjectUtil.isNotEmpty(mqTimer)) { + mqTimer!.cancel(); + mqTimer = null; + } + } + + @override + void close() { + super.close(); + readerClose(); + if (ObjectUtil.isNotEmpty(errorTimer)) { + errorTimer!.cancel(); + errorTimer = null; + } + if (ObjectUtil.isNotEmpty(mqTimer)) { + mqTimer!.cancel(); + mqTimer = null; + } + } + + /// 回调时间5秒内接收不到消息就报错 + void callbackTimer() { + if (ObjectUtil.isNotEmpty(errorTimer)) return; + sendDate = DateTime.now(); + errorTimer = Timer(const Duration(seconds: 30), () { + callback!.call(currentCmd!, SerialCardResStatus.error, null); + readerClose(); + close(); + }); + } + + /// 发送写入命令 + void sendWrite(String cardNumber) { + final hexPadded = SerialTool.toHexPadded(str: cardNumber); + if (ObjectUtil.isEmpty(hexPadded)) { + return; + } + final writeHexReq = SerialReadCardTool.writeHexReq(hexPadded!); + final bytes = SerialTool.stringToBytes(writeHexReq); + + currentCmd = SerialCardCmdEnum.write; + callbackTimer(); + _listenEvent(); + + mqTimer = Timer.periodic(const Duration(seconds: 2), (Timer t) { + final write = serialClient.write(Uint8List.fromList(bytes)); + if (write == bytes.length) { + logger.d(logMsg("发送成功|写入命令|string|$writeHexReq|")); + } + }); + } + + /// 发送读卡命令 + void sendRead() { + final stringHex = SerialReadCardTool.readHexReq(); + final bytes = SerialTool.stringToBytes(stringHex); + if (ObjectUtil.isNotEmpty(mqTimer)) return; + + currentCmd = SerialCardCmdEnum.read; + callbackTimer(); + _listenEvent(); + + mqTimer = Timer.periodic(const Duration(seconds: 2), (Timer t) { + final write = serialClient.write(Uint8List.fromList(bytes)); + if (write == bytes.length) { + logger.d(logMsg("发送成功|读卡命令|string|$stringHex|")); + } + }); + } + + /// 监听mq返回 + void _listenEvent() { + if (ObjectUtil.isEmpty(errorTimer)) return; + if (ObjectUtil.isEmpty(currentCmd)) return; + listen((List event) { + if (ObjectUtil.isEmpty(event)) return; + switch (currentCmd!) { + case SerialCardCmdEnum.write: + dispatchWrite(event); + break; + case SerialCardCmdEnum.read: + _dispatchRead(event); + break; + } + }); + } + + /// 处理写入 + void dispatchWrite(List event) { + final res = SerialTool.bytesToString(event); + if (ObjectUtil.isEmpty(res)) return; + if (res == SerialReadCardTool.writeHexResSuccess()) { + logger.d(logMsg("写入成功")); + if (ObjectUtil.isNotEmpty(callback)) { + callback!.call(currentCmd!, SerialCardResStatus.success, null); + } + readerClose(); + close(); + } + if (res == SerialReadCardTool.writeHexResError()) { + logger.d(logMsg("写入失败,未寻到卡")); + if (ObjectUtil.isNotEmpty(callback)) { + callback!.call(currentCmd!, SerialCardResStatus.warning, null); + } + } + } + + /// 处理读 + void _dispatchRead(List event) { + final res = SerialTool.bytesToString(event); + if (ObjectUtil.isEmpty(res)) return; + final readHexRes = SerialReadCardTool.readHexResSuccess(); + if (res.startsWith(readHexRes)) { + event = event.sublist(4); + String number = event.map((num) => String.fromCharCode(num)).join(); + number = number.length >= 12 ? number.substring(0, 12) : res; + logger.d(logMsg("读取成功,event:|$event|string:|$res|卡号为|$number|")); + if (ObjectUtil.isNotEmpty(callback)) { + callback!.call(currentCmd!, SerialCardResStatus.success, number); + } + readerClose(); + close(); + } + if (res == SerialReadCardTool.readHexResError()) { + logger.d(logMsg("读取失败,未寻到卡")); + if (ObjectUtil.isNotEmpty(callback)) { + callback!.call(currentCmd!, SerialCardResStatus.warning, null); + } + } + } +} diff --git a/lib/common/utils/manage/serial_steelyard_manage.dart b/lib/common/utils/manage/serial_steelyard_manage.dart new file mode 100644 index 0000000..8c881bc --- /dev/null +++ b/lib/common/utils/manage/serial_steelyard_manage.dart @@ -0,0 +1,29 @@ +import 'package:pj_supermarket_cloud/common/index.dart'; + +class SerialSteelyardManage extends SerialBase { + SerialSteelyardManage() { + name = "称"; + portName = "/dev/ttyS2"; + baudRate = 9600; + readTimeout = 3000; + } + + double? parseWidget(List bytes) { + String result = String.fromCharCodes(bytes); + final RegExp regExp = RegExp(r'(\d+(\.\d+)?)'); + Iterable matches = regExp.allMatches(result); + final str = matches.map((match) => match.group(0)).join(' '); + return double.tryParse(str); + } + + @override + void readerClose() { + super.readerClose(); + } + + @override + void close() { + readerClose(); + super.close(); + } +} diff --git a/lib/common/utils/manage/tool/serial_read_card_tool.dart b/lib/common/utils/manage/tool/serial_read_card_tool.dart new file mode 100644 index 0000000..0914e18 --- /dev/null +++ b/lib/common/utils/manage/tool/serial_read_card_tool.dart @@ -0,0 +1,31 @@ +abstract class SerialReadCardTool { + /// 写入命令-发送 + static String writeHexReq(String str) { + return "AA120501$str"; + } + + /// 写入命令-接收-成功 + static String writeHexResSuccess() { + return "AA01FE"; + } + + /// 写入命令-接收-失败-未寻到卡 + static String writeHexResError() { + return "AA01E1"; + } + + /// 读命令-发送 + static String readHexReq() { + return "AA020401"; + } + + /// 读命令-接收-成功 + static String readHexResSuccess() { + return "AA120401"; + } + + /// 读命令-接收-失败-未寻到卡 + static String readHexResError() { + return "AA01E1"; + } +} diff --git a/lib/common/utils/manage/tool/serial_tool.dart b/lib/common/utils/manage/tool/serial_tool.dart new file mode 100644 index 0000000..4e6ea71 --- /dev/null +++ b/lib/common/utils/manage/tool/serial_tool.dart @@ -0,0 +1,32 @@ +abstract class SerialTool { + /// 转16进制转字符串 + static String bytesToString(List intList) { + return intList + .map((intVal) => intVal.toRadixString(16).toUpperCase().padLeft(2, '0')) + .join(); + } + + /// 转16进制转字符串 + static List stringToBytes(String strCode) { + List bytes = []; + for (int i = 0; i < strCode.length; i += 2) { + String hexPair = strCode.substring(i, i + 2); + int byteValue = int.parse(hexPair, radix: 16); + bytes.add(byteValue); + } + return bytes; + } + + /// 字符串转16进制不够补零 + static String? toHexPadded({ + required String str, + int max = 32, + }) { + String hex = + str.codeUnits.map((c) => c.toRadixString(16).padLeft(2, '0')).join(); + if (hex.length > max) { + return null; + } + return hex.padRight(max, '0'); + } +} diff --git a/lib/common/utils/manage/tts_manage.dart b/lib/common/utils/manage/tts_manage.dart new file mode 100644 index 0000000..0b670f4 --- /dev/null +++ b/lib/common/utils/manage/tts_manage.dart @@ -0,0 +1,81 @@ +import 'package:flutter_tts/flutter_tts.dart'; +import 'package:pj_supermarket_cloud/common/index.dart'; + +// 语音转文字 +class TtsManage { + static const _tag = "语音转文字"; + + static final FlutterTts _flutterTts = FlutterTts(); + static bool _isInitialized = false; + + // 初始化TTS + static Future initialize() async { + if (!_isInitialized) { + final engines = await _flutterTts.getEngines; + if (engines.isEmpty) { + logger.e("|$_tag|没有可用的TTS引擎"); + } + if (engines.contains("com.iflytek.speechcloud")) { + await _flutterTts.setEngine("com.iflytek.speechcloud"); + } + // 设置语言为中文 + await _flutterTts.setLanguage("zh-CN"); + // 设置音量 + await _flutterTts.setVolume(1.0); + // 设置语速 + await _flutterTts.setSpeechRate(0.95); + // 设置音调 + await _flutterTts.setPitch(2.0); + // await _flutterTts.setQueueMode(1); + _isInitialized = true; + } + } + + // 播放文本 + static Future speak(String text) async { + await initialize(); + // await _flutterTts.awaitSpeakCompletion(true); + await _flutterTts.speak(text, focus: true); + } + + // 停止播放 + static Future stop() async { + await _flutterTts.stop(); + } + + // 释放资源 + static Future dispose() async { + await _flutterTts.stop(); + } + + /// 将数字转换为中文读法(手机号码格式) + /// 特别处理:1读为"幺",其他数字正常读法 + static String numberToChineseReading(String number) { + if (number.isEmpty) return ""; + // 数字到中文读法的映射 + const Map digitToChinese = { + '0': '零', + '1': '幺', // 手机号中1读为"幺" + '2': '二', + '3': '三', + '4': '四', + '5': '五', + '6': '六', + '7': '七', + '8': '八', + '9': '九', + }; + // 逐位转换 + StringBuffer result = StringBuffer(); + for (int i = 0; i < number.length; i++) { + String digit = number[i]; + if (digitToChinese.containsKey(digit)) { + result.write(digitToChinese[digit]); + } else { + // 如果不是数字,保持原样 + result.write(digit); + } + } + return result.toString(); + } +} diff --git a/lib/common/utils/plugs/face_discern_plugs.dart b/lib/common/utils/plugs/face_discern_plugs.dart new file mode 100644 index 0000000..9b51ce2 --- /dev/null +++ b/lib/common/utils/plugs/face_discern_plugs.dart @@ -0,0 +1,129 @@ +import 'dart:convert'; + +import 'package:flutter/services.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +abstract class FaceDiscernPlugs { + static const String _tag = "人脸识别plug"; + + static const MethodChannel _channel = MethodChannel( + Constants.facePlugsMethodChannel, + ); + + static const EventChannel _eventChannel = EventChannel( + Constants.facePlugsEventChannel, + ); + + static MethodChannel get getChannel => _channel; + + static Stream? _stream; + + static Future jumpFaceDiscernPage() async { + try { + await _channel.invokeMethod('jumpFaceDiscern'); + return true; + } catch (e) { + logger.w("|$_tag| jumpFaceDiscernPage 错误: $e"); + } + return false; + } + + static Future getConfig() async { + try { + final Map? map = + await _channel.invokeMethod('getConfig'); + if (map == null) return null; + final encode = json.encode(map); + return FaceConfig.fromJson(json.decode(encode)); + } catch (e) { + logger.w("|$_tag| getConfig 错误: $e"); + } + return null; + } + + static Future active({ + required String appId, + required String sdkKey, + required String activeKey, + }) async { + try { + final pa = { + "appId": appId, + "sdkKey": sdkKey, + "activeKey": activeKey, + }; + final isActive = await _channel.invokeMethod('active', pa); + return isActive; + } catch (e) { + logger.w("|$_tag| getConfig 错误: $e"); + } + return false; + } + + static Future registerAllStorageFace({ + required String filePath, + String? fileName, + }) async { + final pa = { + "filePath": filePath, + "fileName": fileName, + }; + final map = + await _channel.invokeMethod('registerAllStorageFace', pa); + if (map == null) return null; + final encode = json.encode(map); + return RegisterAllStorageFaceRes.fromJson(json.decode(encode)); + } + + static Future getFace({ + required int sampleId, + }) async { + final pa = { + "sampleId": sampleId, + }; + final map = await _channel.invokeMethod('getFace', pa); + if (map == null) return null; + final encode = json.encode(map); + return FaceRes.fromJson(json.decode(encode)); + } + + static Future getFaceCount() async { + final pa = {}; + final res = await _channel.invokeMethod('getFaceCount', pa); + if (res == null) return 0; + return res; + } + + static Future removeFace({ + required String sampleId, + }) async { + final sampleIdInt = int.tryParse(sampleId); + final pa = { + "sampleId": sampleIdInt, + }; + final res = await _channel.invokeMethod('removeFace', pa); + if (res == null) return false; + return res; + } + + static Future> registerFaceByDatabase({ + String? userId, + }) async { + final pa = { + "userId": userId, + }; + final res = await _channel.invokeMethod('registerFaceByDatabase', pa); + final errorSampleIdList = (res as List).cast(); + return errorSampleIdList; + } + + static Stream get faceStream { + _stream ??= _eventChannel.receiveBroadcastStream().map((event) { + if (event == null) return null; + if (event == 'null') return null; + return FaceStream.fromJson(jsonDecode(event)); + }); + return _stream!; + } +} diff --git a/lib/common/utils/router/router_observer_tool.dart b/lib/common/utils/router/router_observer_tool.dart new file mode 100644 index 0000000..bf00126 --- /dev/null +++ b/lib/common/utils/router/router_observer_tool.dart @@ -0,0 +1,57 @@ +import 'package:event_bus/event_bus.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +enum RouterActionType { didPush, didReplace, didPop, didRemove } + +class GlobalRouter { + final String? name; + final Route? router; + final RouterActionType action; + + GlobalRouter({ + this.name, + this.router, + required this.action, + }); +} + +class GlobalRouteObserver extends GetObserver { + static EventBus globalRouteObserverBus = EventBus(); + + @override + void didPush(Route route, Route? previousRoute) { + super.didPush(route, previousRoute); + globalRouteObserverBus.fire( + GlobalRouter( + name: route.settings.name, + router: route, + action: RouterActionType.didPush, + ), + ); + } + + @override + void didPop(Route route, Route? previousRoute) { + super.didPop(route, previousRoute); + globalRouteObserverBus.fire( + GlobalRouter( + name: route.settings.name, + router: route, + action: RouterActionType.didPop, + ), + ); + } + + @override + void didRemove(Route route, Route? previousRoute) { + super.didRemove(route, previousRoute); + globalRouteObserverBus.fire( + GlobalRouter( + name: route.settings.name, + router: route, + action: RouterActionType.didRemove, + ), + ); + } +} diff --git a/lib/common/utils/sync/sync_check_tool.dart b/lib/common/utils/sync/sync_check_tool.dart new file mode 100644 index 0000000..10fb819 --- /dev/null +++ b/lib/common/utils/sync/sync_check_tool.dart @@ -0,0 +1,132 @@ +import 'dart:async'; + +import 'package:get/get.dart'; +import 'package:mqtt_client/mqtt_client.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +class SyncCheckTool { + static const _tag = "自动检查任务"; + + SyncCheckTool._privateConstructor(); + static final SyncCheckTool _instance = SyncCheckTool._privateConstructor(); + static SyncCheckTool get instance => _instance; + + bool _isRunning = false; + +// ************************************************************************** +// 定时器配置 start +// ************************************************************************** + + Duration mqInterval = const Duration(seconds: 60); + + Duration printInterval = const Duration(seconds: 60 * 10); + + Duration broadcastInterval = const Duration(seconds: 60 * 2); + + Timer? mqTimer; + + Timer? printTimer; + + Timer? broadcastTimer; + +// ************************************************************************** +// 定时器配置 end +// ************************************************************************** + + startAll() { + if (_isRunning) return; + checkMq(); + checkPrint(); + checkBroadcast(); + } + + void dispose() { + _isRunning = false; + mqTimer?.cancel(); + mqTimer = null; + } + + // 检查mq是否可以连接,是否需要重新连接 + checkMq() { + String logMsg = "检查mq是否可以连接,是否需要重新连接"; + final inSeconds = mqInterval.inSeconds; + if (mqTimer != null && mqTimer!.isActive) { + mqTimer?.cancel(); + mqTimer = null; + } + if (mqTimer != null) { + mqTimer?.cancel(); + mqTimer = null; + } + mqTimer = Timer.periodic(mqInterval, (Timer timer) async { + final isNetwork = AppNetworkTool.isAutoNetwork(); + if (!isNetwork) return; + logger.i("|$_tag|$logMsg,$inSeconds秒一次,开始执行"); + if (MqManager.state == MqttConnectionState.connected) return; + try { + AppMqTool.mqRefresh(); + } catch (e) { + logger.i("|$_tag|$logMsg,$inSeconds秒一次,执行失败,error:$e"); + } finally { + logger.i("|$_tag|$logMsg,$inSeconds秒一次,执行完成"); + } + }); + } + + // 定时检查需不要打印 + checkPrint() { + String logMsg = "定时检查需不要打印"; + final inSeconds = printInterval.inSeconds; + if (printTimer != null && printTimer!.isActive) { + printTimer?.cancel(); + printTimer = null; + } + if (printTimer != null) { + printTimer?.cancel(); + printTimer = null; + } + printTimer = Timer.periodic(printInterval, (Timer timer) async { + final isNetwork = AppNetworkTool.isAutoNetwork(); + if (!isNetwork) return; + logger.i("|$_tag|$logMsg,$inSeconds秒一次,开始执行"); + try { + await SyncPrintTool.doCheckPrint(); + await SyncPrintTool.doCheckSync(); + } catch (e) { + logger.i("|$_tag|$logMsg,$inSeconds秒一次,执行失败,error:$e"); + } finally { + logger.i("|$_tag|$logMsg,$inSeconds秒一次,执行完成"); + } + }); + } + + // 定时检查需不要广播 + checkBroadcast() { + String logMsg = "定时检查需不要广播"; + final inSeconds = broadcastInterval.inSeconds; + if (broadcastTimer != null && broadcastTimer!.isActive) { + broadcastTimer?.cancel(); + broadcastTimer = null; + } + if (broadcastTimer != null) { + broadcastTimer?.cancel(); + broadcastTimer = null; + } + broadcastTimer = Timer.periodic(broadcastInterval, (Timer timer) async { + final isNetwork = AppNetworkTool.isAutoNetwork(); + if (!isNetwork) return; + logger.i("|$_tag|$logMsg,$inSeconds秒一次,开始执行"); + try { + final setting = await AppSettingTool.getSysSetting(); + await SyncTakeFoodBroadcastTool.doCheckBroadcast( + setting.hourType ?? ProductHourType.TOTAL, + ); + } catch (e) { + logger.i("|$_tag|$logMsg,$inSeconds秒一次,执行失败,error:$e"); + } finally { + logger.i("|$_tag|$logMsg,$inSeconds秒一次,执行完成"); + } + }); + } +} diff --git a/lib/common/utils/sync/sync_config_tool.dart b/lib/common/utils/sync/sync_config_tool.dart new file mode 100644 index 0000000..0c5e32f --- /dev/null +++ b/lib/common/utils/sync/sync_config_tool.dart @@ -0,0 +1,59 @@ +import 'dart:convert'; + +import 'package:common_utils/common_utils.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +abstract class SyncConfigTool { + static const _tag = "sync配置工具"; + + // 自动同步设备信息导本地缓存 + static Future autoSyncSysInfo() async { + final login = await AppUserTool.isLogin(); + if (!login) return null; + final sysSetting = await AppSettingTool.getSysSetting(); + logger.i("|$_tag|开始同步设备信息 sysInfo:|${json.encode(sysSetting)}|"); + if (ObjectUtil.isEmpty(sysSetting)) return null; + if (ObjectUtil.isEmpty(sysSetting.serviceId)) return null; + final serviceId = sysSetting.serviceId; + final pa = {"id": serviceId}; + final data = await HardApi.get(pa); + if (data == null) { + logger.e("|$_tag|同步设备信息错误serviceId:|$serviceId|;"); + return null; + } + // 基本信息 + await AppSettingTool.updateSysInfoByService(data); + // 人脸sdk + await autoSyncFaceSdk(detail: data, sysInfo: sysSetting); + logger.i("|$_tag|结束同步设备信息 sysInfo:|${json.encode(sysSetting)}|"); + return sysSetting; + } + + // 自动同步设备信息导本地缓存-同步人脸sdk + static Future autoSyncFaceSdk({ + required ServiceHardware detail, + required SysSetting sysInfo, + }) async { + final appId = detail.faceAppId; + final sdkKey = detail.faceSdkKey; + final activeKey = detail.faceActiveKey; + bool isUpdate = false; + if (appId != sysInfo.faceAppId) { + sysInfo.faceAppId = appId; + isUpdate = true; + } + if (sdkKey != sysInfo.faceSdkKey) { + sysInfo.faceSdkKey = sdkKey; + isUpdate = true; + } + if (activeKey != sysInfo.faceActiveKey) { + sysInfo.faceActiveKey = activeKey; + isUpdate = true; + } + if (!isUpdate) return; + final activeSdk = await AppFaceTool.isActiveSdk(); + await AppSettingTool.setSysSetting(sysInfo); + logger.i("|$_tag||同步人脸sdk参数自动激活${activeSdk ? '成功' : '失败'}"); + } +} diff --git a/lib/common/utils/sync/sync_face_tool.dart b/lib/common/utils/sync/sync_face_tool.dart new file mode 100644 index 0000000..7b6727d --- /dev/null +++ b/lib/common/utils/sync/sync_face_tool.dart @@ -0,0 +1,259 @@ +import 'dart:io'; + +import 'package:common_utils/common_utils.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +class SyncFaceTool { + static const _tag = "sync人脸工具"; + + // 删除数据库中为空的人脸 + static Future delEmptyFace() async { + final app = Get.find(); + final faceDao = app.dataBase.getFaceDao; + final faceList = await faceDao.getEmptyFaceList(); + if (ObjectUtil.isEmptyList(faceList)) return; + for (final v in faceList!) { + if (ObjectUtil.isNotEmpty(v.path)) { + await delFaceByStorage(filePath: v.path!); + } + await faceDao.delById(v.id!); + } + } + + // 同步人脸 + static Future syncFace({ + bool isAll = false, + VoidCallback? onSyncGroup, + }) async { + final appSetting = await AppSettingTool.getSysSetting(); + final merchantDeptId = appSetting.merchantDeptId; + // 人脸存储路径 + final storagePath = await AppFileTool.getFacePath(); + if (ObjectUtil.isEmpty(storagePath)) { + logger.e("|$_tag|同步人脸错误,创建人脸存储路径错误"); + return; + } + // 同步没有的人脸 + int pageNo = 1; + bool isNext = true; + while (isNext) { + final bool = await syncGroupFace( + pageNo: pageNo, + merchantDeptId: isAll ? "" : merchantDeptId!, + storagePath: storagePath!, + ); + if (isNext != bool) isNext = bool; + if (!bool) break; + pageNo += 1; + onSyncGroup?.call(); + } + } + + static Future syncGroupFace({ + required int pageNo, + required String merchantDeptId, + required String storagePath, + String? userId, + bool? isGlobalFace, + }) async { + final app = Get.find(); + final faceDao = app.dataBase.getFaceDao; + const size = 100; + var pa = { + "current": pageNo, + "size": size, + "userId": userId, + "merchantDeptId": merchantDeptId, + }; + if (isGlobalFace != null && isGlobalFace) pa["merchantDeptId"] = null; + logger.i("|$_tag|同步人脸api参数:|$pa|"); + try { + final data = await UserApi.getFaceListV2(pa); + if (data == null) throw Error(); + if (ObjectUtil.isEmptyList(data)) { + if (ObjectUtil.isEmpty(userId)) return false; + // 删除人脸图片 + final faceList = await faceDao.findByUserId(userId!); + if (!ObjectUtil.isEmptyList(faceList)) { + for (final v in faceList!) { + if (ObjectUtil.isNotEmpty(v.path)) { + await delFaceByStorage(filePath: v.path!); + await AppFaceTool.removeFace(sampleId: v.sampleId); + } + } + } + // 删除数据库 + await faceDao.delByUserId(userId); + return false; + } + if (isGlobalFace != null && !isGlobalFace) return false; + if (userId != null) { + final dbUserList = await faceDao.findByUserId(userId); + if (!ObjectUtil.isEmptyList(dbUserList)) { + for (final v in dbUserList!) { + final target = data.firstWhereOrNull((i) => i.id == v.sampleId); + if (target == null) { + if (ObjectUtil.isNotEmpty(v.path)) { + await delFaceByStorage(filePath: v.path!); + await AppFaceTool.removeFace(sampleId: v.sampleId); + } + await faceDao.delById(v.id!); + } + } + } + } + for (final v in data) { + if (ObjectUtil.isEmpty(v.autoCode) || ObjectUtil.isEmpty(v.userId)) { + logger.e( + "|$_tag|同步人脸跳过无效数据:|autoCode=${v.autoCode}|userId=${v.userId}|deptId=${v.deptId}|username=${v.username}|", + ); + continue; + } + + final sampleId = v.autoCode!; + final userId = v.userId!; + // 删除人脸 + if (v.delFlag == true) { + // 先查询数据库是否存在 + final detail = await faceDao.findBySampleId(sampleId); + if (detail == null) { + continue; + } + if (ObjectUtil.isNotEmpty(detail.path)) { + await delFaceByStorage(filePath: detail.path!); + } + await AppFaceTool.removeFace(sampleId: sampleId); + await faceDao.delBySampleId(sampleId); + continue; + } + final faceEntity = await faceDao.findBySampleId(sampleId); + if (faceEntity == null) { + final add = FaceEntity( + userId: userId, + sampleId: sampleId, + userName: v.username ?? "", + deptId: v.deptId, + deptName: v.deptName ?? "", + imagePath: v.faceImg ?? "", + createTime: DateTime.now(), + ); + final addId = await faceDao.save(add); + if (addId != null) { + String? fileName = generateFaceFileName( + userId: add.userId, + sampleId: add.sampleId, + username: add.userName, + facePath: add.imagePath, + ); + final filePath = await writeFaceByStorage( + storagePath: storagePath, + fileName: fileName, + facePath: add.imagePath, + ); + if (ObjectUtil.isNotEmpty(filePath)) { + add.id = addId; + add.updateTime = DateTime.now(); + add.path = filePath; + await faceDao.edit(add); + } + } + await AppFaceTool.removeFace(sampleId: add.sampleId); + await AppFaceTool.faceRegister( + userId: add.userId.toString(), + forceRegister: true, + ); + continue; + } + final edit = FaceEntity( + id: faceEntity.id!, + userId: userId, + sampleId: sampleId, + userName: v.username ?? "", + deptId: v.deptId, + deptName: v.deptName ?? "", + imagePath: v.faceImg ?? "", + createTime: DateTime.now(), + ); + if (edit == faceEntity) { + continue; + } + String? fileName = generateFaceFileName( + userId: edit.userId, + sampleId: edit.sampleId, + username: edit.userName, + facePath: edit.imagePath, + ); + final filePath = await writeFaceByStorage( + storagePath: storagePath, + facePath: edit.imagePath, + fileName: fileName, + ); + if (ObjectUtil.isNotEmpty(filePath)) { + if (ObjectUtil.isNotEmpty(edit.path)) { + await delFaceByStorage(filePath: edit.path!); + } + edit.path = filePath; + } + await faceDao.edit(edit); + // 重新生成人脸模型 + await AppFaceTool.removeFace(sampleId: edit.sampleId); + await AppFaceTool.faceRegister( + userId: edit.userId.toString(), + forceRegister: true, + ); + } + if (data.length >= size) return true; + } catch (e, stack) { + logger.e("|$_tag|同步人脸意外的错误error:|$e|stack:|$stack|"); + } + return false; + } + + /// 删除人脸图片 + static Future delFaceByStorage({ + required String filePath, + }) async { + try { + final file = File(filePath); + if (await file.exists()) { + await file.delete(); + } + } catch (e) { + logger.e("|$_tag|删除人脸图片意外的错误error:|$e|"); + } + } + + static String? generateFaceFileName({ + required String userId, + required String sampleId, + required String username, + required String facePath, + }) { + if (ObjectUtil.isEmpty(facePath)) return null; + return "${userId}_${sampleId}_$username${AppFileTool.getFileExtension(facePath)}"; + } + + /// 写入人脸头像到本地磁盘 + static Future writeFaceByStorage({ + required String storagePath, + required String? fileName, + required String facePath, + }) async { + if (ObjectUtil.isEmpty(fileName)) return null; + final storageDirectory = Directory(storagePath); + final file = File('${storageDirectory.path}/$fileName'); + if (await file.exists()) { + await file.delete(); + } + final bytesImg = await AppNetworkTool.downloadImage(facePath); + if (bytesImg == null) return null; + final writeFile = await file.writeAsBytes(bytesImg); + if (await writeFile.exists()) { + return file.path; + } + return null; + } +} diff --git a/lib/common/utils/sync/sync_order_tool.dart b/lib/common/utils/sync/sync_order_tool.dart new file mode 100644 index 0000000..95e7496 --- /dev/null +++ b/lib/common/utils/sync/sync_order_tool.dart @@ -0,0 +1,100 @@ +import 'package:common_utils/common_utils.dart'; +import 'package:get/get.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +abstract class SyncOrderTool { + static const _tag = "sync订单工具"; + + // 同步离线订单 + static Future syncOfflineOrder() async { + final app = Get.find(); + final sysSetting = await AppSettingTool.getSysSetting(); + final sysType = sysSetting.sysType.name; + + final orderDao = app.dataBase.getOrderDao; + + // 查询离线订单 + final startDate = DateTime.now().subtract(const Duration(days: 30)); + final endDate = DateTime.now().add(const Duration(days: 1)); + final list = await orderDao.findOffline( + sysType, + startDate, + endDate, + ); + if (ObjectUtil.isEmptyList(list)) { + logger.i("|$_tag|错误,没有需要同步的离线订单"); + return; + } + final chunkList = chunkArray(list, 5); + for (var v in chunkList) { + await _syncOfflineGroupOrder(v); + } + // 更新同步时间 + sysSetting.offlineOrderSyncTime = DateTime.now(); + await AppSettingTool.setSysSetting(sysSetting); + } + + // 同步一组离线订单 + static Future _syncOfflineGroupOrder(List list) async { + final app = Get.find(); + + final sysSetting = await AppSettingTool.getSysSetting(); + final serviceId = sysSetting.serviceId; + final serviceMerchantId = sysSetting.serviceMerchantId; + + final shopCardDao = app.dataBase.getShopCardDao; + List> uploadList = []; + for (final OrderEntity v in list) { + Map item = v.toJson(); + if (ObjectUtil.isNotEmpty(v.createTime)) { + item["createTime"] = DateUtil.formatDate(v.createTime); + } + if (ObjectUtil.isNotEmpty(v.payTime)) { + item["payTime"] = DateUtil.formatDate(v.payTime!); + } + if (v.id != null) { + final carList = await shopCardDao.findByOrderId(v.id!); + if (!ObjectUtil.isEmptyList(carList)) { + item["carList"] = carList.map((i) => i.toJson()).toList(); + } + } + uploadList.add(item); + } + final pa = { + "list": uploadList, + "hardwareId": serviceId, + "merchantId": serviceMerchantId!, + }; + logger.i("|$_tag|同步参数:|$pa|"); + try { + final data = await HardApi.syncOfflineOrder(pa); + if (data == null) throw Error(); + final batchEditCount = await handEditOfflineStatus(data); + logger.i("|$_tag|同步成功的数量为:|$batchEditCount|ids为:|$data|"); + } catch (e) { + logger.e("|$_tag|同步一组离线订单意外的错误error:$e"); + } + } + + static Future handEditOfflineStatus(List codes) async { + final app = Get.find(); + final placeholders = List.generate(codes.length, (index) => '?').join(', '); + final sql = ''' + UPDATE order_record SET status = ? WHERE uniqueCode IN ($placeholders) + '''; + final database = app.dataBase.database; + return await database.rawUpdate(sql, [OrderStatus.PAY.name, ...codes]); + } + + // 切割数组 + static List> chunkArray(List array, int chunkSize) { + if (chunkSize <= 0) return []; + List> chunks = []; + for (var i = 0; i < array.length; i += chunkSize) { + int end = (i + chunkSize < array.length) ? i + chunkSize : array.length; + chunks.add(array.sublist(i, end)); + } + return chunks; + } +} diff --git a/lib/common/utils/sync/sync_print_tool.dart b/lib/common/utils/sync/sync_print_tool.dart new file mode 100644 index 0000000..e076829 --- /dev/null +++ b/lib/common/utils/sync/sync_print_tool.dart @@ -0,0 +1,132 @@ +import 'dart:convert'; + +import 'package:common_utils/common_utils.dart'; +import 'package:get/get.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +abstract class SyncPrintTool { + static const _tag = "sync打印工具"; + + static bool _isPrint = false; + + static bool _isSync = false; + + static Future addTask({ + required String orderId, + required String androidOrderId, + required String historyId, + required String pic, + }) async { + final appC = Get.find(); + final printHistoryDao = appC.dataBase.getPrintHistoryDao; + final saveObj = PrintHistoryEntity( + orderId: androidOrderId, + serviceOrderId: orderId, + servicePrintId: historyId, + invoiceUrl: pic, + status: PrintStatus.NONE, + createTime: DateTime.now(), + ); + await printHistoryDao.save(saveObj); + logger.i("|$_tag|添加到打印记录到本地数据库,json:|${saveObj.toJson()}|"); + await SyncPrintTool.doCheckPrint(); + } + + // 检查打印 + static Future doCheckPrint() async { + if (_isPrint) { + logger.i("|$_tag|检查打印,正在打印中"); + return; + } + final appC = Get.find(); + final printHistoryDao = appC.dataBase.getPrintHistoryDao; + final printFacility = appC.printManage; + try { + // 检查有没有需要打印的 + final list = await printHistoryDao.getAll(PrintStatus.NONE.name); + if (ObjectUtil.isEmptyList(list)) { + _isPrint = false; + logger.i("|$_tag|检查打印,没有需要打印的发票"); + return; + } + final isOpen = await printFacility.open(); + if (!isOpen) { + _isPrint = false; + logger.e("|$_tag|检查打印,打开打印机失败"); + return; + } + for (final v in list) { + await doPrint(v); + } + } catch (e) { + logger.e("|$_tag|检查打印,打印错误,error:|$e|"); + } finally { + _isPrint = false; + } + } + + // 检查同步 + static Future doCheckSync() async { + if (_isSync) { + logger.i("|$_tag|检查打印,正在打印中"); + return; + } + final app = Get.find(); + final printHistoryDao = app.dataBase.getPrintHistoryDao; + final list = await printHistoryDao.getAll(PrintStatus.SUCCESS.name); + if (ObjectUtil.isEmptyList(list)) { + _isSync = false; + return; + } + for (final v in list) { + final printPa = { + "orderId": v.serviceOrderId, + "historyId": v.servicePrintId, + }; + try { + final flag = await HardApi.printInvoice(printPa); + if (!flag) Exception("|$_tag|同步失败api错误params|${v.toJson()}|"); + v.status = PrintStatus.SYNC; + v.syncTime = DateTime.now(); + await printHistoryDao.edit(v); + } catch (e) { + logger.e("|$_tag|检查同步,打印错误,error:|$e|"); + v.status = PrintStatus.ERROR; + await printHistoryDao.edit(v); + } finally { + _isSync = false; + } + } + } + + // 执行打印 + static Future doPrint(PrintHistoryEntity v) async { + final app = Get.find(); + final printHistoryDao = app.dataBase.getPrintHistoryDao; + final printFacility = app.printManage; + try { + final isPrint = await printFacility.printImg(v.invoiceUrl); + if (!isPrint) { + v.status = PrintStatus.ERROR; + await printHistoryDao.edit(v); + throw Exception("|$_tag|打印失败params|${v.toJson()}|"); + } + // 更新本地数据库 + v.status = PrintStatus.SUCCESS; + v.printTime = DateUtil.formatDate(DateTime.now()); + await printHistoryDao.edit(v); + // 同步到服务端 + final printPa = { + "orderId": v.serviceOrderId, + "historyId": v.servicePrintId, + }; + await HardApi.printInvoice(printPa); + v.status = PrintStatus.SYNC; + v.syncTime = DateTime.now(); + await printHistoryDao.edit(v); + } catch (e) { + logger.i("|$_tag|执行打印,打印错误,error:|$e|"); + } + } +} diff --git a/lib/common/utils/sync/sync_product_tool.dart b/lib/common/utils/sync/sync_product_tool.dart new file mode 100644 index 0000000..ba9b912 --- /dev/null +++ b/lib/common/utils/sync/sync_product_tool.dart @@ -0,0 +1,529 @@ +import 'package:common_utils/common_utils.dart'; +import 'package:flutter_easyloading/flutter_easyloading.dart'; +import 'package:get/get.dart'; +import 'package:pinyin/pinyin.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +abstract class SyncProductTool { + static const _tag = "sync商品工具"; + + // 更新商品和分类 + static Future updateDatabase() async { + final isNetwork = AppNetworkTool.isAutoNetwork(); + if (!isNetwork) return; + await syncClassify(); + await syncProduct(); + } + + // 删除商品和分类 + static Future delDatabase() async { + final isNetwork = AppNetworkTool.isAutoNetwork(); + if (!isNetwork) return; + final app = Get.find(); + final sysSetting = await AppSettingTool.getSysSetting(); + final sysType = sysSetting.sysType; + // 删除超市商品 + if (sysType == SysType.SUPERMARKET) { + final supermarketSpuDao = app.dataBase.getSupermarketSpuDao; + await supermarketSpuDao.delAllForce(); + final supermarketSkuDao = app.dataBase.getSupermarketSkuDao; + await supermarketSkuDao.delAllForce(); + final supermarketCodeDao = app.dataBase.getSupermarketCodeDao; + await supermarketCodeDao.delAllForce(); + return; + } + // 删除食堂商品 + if (sysType == SysType.CANTEEN) { + final canteenSpuDao = app.dataBase.getCanteenSpuDao; + await canteenSpuDao.delAllByType(); + return; + } + // 删除分类 + final classifyDao = app.dataBase.getClassifyDao; + await classifyDao.delAllForce(); + } + +// ************************************************************************** +// 同步商品分类 spu start +// ************************************************************************** + + static Future syncClassify() async { + final sysSetting = await AppSettingTool.getSysSetting(); + final sysType = sysSetting.sysType; + try { + List? apiList; + if (sysType == SysType.CANTEEN) { + apiList = await ProductApi.canteenClassify(null); + } else if (sysType == SysType.SUPERMARKET) { + apiList = await ProductApi.supermarketClassify(null); + } + apiList ??= []; + await _batchUpdateClassify(list: apiList, sysType: sysSetting.sysType); + // 更新同步时间 + if (sysType == SysType.CANTEEN) { + sysSetting.canteenClassifySyncTime = DateTime.now(); + } + if (sysType == SysType.SUPERMARKET) { + sysSetting.supermarketClassifySyncTime = DateTime.now(); + } + await AppSettingTool.setSysSetting(sysSetting); + } catch (e, stack) { + logger.e("|$_tag|同步|${sysType.title}|分类信息意外的错误error:|$e|,stack:|$stack|"); + } + } + + static Future _batchUpdateClassify({ + required List list, + required SysType sysType, + }) async { + final app = Get.find(); + final dataBase = app.dataBase; + final classifyDao = dataBase.getClassifyDao; + final classifyList = await classifyDao.getAll(sysType.name, "-1"); + if (ObjectUtil.isEmpty(list)) { + await classifyDao.delBySysType(sysType.name); + return; + } + if (ObjectUtil.isEmpty(classifyList)) { + await _batchAddOrUpdateClassify( + list: list, + sysType: sysType, + isUpdate: false, + ); + return; + } + final apiServiceCodes = list.map((e) => e.id.toString()).toSet(); + final dbServiceCodes = classifyList.map((e) => e.serviceUniqueCode).toSet(); + + final addCodes = apiServiceCodes.difference(dbServiceCodes).toList(); + final deleteCodes = dbServiceCodes.difference(apiServiceCodes).toList(); + final updateCodes = apiServiceCodes.intersection(dbServiceCodes).toList(); + + if (ObjectUtil.isNotEmpty(addCodes)) { + await _batchAddOrUpdateClassify( + list: _filterClassifyListByCodes(list, addCodes), + sysType: sysType, + isUpdate: false, + ); + } + if (ObjectUtil.isNotEmpty(deleteCodes)) { + await classifyDao.delByIds(deleteCodes); + } + if (ObjectUtil.isNotEmpty(updateCodes)) { + await _batchAddOrUpdateClassify( + list: _filterClassifyListByCodes(list, updateCodes), + sysType: sysType, + isUpdate: true, + ); + } + } + + static Future _batchAddOrUpdateClassify({ + required List list, + required SysType sysType, + required bool isUpdate, + }) async { + if (ObjectUtil.isEmpty(list)) return; + final app = Get.find(); + final dataBase = app.dataBase; + final classifyDao = dataBase.getClassifyDao; + List changeList = []; + for (final v in list) { + ClassifyEntity item = ClassifyEntity( + serviceUniqueCode: v.id.toString(), + sysType: sysType, + name: v.name!, + categoryType: v.categoryType ?? "-1", + sort: v.sort ?? 0, + createTime: DateTime.now(), + updateTime: DateTime.now(), + delFlag: false, + ); + if (isUpdate) { + final detail = await classifyDao.getByCode(item.serviceUniqueCode); + if (ObjectUtil.isEmpty(detail)) continue; + if (detail == item) continue; + item.id = detail?.id; + } + changeList.add(item); + } + if (ObjectUtil.isNotEmpty(changeList)) { + isUpdate + ? await classifyDao.batchEdit(changeList) + : await classifyDao.batchSave(changeList); + } + } + + static List _filterClassifyListByCodes( + List sourceList, + List addCodes, + ) { + return sourceList + .where((item) => addCodes.contains(item.id.toString())) + .toList(); + } + +// ************************************************************************** +// 同步商品分类 spu end +// ************************************************************************** + + /// 同步超市所有数据 + /// 1-删除所有商品分类 + /// 2-删除所有商品 + /// 3-同步商品分类 + /// 4-同步商品 + static Future syncSupermarket() async { + final stopwatch = Stopwatch()..start(); + EasyLoading.show( + status: '远程同步数据中...', + maskType: EasyLoadingMaskType.black, + ); + try { + // 删除 + await delDatabase(); + // 同步 + await syncClassify(); + await syncProduct(); + } catch (error, stack) { + logger.e( + "|$_tag|同步|${SysType.SUPERMARKET.title}|商品信息意外的错误error:|$error|,stack:|$stack|"); + } finally { + stopwatch.stop(); + logger.i('|$_tag|同步超市数据总耗时: ${stopwatch.elapsedMilliseconds}ms'); + EasyLoading.dismiss(); + } + } + + /// 同步商品 + static Future syncProduct() async { + final sysSetting = await AppSettingTool.getSysSetting(); + final sysType = sysSetting.sysType; + try { + if (sysType == SysType.CANTEEN) { + // 获取从今天开始的所有日期时间 + final DateTime now = DateTime.now(); + for (int i = 0; i < 7; i++) { + final DateTime date = now.add(Duration(days: i)); + final String day = DateUtil.formatDate( + date, + format: DateFormats.y_mo_d, + ); + final dayDatetime = DateUtil.getDateTime(day); + final pa = { + "day": day, + "hourType": null, + "categoryId": null, + "merchantId": sysSetting.serviceMerchantId, + }; + final canteenList = await ProductApi.canteenProduct(pa); + if (canteenList == null) return; + for (var v in canteenList) { + v.saleDate = dayDatetime; + } + // 更新数据 + await _batchUpdateCaneenProduct(list: canteenList, dayStr: day); + } + } else if (sysType == SysType.SUPERMARKET) { + // 超市 + final app = Get.find(); + final dataBase = app.dataBase; + final supermarketSpuDao = dataBase.getSupermarketSpuDao; + final supermarketSkuDao = dataBase.getSupermarketSkuDao; + final supermarketCodeDao = dataBase.getSupermarketCodeDao; + int currentPage = 1; + Set serviceCodes = {}; + while (true) { + final pa = {"current": currentPage, "size": 100}; + final supermarket = await ProductApi.supermarketSpu(pa); + if (supermarket == null) break; + if (ObjectUtil.isEmptyList(supermarket.records)) break; + final ids = await _batchUpdateSupermarketProduct( + list: supermarket.records!, + ); + currentPage += 1; + serviceCodes.addAll(ids); + } + // 处理删除 + final productList = await supermarketSpuDao.getAll( + "-1", + ); + final dbCodes = productList.map((e) => e.serviceUniqueCode).toSet(); + final deleteCodes = dbCodes.difference(serviceCodes).toList(); + if (ObjectUtil.isNotEmpty(deleteCodes)) { + await supermarketSpuDao.delByIds(deleteCodes); + await supermarketSkuDao.delBySpuIds(deleteCodes); + await supermarketCodeDao.delBySpuIds(deleteCodes); + } + } + // 更新同步时间 + if (sysType == SysType.CANTEEN) { + sysSetting.canteenProductSyncTime = DateTime.now(); + } + if (sysType == SysType.SUPERMARKET) { + sysSetting.supermarketProductSyncTime = DateTime.now(); + } + await AppSettingTool.setSysSetting(sysSetting); + } catch (e, stack) { + logger.e("|$_tag|同步|${sysType.title}|商品信息意外的错误error:|$e|,stack:|$stack|"); + } + } + +// ************************************************************************** +// 同步食堂商品 spu start +// ************************************************************************** + + static Future _batchUpdateCaneenProduct({ + required List list, + required String dayStr, + }) async { + final app = Get.find(); + final dataBase = app.dataBase; + final productDao = dataBase.getCanteenSpuDao; + // 查询当天全部数据 + final productList = await productDao.getAll( + "-1", + "-1", + dayStr, + ); + // 删除当天全部数据 + await productDao.delAllByDay(dayStr); + // 插入 + await _batchAddOrUpdateCaneenProduct(list: list, productList: productList); + } + + static Future _batchAddOrUpdateCaneenProduct({ + required List list, + required List productList, + }) async { + if (ObjectUtil.isEmpty(list)) return; + final app = Get.find(); + final dataBase = app.dataBase; + final productDao = dataBase.getCanteenSpuDao; + List changeList = []; + for (final v in list) { + CanteenSpuEntity item = CanteenSpuEntity( + serviceUniqueCode: v.spuId.toString(), + classifyServiceUniqueCode: + v.categoryId != null ? v.categoryId.toString() : "-1", + name: v.name!, + price: v.price ?? 0, + image: v.picUrl, + status: 1, + sort: v.sort ?? 0, + createTime: DateTime.now(), + updateTime: DateTime.now(), + hourType: ProductHourType.values.fromCode(v.hourType), + saleDate: v.saleDate ?? DateTime.now(), + isDefault: false, + isPriceIncreased: v.isPriceIncreased ?? false, + ); + if (!ObjectUtil.isEmptyList(productList)) { + final targetProduct = productList.firstWhereOrNull((i) => + i.serviceUniqueCode == item.serviceUniqueCode && + i.isDefault == true); + if (targetProduct != null) { + item.isDefault = true; + } + } + changeList.add(item); + } + if (ObjectUtil.isNotEmpty(changeList)) { + await productDao.batchSave(changeList); + } + } + +// ************************************************************************** +// 同步食堂商品 spu end +// ************************************************************************** + +// ************************************************************************** +// 同步食堂商品 spu start +// ************************************************************************** + + static Future> _batchUpdateSupermarketProduct({ + required List list, + }) async { + final app = Get.find(); + final dataBase = app.dataBase; + final supermarketSpuDao = dataBase.getSupermarketSpuDao; + final productList = await supermarketSpuDao.getAll( + "-1", + ); + if (ObjectUtil.isEmpty(productList)) { + await _batchAddOrUpdateSupermarketProduct( + list: list, + isUpdate: false, + ); + await _batchAddOrUpdateSupermarketSpuAndCode(list); + return list.map((e) => e.id.toString()).toSet().toList(); + } + final apiServiceCodes = list.map((e) => e.id.toString()).toSet(); + final dbServiceCodes = productList.map((e) => e.serviceUniqueCode).toSet(); + + final addCodes = apiServiceCodes.difference(dbServiceCodes).toList(); + final updateCodes = apiServiceCodes.intersection(dbServiceCodes).toList(); + if (ObjectUtil.isNotEmpty(addCodes)) { + await _batchAddOrUpdateSupermarketProduct( + list: _filterCaneenSupermarketByCodes(list, addCodes), + isUpdate: false, + ); + } + if (ObjectUtil.isNotEmpty(updateCodes)) { + await _batchAddOrUpdateSupermarketProduct( + list: _filterCaneenSupermarketByCodes(list, updateCodes), + isUpdate: true, + ); + } + await _batchAddOrUpdateSupermarketSpuAndCode(list); + return apiServiceCodes.toList(); + } + + static Future _batchAddOrUpdateSupermarketProduct({ + required List list, + required bool isUpdate, + }) async { + if (ObjectUtil.isEmpty(list)) return; + final app = Get.find(); + final dataBase = app.dataBase; + final productDao = dataBase.getSupermarketSpuDao; + List changeList = []; + for (final v in list) { + SupermarketSpuEntity item = SupermarketSpuEntity( + serviceUniqueCode: v.id ?? "-1", + classifyServiceUniqueCode: v.categoryId ?? "-1", + productCode: v.productCode ?? "-1", + productName: v.productName ?? "", + productNature: ProductNature.values.fromCode(v.productNature) ?? + ProductNature.DEFAULT, + barCode: "", + packageNum: 0, + unit: v.unit ?? "", + spec: v.spec ?? "", + picUrl: v.picUrl ?? "", + stock: v.stock ?? 0, + salesCount: + v.salesCount != null ? double.tryParse(v.salesCount.toString()) : 0, + purchasePrice: 0, + retailPrice: v.retailPrice ?? 0, + promotionPrice: v.promotionPrice ?? 0, + sort: 0, + createTime: DateTime.now(), + updateTime: DateTime.now(), + delFlag: false, + ); + // 计算一下拼音 + final productNamePy = await transitionPy(item.productName); + if (ObjectUtil.isNotEmpty(productNamePy)) { + item.productNamePy = productNamePy; + } + if (isUpdate) { + final detail = await productDao.getByCode(item.serviceUniqueCode); + if (ObjectUtil.isEmpty(detail)) continue; + if (detail == item) continue; + item.id = detail?.id; + } + changeList.add(item); + } + if (ObjectUtil.isNotEmpty(changeList)) { + isUpdate + ? await productDao.batchEdit(changeList) + : await productDao.batchSave(changeList); + } + } + + static List _filterCaneenSupermarketByCodes( + List sourceList, + List codes, + ) { + return sourceList + .where((item) => codes.contains(item.id.toString())) + .toList(); + } + + // 更新辅助条码和spu + static Future _batchAddOrUpdateSupermarketSpuAndCode( + List list, + ) async { + if (list.isEmpty) return; + final app = Get.find(); + final dataBase = app.dataBase; + final skuDao = dataBase.getSupermarketSkuDao; + final codeDao = dataBase.getSupermarketCodeDao; + + final spuIds = + list.where((v) => ObjectUtil.isNotEmpty(v)).map((v) => v.id!).toList(); + try { + final pa = {"ids": spuIds}; + final skuDetailList = await ProductApi.supermarketSupDetial(pa); + if (skuDetailList == null) { + return; + } + // 删除 sku + skuDao.delBySpuIds(spuIds); + // 删除辅助条码 + codeDao.delBySpuIds(spuIds); + List codeList = []; + List skuList = []; + for (final v in skuDetailList) { + // 辅助条码 + if (v.supermarketAuxiliaryCodeEntityList != null) { + for (final i in v.supermarketAuxiliaryCodeEntityList!) { + SupermarketCodeEntity item = SupermarketCodeEntity( + serviceUniqueCode: i.id ?? "-1", + spuId: i.spuId ?? "-1", + unitId: i.unitId ?? "", + barCode: i.barCode ?? "", + ); + codeList.add(item); + } + } + // sku + if (v.supermarketUnitEntityList != null) { + for (final i in v.supermarketUnitEntityList!) { + SupermarketSkuEntity item = SupermarketSkuEntity( + serviceUniqueCode: i.id ?? "-1", + spuId: i.spuId ?? "-1", + barCode: i.barCode ?? "", + spec: i.spec ?? "", + unit: i.unit ?? "", + packageNum: i.packageNum ?? 0, + purchasePrice: i.purchasePrice ?? 0, + retailPrice: i.retailPrice ?? 0, + promotionPrice: i.promotionPrice ?? 0, + ); + skuList.add(item); + } + } + } + if (codeList.isNotEmpty) { + await codeDao.batchSave(codeList); + } + if (skuList.isNotEmpty) { + await skuDao.batchSave(skuList); + } + } catch (e, stack) { + logger.e("|$_tag|添加超市spu detail 错误error:|$e|stack:|$stack|"); + } + } + +// ************************************************************************** +// 同步食堂商品 spu end +// ************************************************************************** + + // 中文转拼音 + static Future transitionPy(String str) async { + try { + if (ObjectUtil.isEmpty(str)) return ""; + // 尝试转换为数字,如果是数字,返回空 + if (double.tryParse(str) != null) return ""; + final py = PinyinHelper.getShortPinyin(str); + logger.d("|$_tag|中文转拼音|中文:|$str|转换到拼音:|$py|"); + // 去除所有空格 + final pyNoSpace = py.replaceAll(" ", ""); + return pyNoSpace; + } catch (error, stack) { + logger.e("|$_tag|添加超市spu detail 错误error:|$error|stack:|$stack|"); + } + } +} diff --git a/lib/common/utils/sync/sync_take_food_broadcast_tool.dart b/lib/common/utils/sync/sync_take_food_broadcast_tool.dart new file mode 100644 index 0000000..03be004 --- /dev/null +++ b/lib/common/utils/sync/sync_take_food_broadcast_tool.dart @@ -0,0 +1,190 @@ +import 'package:common_utils/common_utils.dart'; +import 'package:get/get.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +abstract class SyncTakeFoodBroadcastTool { + static const _tag = "sync订餐广播工具"; + + static bool _isBroadcast = false; + + static Future addTask(MqttTakeFoodBroadcastMsg msg) async { + final appC = Get.find(); + final broadcastDao = appC.dataBase.getBroadcastDao; + // 完成就删除掉 + if (msg.isDone) { + await broadcastDao.del(msg.orderId); + return; + } + final saveObj = BroadcastEntity( + content: msg.content, + broadcastCount: 0, + broadcastMaxCount: msg.broadcastMaxCount, + isBroadcast: true, + isDone: msg.isDone, + productDesc: msg.productDesc, + serviceCode: msg.id, + serviceOrderCode: msg.orderId, + userId: msg.userId, + username: msg.username, + userMobile: msg.userMobile, + payPrice: msg.payPrice, + hourType: + ProductHourType.values.fromCode(msg.hourType) ?? ProductHourType.NOON, + enable: true, + createTime: DateTime.now(), + ); + await broadcastDao.save(saveObj); + logger.i("|$_tag|添加到广播记录到本地数据库,json:|${saveObj.toJson()}|"); + final setting = await AppSettingTool.getSysSetting(); + await SyncTakeFoodBroadcastTool.doCheckBroadcast( + setting.hourType ?? ProductHourType.TOTAL, + ); + } + + // 检查广播 + static Future doCheckBroadcast(ProductHourType hourType) async { + if (_isBroadcast) { + logger.i("|$_tag|检查打印,正在广播中"); + return; + } + _isBroadcast = true; + final appC = Get.find(); + final broadcastDao = appC.dataBase.getBroadcastDao; + try { + final list = await broadcastDao.getAll(hourType.code); + if (ObjectUtil.isEmptyList(list)) { + _isBroadcast = false; + logger.i("|$_tag|检查广播,没有需要广播的订单信息"); + return; + } + // 将列表分组,每组5个元素 + final int groupSize = 5; + final int totalGroups = (list.length / groupSize).ceil(); + for (int i = 0; i < totalGroups; i++) { + // 计算当前组的起始和结束索引 + final int start = i * groupSize; + final int end = (start + groupSize) < list.length + ? (start + groupSize) + : list.length; + // 获取当前组的元素 + final currentGroup = list.sublist(start, end); + // 使用 Set 去重 + final Set uniqueContents = {}; + for (final v in currentGroup) { + uniqueContents.add(v.content); + } + String msg = "请手机尾号"; + for (final content in uniqueContents) { + msg += "${TtsManage.numberToChineseReading(content)},"; + } + // 逗号会影响语音播报断句 + msg = msg.substring(0, msg.length - 1); + msg += "的用户,尽快取餐"; + // 播报当前组 + await TtsManage.speak(msg); + // 如果不是最后一组,等待一段时间再播报下一组 + if (i < totalGroups - 1) { + await Future.delayed(const Duration(seconds: 3)); + } + // 更新广播次数 + for (final v in currentGroup) { + v.broadcastCount++; + await broadcastDao.edit(v); + } + // 通知服务端 + if (AppNetworkTool.isAutoNetwork()) { + final serviceCodeList = currentGroup + .map( + (e) => e.serviceCode, + ) + .toList(); + final pa = {"ids": serviceCodeList, "count": 1}; + await HardApi.broadcast(pa); + } + } + } catch (e, stack) { + logger.e("|$_tag|检查广播,广播错误,error:|$e|stack:|$stack|"); + } finally { + _isBroadcast = false; + } + } + + /// 手动查询今天所有的广播并且更新本地数据库 + static Future queryTodayBroadcast() async { + // 检查有没有网络 + if (!AppNetworkTool.isAutoNetwork()) return; + final sysSetting = await AppSettingTool.getSysSetting(); + int currentPage = 1; + final pa = { + "current": currentPage, + "size": 100, + "hardId": sysSetting.serviceId + }; + while (true) { + final isMore = await _queryTodayBroadcastGroup(pa); + if (!isMore) break; + currentPage++; + } + } + + /// 手动查询今天所有的广播并且更新本地数据库 + static Future _queryTodayBroadcastGroup(dynamic pa) async { + final serviceList = await HardApi.broadcastToday(pa); + if (serviceList == null) return false; + final result = serviceList.total == 100; + final appC = Get.find(); + final broadcastDao = appC.dataBase.getBroadcastDao; + if (ObjectUtil.isEmptyList(serviceList.records)) { + // 删除所有数据 + if (pa["current"] == 1) { + // 根据服务码查询本地数据库 + await broadcastDao.delAll(); + } + return false; + } + // 转化为 BroadcastEntity + final saveList = serviceList.records! + .map((e) => _convertServiceBroadcastTodayToBroadcastEntity(e)) + .toList(); + // 查询全部本地数据 + final localList = await broadcastDao.findAllPage("-1", 999, 0); + final localServiceCode = localList.map((i) => i.serviceCode).toList(); + for (final i in saveList) { + if (localServiceCode.contains(i.serviceCode)) { + final target = + localList.firstWhereOrNull((j) => j.serviceCode == i.serviceCode); + if (target != null) { + i.enable = target.enable; + } + } + } + await broadcastDao.delAll(); + await broadcastDao.batchSave(saveList); + return result; + } + + /// 把 ServiceBroadcastToday 转化为 BroadcastEntity + static BroadcastEntity _convertServiceBroadcastTodayToBroadcastEntity( + ServiceBroadcastToday obj, + ) { + return BroadcastEntity( + content: obj.content, + broadcastCount: obj.broadcastCount ?? 0, + broadcastMaxCount: obj.broadcastMaxCount ?? 20, + isBroadcast: obj.isBroadcast, + isDone: false, + productDesc: obj.productDesc, + serviceCode: obj.id, + serviceOrderCode: obj.bizId!, + userId: obj.userId, + username: obj.username, + userMobile: obj.userMobile, + payPrice: obj.payPrice, + hourType: + ProductHourType.values.fromCode(obj.hourType) ?? ProductHourType.NOON, + enable: true, + createTime: DateUtil.getDateTime(obj.createTime) ?? DateTime.now(), + ); + } +} diff --git a/lib/common/utils/sync/sync_user_tool.dart b/lib/common/utils/sync/sync_user_tool.dart new file mode 100644 index 0000000..c22c695 --- /dev/null +++ b/lib/common/utils/sync/sync_user_tool.dart @@ -0,0 +1,85 @@ +import 'package:get/get.dart'; +import 'package:pj_supermarket_cloud/common/index.dart'; + +abstract class SyncUserTool { + static const _tag = "sync商品工具"; + + // 获取存储用户 + static Future storageUserByCarNumber(String cardNumber) async { + final app = Get.find(); + final userDao = app.dataBase.getUserDao; + return await userDao.findByCardNumber(cardNumber); + } + + // 获取存储用户 + static Future storageUserByUserId(String userId) async { + final app = Get.find(); + final userDao = app.dataBase.getUserDao; + return await userDao.findByUserId(userId); + } + + /// 存储用户 + static Future storageUser({required ServiceUser user}) async { + try { + final app = Get.find(); + final userDao = app.dataBase.getUserDao; + final detail = await userDao.findByUserId(user.userId!); + if (detail == null) { + final obj = _parseStorageUser(user: user, createDate: true); + await userDao.save(obj); + logger.i("|$_tag|,存储用户,新增成功|${obj.toJson()}|"); + } else { + final obj = _parseStorageUser(user: user, updateDate: true); + obj.id = detail.id; + if (obj == detail) return; + await userDao.edit(obj); + logger.i("|$_tag|,存储用户,修改成功|${obj.toJson()}|"); + } + } catch (e, stack) { + logger.e("|$_tag|,存储用户失败意外的错误error:|$e|stack:|$stack|"); + } + } + + static ServiceUser parsePayUser({ + required UserEntity user, + }) { + return ServiceUser( + userId: user.userId, + username: user.nickname, + deptId: null, + userMobile: user.mobile, + userAvatar: user.avatar, + walletId: user.walletId, + walletTypeId: user.walletTypeId, + walletBalance: user.walletBalance, + walletIsFreeze: false, + walletCardNumber: user.walletCardNumber, + canteenId: user.canteenId, + ); + } + + static UserEntity _parseStorageUser({ + required ServiceUser user, + bool createDate = false, + bool updateDate = false, + }) { + final userEntity = UserEntity( + userId: user.userId ?? "-1", + nickname: user.username ?? "未知", + mobile: user.userMobile, + avatar: user.userAvatar, + walletCardNumber: user.walletCardNumber, + walletId: user.walletId, + walletTypeId: user.walletTypeId, + walletBalance: user.walletBalance, + canteenId: user.canteenId, + ); + if (createDate) { + userEntity.createTime = DateTime.now(); + } + if (updateDate) { + userEntity.updateTime = DateTime.now(); + } + return userEntity; + } +} diff --git a/lib/common/values/constants.dart b/lib/common/values/constants.dart new file mode 100644 index 0000000..919f74a --- /dev/null +++ b/lib/common/values/constants.dart @@ -0,0 +1,32 @@ +/// 常量 +class Constants { + // oauth2 APP模式客户端信息 + static const oauth2AppClient = 'mini:mini'; + + // oauth2 登录的url + static const oauth2AppApiUrl = 'auth/oauth2/token'; + + // 前端加密密钥 + static const pwdEncKey = 'pigxpigxpigxpigx'; + + /// 服务 url 线上 + // static const apiUrl = 'https://dzc-test-1.fy.takin.cc'; + static const apiUrl = 'https://www.zianxin.com/api'; + + // static const apiUrl = 'https://dzc-test-1.fy.takin.cc'; + + /// 静态资源 url + static const ossUrl = apiUrl; + + /// 人脸识别 插件方法管道 + static const facePlugsMethodChannel = 'local/face_discern'; + + /// 人脸识别 插件回调管道 + static const facePlugsEventChannel = 'local/face_discern_event'; + + /// 数据库名字 + static const databaseName = 'cash_register.db'; + + /// 设备唯一编码 + static const deviceUniqueCode = 'EjFJx0WQ'; +} diff --git a/lib/common/values/crt.dart b/lib/common/values/crt.dart new file mode 100644 index 0000000..22c0970 --- /dev/null +++ b/lib/common/values/crt.dart @@ -0,0 +1,4 @@ +/// 证书 assets +class AssetsCrt { + static const emqxCa = "assets/crt/emqxsl-ca.crt"; +} diff --git a/lib/common/values/images.dart b/lib/common/values/images.dart new file mode 100644 index 0000000..99cbafe --- /dev/null +++ b/lib/common/values/images.dart @@ -0,0 +1,5 @@ +/// 图片 assets +class AssetsImages { + static const logo = "assets/images/logo.png"; + static const defaultAvatar = "assets/images/default_avatar.png"; +} diff --git a/lib/common/values/index.dart b/lib/common/values/index.dart new file mode 100644 index 0000000..817d5ce --- /dev/null +++ b/lib/common/values/index.dart @@ -0,0 +1,7 @@ +library; + +export './constants.dart'; +export './crt.dart'; +export './images.dart'; +export 'keys.dart'; +export './svgs.dart'; diff --git a/lib/common/values/keys.dart b/lib/common/values/keys.dart new file mode 100644 index 0000000..d46d11b --- /dev/null +++ b/lib/common/values/keys.dart @@ -0,0 +1,5 @@ +import 'package:flutter/material.dart'; + +class GlobalKeys { + static GlobalKey selectPayTypeDialogKey = GlobalKey(); +} diff --git a/lib/common/values/svgs.dart b/lib/common/values/svgs.dart new file mode 100644 index 0000000..d379140 --- /dev/null +++ b/lib/common/values/svgs.dart @@ -0,0 +1,3 @@ +/// svgs assets +class AssetsSvgs { +} diff --git a/lib/common/widgets/app/app_date_picker.dart b/lib/common/widgets/app/app_date_picker.dart new file mode 100644 index 0000000..e8eddac --- /dev/null +++ b/lib/common/widgets/app/app_date_picker.dart @@ -0,0 +1,157 @@ +import 'package:common_utils/common_utils.dart'; +import 'package:ducafe_ui_core/ducafe_ui_core.dart'; +import 'package:flutter/material.dart'; +import 'package:forui/forui.dart'; +import 'package:get/get.dart'; +import 'package:syncfusion_flutter_datepicker/datepicker.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +Widget appDatePicker( + BuildContext context, { + required Function(List?) onChnage, + required List? datetimeRange, +}) { + final theme = FTheme.of(context); + String detail = "请选择"; + if (!ObjectUtil.isEmpty(datetimeRange)) { + final sartStr = DateUtil.formatDate( + datetimeRange![0], + format: DateFormats.y_mo_d, + ); + final endStr = DateUtil.formatDate( + datetimeRange[1], + format: DateFormats.y_mo_d, + ); + detail = "$sartStr-$endStr"; + } + return TerminalToolbarControl( + child: FTile( + prefixIcon: FIcon( + FAssets.icons.calendar, + size: theme.typography.xl.fontSize, + ), + suffixIcon: FIcon( + FAssets.icons.chevronsUpDown, + size: theme.typography.sm.fontSize, + ), + title: const SizedBox.shrink(), + details: Text( + detail, + style: theme.typography.base, + ), + onPress: () async { + final result = await appShowDatePicker( + context, + datetimeRange: datetimeRange, + ); + onChnage(result); + }, + ), + ); +} + +Future?> appShowDatePicker( + BuildContext context, { + List? datetimeRange, +}) async { + final theme = FTheme.of(context); + final width = context.width; + final height = context.height; + // 默认时间为当天 + DateTime startTIme = DateTime.now(); + DateTime endTime = DateTime.now().add(const Duration(days: 1)); + if (!ObjectUtil.isEmptyList(datetimeRange)) { + startTIme = datetimeRange![0]; + endTime = datetimeRange[1]; + } + List slectDateTime = []; + return await showAdaptiveDialog?>( + context: context, + builder: (context) => FDialog( + style: theme.dialogStyle.copyWith( + insetPadding: EdgeInsets.zero, + maxWidth: width * 0.6, + minWidth: width * 0.6, + horizontalStyle: theme.dialogStyle.horizontalStyle.copyWith( + padding: EdgeInsets.all(20.w), + ), + ), + direction: Axis.horizontal, + title: const SizedBox.shrink(), + body: SfDateRangePicker( + onSelectionChanged: (date) { + if (date.value is PickerDateRange) { + final v = date.value as PickerDateRange; + if (v.startDate == null || v.endDate == null) { + return; + } + slectDateTime = [v.startDate!, v.endDate!]; + } + }, + initialSelectedDates: [startTIme, endTime], + selectionMode: DateRangePickerSelectionMode.range, + headerHeight: 60.h, + todayHighlightColor: theme.colorScheme.primary, + backgroundColor: theme.colorScheme.background, + headerStyle: DateRangePickerHeaderStyle( + textStyle: theme.typography.xl, + textAlign: TextAlign.center, + backgroundColor: theme.colorScheme.background, + ), + selectionColor: theme.colorScheme.primary, + startRangeSelectionColor: theme.colorScheme.primary, + endRangeSelectionColor: theme.colorScheme.primary, + rangeSelectionColor: theme.colorScheme.mutedForeground, + selectionTextStyle: theme.typography.sm.copyWith( + color: theme.colorScheme.secondary, + ), + rangeTextStyle: theme.typography.sm, + monthCellStyle: DateRangePickerMonthCellStyle( + textStyle: theme.typography.sm, + todayTextStyle: theme.typography.sm, + weekendTextStyle: theme.typography.sm, + ), + monthViewSettings: DateRangePickerMonthViewSettings( + viewHeaderHeight: 50.h, + showWeekNumber: true, + weekNumberStyle: DateRangePickerWeekNumberStyle( + textStyle: theme.typography.sm, + backgroundColor: theme.colorScheme.mutedForeground, + ), + viewHeaderStyle: DateRangePickerViewHeaderStyle( + textStyle: theme.typography.base, + ), + ), + ).width(width).height(height * 0.6), + actions: [ + FButton( + label: Text( + "确定", + style: theme.typography.xl.copyWith( + color: theme.colorScheme.secondary, + ), + ), + onPress: () { + if (ObjectUtil.isEmptyList(slectDateTime)) { + Toast.error(msg: "请选择日期范围"); + return; + } + Navigator.of(context).pop(slectDateTime); + }, + ).width(300.w), + SizedBox(width: 30.w), + FButton( + style: FButtonStyle.destructive, + label: Text( + "取消", + style: theme.typography.xl.copyWith( + color: theme.colorScheme.secondary, + ), + ), + onPress: () => Navigator.of(context).pop(null), + ).width(300.w), + ], + ), + ); +} diff --git a/lib/common/widgets/app/app_divider_title.dart b/lib/common/widgets/app/app_divider_title.dart new file mode 100644 index 0000000..a808c41 --- /dev/null +++ b/lib/common/widgets/app/app_divider_title.dart @@ -0,0 +1,24 @@ +import 'package:ducafe_ui_core/ducafe_ui_core.dart'; +import 'package:flutter/material.dart'; +import 'package:forui/forui.dart'; + +Widget appDividerTitle(BuildContext context, String title) { + final theme = FTheme.of(context); + return [ + FDivider( + style: theme.dividerStyles.horizontalStyle.copyWith( + padding: EdgeInsets.zero, + ), + ).flexible(flex: 1), + Text( + title, + textAlign: TextAlign.center, + style: theme.typography.xl, + ).padding(horizontal: 50.w), + FDivider( + style: theme.dividerStyles.horizontalStyle.copyWith( + padding: EdgeInsets.zero, + ), + ).flexible(flex: 1), + ].toRow(mainAxisAlignment: MainAxisAlignment.center); +} diff --git a/lib/common/widgets/app/app_empty.dart b/lib/common/widgets/app/app_empty.dart new file mode 100644 index 0000000..4eed709 --- /dev/null +++ b/lib/common/widgets/app/app_empty.dart @@ -0,0 +1,41 @@ +import 'package:ducafe_ui_core/ducafe_ui_core.dart'; +import 'package:flutter/material.dart'; +import 'package:forui/forui.dart'; + +Widget appEmpty( + BuildContext context, { + bool loading = false, + String loadingText = "正在识别中", + SvgAsset? emptyIcon, + String emptyText = "请让用户放置饭卡", +}) { + final theme = FTheme.of(context); + emptyIcon ??= FAssets.icons.idCard; + if (loading) { + return [ + CircularProgressIndicator( + color: theme.colorScheme.primary, + ).width(100.w).height(100.w).center(), + SizedBox(height: 10.h), + Text( + loadingText, + style: theme.typography.base.copyWith( + color: theme.colorScheme.primary, + ), + ), + ].toColumn(mainAxisAlignment: MainAxisAlignment.center); + } + return [ + FIcon( + emptyIcon, + size: theme.typography.xl4.fontSize, + ), + SizedBox(height: 10.h), + Text( + emptyText, + style: theme.typography.base.copyWith( + color: theme.colorScheme.primary, + ), + ), + ].toColumn(mainAxisAlignment: MainAxisAlignment.center); +} diff --git a/lib/common/widgets/app/app_hardware_input_dialog.dart b/lib/common/widgets/app/app_hardware_input_dialog.dart new file mode 100644 index 0000000..73dab40 --- /dev/null +++ b/lib/common/widgets/app/app_hardware_input_dialog.dart @@ -0,0 +1,91 @@ +import 'package:ducafe_ui_core/ducafe_ui_core.dart'; +import 'package:flutter/material.dart'; +import 'package:forui/forui.dart'; +import 'package:get/get.dart'; + +/// 没有真实硬件时需要人工补充的参数场景。 +enum HardwareInputScene { + scanCode('扫码录入', '扫码内容', '请输入条码、二维码短码或付款码'), + readCard('读取饭卡', '饭卡卡号', '请输入饭卡卡号'), + writeCard('写入饭卡', '写入卡号', '请输入本次写入的饭卡卡号'), + faceRecognition('人脸识别', '用户编号', '请输入本次识别到的用户编号'); + + final String title; + final String label; + final String hint; + + const HardwareInputScene(this.title, this.label, this.hint); +} + +/// 使用现有 Forui 弹层承接虚拟机和手动模式的硬件结果,提交前执行必填校验。 +Future appHardwareInputDialog( + BuildContext context, { + required HardwareInputScene scene, + String? initialValue, + String? description, +}) async { + final theme = FTheme.of(context); + final formKey = GlobalKey(); + final controller = TextEditingController(text: initialValue ?? ''); + final result = await showAdaptiveDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) => FDialog( + direction: Axis.horizontal, + title: [ + FIcon( + FAssets.icons.keyboard, + color: theme.colorScheme.primary, + size: theme.typography.xl2.fontSize, + ), + Text(scene.title, style: theme.typography.xl2), + ].toRowSpace(space: 12.w), + body: Form( + key: formKey, + child: FTextField( + controller: controller, + autofocus: true, + label: Text(scene.label, style: theme.typography.base), + description: Text( + description ?? '当前设备使用手动输入模式,提交后将按真实硬件结果继续处理。', + style: theme.typography.sm, + ), + hint: scene.hint, + maxLines: 1, + textInputAction: TextInputAction.done, + validator: (value) { + if (value == null || value.trim().isEmpty) { + return '请输入${scene.label}'; + } + return null; + }, + onSubmit: (_) { + if (formKey.currentState?.validate() != true) return; + Navigator.of(dialogContext).pop(controller.text.trim()); + }, + ).width(context.width * 0.5).marginSymmetric(vertical: 20.h), + ), + actions: [ + FButton( + style: FButtonStyle.outline, + label: Text('取消', style: theme.typography.base), + onPress: () => Navigator.of(dialogContext).pop(), + ), + FButton( + label: Text( + '确定', + style: theme.typography.base.copyWith( + color: theme.colorScheme.secondary, + ), + ), + onPress: () { + if (formKey.currentState?.validate() != true) return; + Navigator.of(dialogContext).pop(controller.text.trim()); + }, + ), + ], + ), + ); + controller.dispose(); + return result; +} diff --git a/lib/common/widgets/app/app_img.dart b/lib/common/widgets/app/app_img.dart new file mode 100644 index 0000000..a10794f --- /dev/null +++ b/lib/common/widgets/app/app_img.dart @@ -0,0 +1,92 @@ +import 'dart:io'; + +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:common_utils/common_utils.dart'; +import 'package:ducafe_ui_core/ducafe_ui_core.dart'; +import 'package:flutter/material.dart'; +import 'package:forui/forui.dart'; +import 'package:get/get.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +Widget appEmptyIcon( + BuildContext context, { + required double height, +}) { + final theme = FTheme.of(context); + return FIcon( + FAssets.icons.globeLock, + color: theme.colorScheme.error, + size: theme.typography.base.fontSize, + ).center().width(height).height(height); +} + +Widget appNetworkImg( + String src, + double errorSize, { + double imgWidth = 60, + double imgHeight = 60, + BoxFit fit = BoxFit.cover, +}) { + return SizedBox( + width: imgWidth, + height: imgHeight, + child: CachedNetworkImage( + imageUrl: AppNetworkTool.ossImage(src), + fit: fit, + placeholder: (context, url) => CircularProgressIndicator() + .center() + .width(imgHeight) + .height(imgHeight), + errorWidget: (context, url, error) { + return appEmptyIcon(context, height: imgHeight); + }, + ), + ); +} + +Widget appFileImg( + String str, + double errorSize, { + double imgWidth = 60, + double imgHeight = 60, + BoxFit fit = BoxFit.cover, +}) { + return Image.file( + File(str), + fit: fit, + width: imgWidth, + height: imgHeight, + errorBuilder: (BuildContext context, Object error, StackTrace? stackTrace) { + return appEmptyIcon(context, height: imgHeight); + }, + ); +} + +Widget appImg( + String? src, { + double width = 100, + double height = 100, + double errorSize = 20, + BoxFit fit = BoxFit.contain, +}) { + if (ObjectUtil.isEmpty(src)) { + return appEmptyIcon(Get.context!, height: height); + } else { + return AppNetworkTool.isNetworkImage(src!) + ? appNetworkImg( + src, + errorSize, + imgWidth: double.infinity, + imgHeight: height, + fit: fit, + ) + : appFileImg( + src, + errorSize, + imgWidth: double.infinity, + imgHeight: height, + fit: fit, + ); + } +} diff --git a/lib/common/widgets/app/app_message_dialog.dart b/lib/common/widgets/app/app_message_dialog.dart new file mode 100644 index 0000000..6823cd5 --- /dev/null +++ b/lib/common/widgets/app/app_message_dialog.dart @@ -0,0 +1,48 @@ +import 'package:ducafe_ui_core/ducafe_ui_core.dart'; +import 'package:flutter/material.dart'; +import 'package:forui/forui.dart'; +import 'package:get/get.dart'; + +Future appMessageDialog(BuildContext context, String msg) async { + final theme = FTheme.of(context); + final res = await showAdaptiveDialog( + context: context, + builder: (context) => FDialog( + direction: Axis.vertical, + title: [ + FIcon( + FAssets.icons.circleAlert, + color: theme.colorScheme.primary, + size: theme.typography.xl2.fontSize, + ).paddingOnly(top: 5.h), + Text("警告", style: theme.typography.xl).paddingOnly(left: 10.w), + ].toRow(crossAxisAlignment: CrossAxisAlignment.center), + body: Text(msg, style: theme.typography.xl) + .width(context.width * 0.6) + .padding(vertical: 50.w) + .marginSymmetric( + vertical: 10.h, + ), + actions: [ + [ + FButton( + label: Text( + '确定', + style: theme.typography.base.copyWith( + color: theme.colorScheme.secondary, + ), + ), + onPress: () => Navigator.of(context).pop(true), + ).flexible(flex: 1), + SizedBox(width: 10.w), + FButton( + style: FButtonStyle.outline, + label: Text('取消', style: theme.typography.base), + onPress: () => Navigator.of(context).pop(false), + ).flexible(flex: 1), + ].toRow(), + ], + ), + ); + return res ?? false; +} diff --git a/lib/common/widgets/app/app_page.dart b/lib/common/widgets/app/app_page.dart new file mode 100644 index 0000000..b97f433 --- /dev/null +++ b/lib/common/widgets/app/app_page.dart @@ -0,0 +1,181 @@ +import 'package:ducafe_ui_core/ducafe_ui_core.dart'; +import 'package:flutter/material.dart'; +import 'package:forui/forui.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +Widget appPage( + BuildContext context, { + required int pageNo, + required int pageSize, + required int total, + required Function(int) onPageChanged, + String itemLabel = '条数据', +}) { + return TerminalPagination( + currentPage: pageNo, + pageSize: pageSize, + total: total, + itemLabel: itemLabel, + onPageChanged: onPageChanged, + ); +} + +/// 对齐主界面原型的圆角分页条,不依赖第三方分页组件的固定视觉实现。 +class TerminalPagination extends StatelessWidget { + const TerminalPagination({ + super.key, + required this.currentPage, + required this.pageSize, + required this.total, + required this.onPageChanged, + this.itemLabel = '条数据', + }); + + final int currentPage; + final int pageSize; + final int total; + final String itemLabel; + final ValueChanged onPageChanged; + + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + final totalPages = total == 0 ? 1 : (total / pageSize).ceil(); + return LayoutBuilder( + builder: (context, constraints) { + final buttonWidth = 48.w; + final available = + (constraints.maxWidth - 260.w).clamp(0, double.infinity); + final pageCount = + ((available / buttonWidth).floor() - 2).clamp(5, 6).toInt(); + return Container( + height: 76.h, + padding: EdgeInsets.symmetric(horizontal: 20.w), + decoration: BoxDecoration( + color: XfjDesignTokens.surface, + border: Border.all(color: XfjDesignTokens.line), + borderRadius: BorderRadius.circular(XfjDesignTokens.cardRadius), + boxShadow: XfjDesignTokens.surfaceShadow, + ), + child: Row( + children: [ + Text( + '共 $total $itemLabel', + style: theme.typography.lg.copyWith( + color: XfjDesignTokens.muted, + fontWeight: FontWeight.w500, + ), + ), + const Spacer(), + if (total > 0) + Row( + children: [ + _PageButton( + label: '‹', + enabled: currentPage > 1, + onTap: () => onPageChanged(currentPage - 1), + ), + ..._visibleItems(currentPage, totalPages, pageCount).map( + (page) => page == 0 + ? const _PageEllipsis() + : _PageButton( + label: '$page', + selected: page == currentPage, + onTap: () => onPageChanged(page), + ), + ), + _PageButton( + label: '›', + enabled: currentPage < totalPages, + onTap: () => onPageChanged(currentPage + 1), + ), + ], + ), + ], + ), + ); + }, + ); + } + + List _visibleItems(int current, int totalPages, int count) { + if (totalPages <= count) { + return List.generate(totalPages, (index) => index + 1); + } + if (current <= count - 2) { + return [...List.generate(count - 1, (index) => index + 1), 0, totalPages]; + } + if (current >= totalPages - count + 3) { + return [ + 1, + 0, + ...List.generate(count - 1, (index) => totalPages - count + 2 + index), + ]; + } + return [1, 0, current - 1, current, current + 1, 0, totalPages]; + } +} + +class _PageButton extends StatelessWidget { + const _PageButton({ + required this.label, + required this.onTap, + this.selected = false, + this.enabled = true, + }); + + final String label; + final VoidCallback onTap; + final bool selected; + final bool enabled; + + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + final buttonSize = 52.h; + return Opacity( + opacity: enabled ? 1 : 0.35, + child: InkWell( + onTap: enabled ? onTap : null, + borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), + child: Container( + constraints: BoxConstraints(minWidth: buttonSize), + height: buttonSize, + margin: EdgeInsets.symmetric(horizontal: 3.w), + alignment: Alignment.center, + decoration: BoxDecoration( + color: selected ? XfjDesignTokens.primary : Colors.transparent, + borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), + ), + child: Text( + label, + style: theme.typography.sm.copyWith( + color: selected ? Colors.white : XfjDesignTokens.muted, + fontWeight: selected ? FontWeight.w700 : FontWeight.w600, + ), + ), + ), + ), + ); + } +} + +class _PageEllipsis extends StatelessWidget { + const _PageEllipsis(); + + @override + Widget build(BuildContext context) => SizedBox( + width: 48.w, + height: 52.h, + child: Center( + child: Text( + '…', + style: FTheme.of(context).typography.base.copyWith( + color: XfjDesignTokens.muted, + fontWeight: FontWeight.w600, + ), + ), + ), + ); +} diff --git a/lib/common/widgets/app/app_price.dart b/lib/common/widgets/app/app_price.dart new file mode 100644 index 0000000..079947c --- /dev/null +++ b/lib/common/widgets/app/app_price.dart @@ -0,0 +1,39 @@ +import 'package:ducafe_ui_core/ducafe_ui_core.dart'; +import 'package:flutter/material.dart'; +import 'package:forui/forui.dart'; + +Widget appPrice( + BuildContext context, + dynamic price, { + TextStyle? textStyle, +}) { + final theme = FTheme.of(context); + textStyle ??= theme.typography.xl; + double? priceNum = price is double + ? price + : double.tryParse( + price.toString(), + ); + priceNum ??= 0; + if (priceNum != 0) { + priceNum /= 100; + } + return [ + FIcon( + FAssets.icons.japaneseYen, + color: theme.colorScheme.error, + size: theme.typography.sm.fontSize, + ), + Text( + priceNum.toStringAsFixed(2), + style: textStyle.copyWith( + color: theme.colorScheme.error, + height: 1, + ), + ), + ].toRow( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + ); +} diff --git a/lib/common/widgets/app/app_qr_code_dialog.dart b/lib/common/widgets/app/app_qr_code_dialog.dart new file mode 100644 index 0000000..3c6ee78 --- /dev/null +++ b/lib/common/widgets/app/app_qr_code_dialog.dart @@ -0,0 +1,216 @@ +import 'dart:async'; + +import 'package:ducafe_ui_core/ducafe_ui_core.dart'; +import 'package:flutter/material.dart'; +import 'package:forui/forui.dart'; +import 'package:qr_flutter/qr_flutter.dart'; + +Future showQrCodeDialog( + BuildContext context, { + required String qrData, + int countDownSeconds = 30, + required VoidCallback onFinished, + AppQrCodeDialogController? controller, +}) { + final dialogController = controller ?? AppQrCodeDialogController(); + return showDialog( + context: context, + barrierDismissible: false, + builder: (BuildContext context) { + return AppQrCodeDialog( + key: dialogController.key, + qrData: qrData, + countDownSeconds: countDownSeconds, + onFinished: onFinished, + controller: dialogController, + ); + }, + ); +} + +class AppQrCodeDialogController { + final GlobalKey<_AppQrCodeDialogState> _key = + GlobalKey<_AppQrCodeDialogState>(); + + GlobalKey<_AppQrCodeDialogState> get key => _key; + + // 支付完成 + void paySuccess() { + _key.currentState?._paySuccess(); + } + + // 取消支付 + void payCancel() { + _key.currentState?._payCancel(); + } +} + +// 二维码弹窗 +class AppQrCodeDialog extends StatefulWidget { + const AppQrCodeDialog({ + super.key, + required this.qrData, + required this.countDownSeconds, + required this.onFinished, + required this.controller, + }); + + final String qrData; + final int countDownSeconds; + final VoidCallback onFinished; + final AppQrCodeDialogController controller; + + @override + State createState() => _AppQrCodeDialogState(); +} + +class _AppQrCodeDialogState extends State { + // 当前的倒计时秒数 + late int _currentSeconds; + // 倒计时 + Timer? _countdownTimer; + // 倒计时是否运行中 + bool _isRunning = false; + // 是否支付成功 + bool _isPaySuccess = false; + + @override + void initState() { + super.initState(); + _currentSeconds = widget.countDownSeconds; + // 创建倒计时 + _createCountDown(); + } + + @override + void dispose() { + _destroyCountDown(); + super.dispose(); + } + + // 支付成功 + void _paySuccess() { + _destroyCountDown(); + setState(() { + _isPaySuccess = true; + }); + // 延迟2s + Future.delayed(const Duration(seconds: 3), () { + Navigator.of(context).pop(); + }); + } + + // 取消支付 + void _payCancel() { + _destroyCountDown(); + Navigator.of(context).pop(); + } + + // 创建倒计时 + void _createCountDown() { + if (_isRunning) return; + _isRunning = true; + _countdownTimer = Timer.periodic(const Duration(seconds: 1), (timer) { + if (_currentSeconds <= 0) { + _countdownTimer?.cancel(); + _isRunning = false; + // 倒计时结束回调 + widget.onFinished.call(); + Navigator.of(context).pop(); + return; + } + setState(() { + _currentSeconds--; + }); + }); + } + + // 销毁倒计时 + void _destroyCountDown() { + _countdownTimer?.cancel(); + _countdownTimer = null; + } + + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + + // 宽高 + final dialogWidth = MediaQuery.of(context).size.width * 0.5; + final dialogHeight = MediaQuery.of(context).size.height * 0.7; + + final qrCodeSize = dialogWidth * 0.6; + + return FDialog( + title: Text( + "扫一扫支付", + style: theme.typography.xl3, + ).center().paddingBottom(20.h), + style: theme.dialogStyle.copyWith( + insetPadding: EdgeInsets.zero, + maxWidth: dialogWidth, + minWidth: dialogHeight, + horizontalStyle: theme.dialogStyle.horizontalStyle.copyWith( + padding: EdgeInsets.all(20.w), + ), + ), + body: [ + Text( + '请打开"浙政钉"或"诗画浦江"点击右上角扫一扫图标', + style: theme.typography.xl.copyWith( + height: 1, + ), + ), + Stack( + children: [ + QrImageView( + data: widget.qrData, + version: 6, + size: qrCodeSize, + embeddedImageStyle: QrEmbeddedImageStyle( + size: Size(qrCodeSize, qrCodeSize), + ), + ).width(qrCodeSize).height(qrCodeSize), + Positioned( + left: 0, + top: 0, + right: 0, + bottom: 0, + child: [ + FIcon( + FAssets.icons.check, + size: theme.typography.xl4.fontSize, + color: Colors.white, + ) + .padding(all: 20.w) + .decorated( + color: Colors.greenAccent[200], + ) + .clipOval(), + Text( + "扫码成功", + style: theme.typography.xl3.copyWith( + color: Colors.greenAccent[200], + fontWeight: FontWeight.bold, + ), + ), + ] + .toColumn(mainAxisAlignment: MainAxisAlignment.center) + .center() + .decorated(color: Colors.black45) + .offstage(offstage: !_isPaySuccess), + ), + ], + ).padding(horizontal: 100.w, vertical: 50.h), + Text( + '请在$_currentSeconds秒内完成扫码支付', + style: theme.typography.xl2.copyWith( + color: theme.colorScheme.error, + height: 1, + ), + ), + ].toColumn(), + actions: [], + ); + } +} diff --git a/lib/common/widgets/app/app_radio_hour_type_dialog.dart b/lib/common/widgets/app/app_radio_hour_type_dialog.dart new file mode 100644 index 0000000..54d633c --- /dev/null +++ b/lib/common/widgets/app/app_radio_hour_type_dialog.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart'; +import 'package:forui/forui.dart'; +import 'package:pj_supermarket_cloud/common/index.dart'; + +Widget appRadioHourTypeDialog( + BuildContext context, + FRadioSelectGroupController controller, +) { + final theme = FTheme.of(context); + final list = ProductHourType.values.where((v) => v.code != "-1"); + return TerminalToolbarControl( + child: FSelectMenuTile( + title: Text( + "用餐时间", + style: theme.typography.base, + ), + groupController: controller, + validator: (value) => value == null ? '请选择' : null, + autoHide: true, + prefixIcon: FIcon( + FAssets.icons.sun, + size: theme.typography.xl.fontSize, + ), + menu: list.map((v) { + return FSelectTile( + title: Text( + v.title, + style: theme.typography.xl, + ), + value: v.code, + ); + }).toList(), + details: ListenableBuilder( + listenable: controller, + builder: (context, _) { + final v = controller.value.firstOrNull; + if (v == null) return const SizedBox(); + final hourType = ProductHourType.values.fromCode(v)!; + return Text( + hourType.title, + style: theme.typography.base.copyWith( + color: theme.colorScheme.primary, + ), + ); + }, + ), + ), + ); +} diff --git a/lib/common/widgets/app/app_wallet_selector.dart b/lib/common/widgets/app/app_wallet_selector.dart new file mode 100644 index 0000000..6f2efd1 --- /dev/null +++ b/lib/common/widgets/app/app_wallet_selector.dart @@ -0,0 +1,108 @@ +import 'package:common_utils/common_utils.dart'; +import 'package:ducafe_ui_core/ducafe_ui_core.dart'; +import 'package:flutter/material.dart'; +import 'package:forui/forui.dart'; +import 'package:get/get.dart'; +import 'package:pj_supermarket_cloud/common/index.dart'; + +final _successColor = Colors.greenAccent[200]; + +Widget appWalletSelector( + BuildContext context, { + required List list, + required Function(ServiceWallet) onChange, + ServiceWallet? selector, +}) { + if (ObjectUtil.isEmpty(list)) { + return const SizedBox(); + } + ServiceWallet? currentSelector = selector; + // 判断是为为自己的钱包 + if (currentSelector != null) { + final target = list.firstWhereOrNull( + (item) => item.userId == currentSelector!.userId, + ); + if (target == null) { + currentSelector = null; + } + } + currentSelector ??= list[0]; + onChange(currentSelector); + final theme = FTheme.of(context); + return list + .map((v) { + // 钱包类型映射 + final walletTypeStr = WalletType.values + .firstWhereOrNull( + (item) => item.code == v.walletTypeUniqueCode, + ) + ?.title ?? + "未知钱包"; + // 是否选中 + final isSelect = + v.walletTypeUniqueCode == currentSelector!.walletTypeUniqueCode; + + return [ + FIcon( + FAssets.icons.walletMinimal, + size: theme.typography.xl.fontSize, + ), + SizedBox(width: 10.w), + Text( + walletTypeStr, + style: theme.typography.xl.copyWith(height: 1), + ), + SizedBox(width: 10.w), + appPrice(context, v.balance), + const Spacer(), + FIcon( + FAssets.icons.circleCheck, + color: Colors.green, + size: theme.typography.xl2.fontSize, + ).offstage( + offstage: !isSelect, + ), + ] + .toRow() + .padding( + vertical: 15.h, + horizontal: 20.w, + ) + .decorated( + color: !isSelect ? theme.colorScheme.background : _successColor, + border: Border.all( + color: theme.colorScheme.primary, + width: 1, + ), + borderRadius: theme.style.borderRadius / 2, + ) + .onTap(() => onChange(v)); + }) + .toList() + .toColumnSpace(space: 10.w) + .padding( + vertical: 10.h, + horizontal: 10.w, + ); +} + + + +// list.add( +// ServiceWallet( +// id: "1", +// walletTypeUniqueCode: "1", +// userId: "1", +// walletTypeId: "1", +// balance: 100, +// ), +// ); +// list.add( +// ServiceWallet( +// id: "2", +// walletTypeUniqueCode: "2", +// userId: "2", +// walletTypeId: "2", +// balance: 200, +// ), +// ); \ No newline at end of file diff --git a/lib/common/widgets/app/terminal_shell.dart b/lib/common/widgets/app/terminal_shell.dart new file mode 100644 index 0000000..2b9e752 --- /dev/null +++ b/lib/common/widgets/app/terminal_shell.dart @@ -0,0 +1,848 @@ +import 'dart:async'; + +import 'package:ducafe_ui_core/ducafe_ui_core.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:forui/forui.dart'; +import 'package:get/get.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +enum TerminalNavType { + cashier, + reservation, + verification, + transactions, + statistics, + card, + settings, +} + +extension on TerminalNavType { + TerminalMenuItemType get menuType => switch (this) { + TerminalNavType.cashier => TerminalMenuItemType.cashier, + TerminalNavType.reservation => TerminalMenuItemType.reservation, + TerminalNavType.verification => TerminalMenuItemType.verification, + TerminalNavType.transactions => TerminalMenuItemType.transactions, + TerminalNavType.statistics => TerminalMenuItemType.statistics, + TerminalNavType.card => TerminalMenuItemType.card, + TerminalNavType.settings => TerminalMenuItemType.settings, + }; +} + +/// 对应 React 原型 `TerminalLayout` 的 Flutter 公共终端框架。 +/// +/// 顶部设备状态和左侧一级导航必须常驻,业务页面只负责右侧工作区,避免每个页面 +/// 自己创建返回标题栏后破坏收银终端的连续操作体验。 +class TerminalShell extends StatelessWidget { + const TerminalShell({ + super.key, + required this.title, + required this.activeNav, + required this.child, + this.headerLeading, + }); + + final String title; + final TerminalNavType activeNav; + final Widget child; + final Widget? headerLeading; + + @override + Widget build(BuildContext context) { + final menuController = Get.find(); + return ColoredBox( + color: XfjDesignTokens.canvas, + child: SafeArea( + child: Column( + children: [ + TerminalHeader(title: title, leading: headerLeading), + Padding( + padding: EdgeInsets.all(16.w), + child: Obx( + () => Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (menuController.hasVisibleMenus) ...[ + TerminalSideNav(active: activeNav), + SizedBox(width: XfjDesignTokens.cardGap.w), + ], + child.expanded(), + ], + ), + ), + ).expanded(), + ], + ), + ), + ); + } +} + +class TerminalHeader extends StatelessWidget { + const TerminalHeader({super.key, required this.title, this.leading}); + + final String title; + final Widget? leading; + + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + return Container( + height: 80.h, + padding: EdgeInsets.symmetric(horizontal: 32.w), + decoration: const BoxDecoration( + color: XfjDesignTokens.surface, + border: Border(bottom: BorderSide(color: XfjDesignTokens.line)), + ), + child: Row( + children: [ + const TerminalBrand(), + SizedBox(width: 20.w), + Container(width: 1, height: 36.h, color: XfjDesignTokens.line), + SizedBox(width: 20.w), + Text( + title, + style: theme.typography.xl.copyWith( + color: XfjDesignTokens.ink, + fontWeight: FontWeight.w800, + ), + ), + if (leading != null) ...[SizedBox(width: 36.w), leading!], + const Spacer(), + Container( + padding: EdgeInsets.all(4.w), + decoration: BoxDecoration( + color: XfjDesignTokens.surface, + border: Border.all(color: XfjDesignTokens.line), + borderRadius: BorderRadius.circular( + XfjDesignTokens.controlRadius, + ), + ), + child: Row( + children: [ + const TerminalNetworkStatus(), + TerminalStatusPill( + icon: FAssets.icons.printer, + label: '打印机', + showBorder: false, + ), + TerminalStatusPill( + icon: FAssets.icons.scale, + label: '电子秤', + showBorder: false, + ), + TerminalStatusPill( + icon: FAssets.icons.idCard, + label: '读卡器', + showBorder: false, + ), + TerminalStatusPill( + icon: FAssets.icons.monitor, + label: '副屏', + active: AppSubScreenTool.isAvailable, + showBorder: false, + ), + const _TerminalAdminLockStatus(), + ], + ), + ), + SizedBox(width: 24.w), + FutureBuilder( + future: AppSettingTool.getUserSetting(), + builder: (context, snapshot) => Text( + snapshot.data?.username ?? '收银员', + style: theme.typography.base.copyWith( + color: XfjDesignTokens.ink, + fontWeight: FontWeight.w600, + ), + ), + ), + SizedBox(width: 20.w), + Container(width: 1, height: 32.h, color: XfjDesignTokens.line), + SizedBox(width: 20.w), + FIcon(FAssets.icons.clock, color: XfjDesignTokens.ink), + SizedBox(width: 8.w), + const _TerminalClock(), + SizedBox(width: 16.w), + TerminalSoftButton( + label: '设置', + icon: FAssets.icons.settings, + onTap: () => TerminalNavigationTool.open(RouteNames.setting), + ), + SizedBox(width: 8.w), + TerminalSoftButton( + label: '退出', + icon: FAssets.icons.logOut, + danger: true, + onTap: TerminalNavigationTool.logout, + ), + ], + ), + ); + } +} + +/// 顶栏品牌字标需要保持固定比例,不能跟随全局字号缩放无限放大。 +/// +/// 这里使用受约束的 `FittedBox` 保留 React 原型中的斜体、紧字距效果,同时避免 +/// 大字号设置把品牌挤出 80 高的终端顶栏。 +class TerminalBrand extends StatelessWidget { + const TerminalBrand({super.key}); + + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + return SizedBox( + width: 112.w, + height: 54.h, + child: FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerLeft, + child: Text( + 'ZAX', + style: theme.typography.xl4.copyWith( + color: const Color(0xFF1268E8), + fontWeight: FontWeight.w900, + fontStyle: FontStyle.italic, + letterSpacing: -2, + ), + ), + ), + ); + } +} + +class TerminalNetworkStatus extends StatefulWidget { + const TerminalNetworkStatus({super.key}); + + @override + State createState() => _TerminalNetworkStatusState(); +} + +class _TerminalNetworkStatusState extends State { + static AppNetworkSnapshot? _lastSnapshot; + static DateTime? _lastSampleTime; + static String _cachedDownloadSpeed = '0 B/s'; + static String _cachedUploadSpeed = '0 B/s'; + static double _smoothedDownload = 0; + static double _smoothedUpload = 0; + + Timer? _timer; + String _networkType = '网络'; + String _downloadSpeed = '0 B/s'; + String _uploadSpeed = '0 B/s'; + bool _connected = false; + + @override + void initState() { + super.initState(); + _refresh(); + _timer = Timer.periodic(const Duration(seconds: 3), (_) => _refresh()); + } + + @override + void dispose() { + _timer?.cancel(); + super.dispose(); + } + + Future _refresh() async { + final current = await AppRuntimeTool.getNetworkSnapshot(); + final flutterConnected = await AppNetworkTool.isConnectedNow(); + final flutterType = await AppNetworkTool.getNetworkTypeNow(); + final now = DateTime.now(); + final previous = _lastSnapshot; + final previousTime = _lastSampleTime; + if (!mounted) return; + setState(() { + // 原生 activeNetwork 在部分模拟器中可能为空,在线状态以 Flutter 监听结果兜底。 + _connected = current.connected || flutterConnected; + _networkType = _networkLabel( + current.type == 'offline' && flutterConnected + ? flutterType + : current.type, + ); + if (previous != null && previousTime != null && _connected) { + final seconds = now.difference(previousTime).inMilliseconds / 1000; + if (seconds > 0) { + final download = + (current.receivedBytes - previous.receivedBytes) / seconds; + final upload = + (current.transmittedBytes - previous.transmittedBytes) / seconds; + // 短周期流量天然会在 0 和峰值间跳变,平滑后更适合终端状态栏阅读。 + _smoothedDownload = _smoothSpeed(_smoothedDownload, download); + _smoothedUpload = _smoothSpeed(_smoothedUpload, upload); + _cachedDownloadSpeed = _formatSpeed(_smoothedDownload); + _cachedUploadSpeed = _formatSpeed(_smoothedUpload); + } + _downloadSpeed = _cachedDownloadSpeed; + _uploadSpeed = _cachedUploadSpeed; + } else { + // 页面切换重建顶栏时继续显示最近一次测速,避免第一帧错误归零。 + _downloadSpeed = _cachedDownloadSpeed; + _uploadSpeed = _cachedUploadSpeed; + } + _lastSnapshot = current; + _lastSampleTime = now; + }); + } + + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + return Container( + width: 410.w, + constraints: BoxConstraints(minHeight: 48.h), + padding: EdgeInsets.symmetric(horizontal: 12.w), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + FIcon( + _networkIcon(_networkType), + size: 19.w, + color: _connected ? XfjDesignTokens.ink : XfjDesignTokens.warning, + ), + SizedBox(width: 7.w), + Container( + width: 8.w, + height: 8.w, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: _connected + ? XfjDesignTokens.success + : XfjDesignTokens.warning, + ), + ), + SizedBox(width: 7.w), + SizedBox( + width: 124.w, + child: Text( + _connected ? '$_networkType在线' : '网络离线', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.typography.sm.copyWith(color: XfjDesignTokens.ink), + ), + ), + SizedBox(width: 8.w), + Expanded( + child: Text( + '↓$_downloadSpeed ↑$_uploadSpeed', + maxLines: 1, + overflow: TextOverflow.clip, + style: theme.typography.xs.copyWith( + color: XfjDesignTokens.muted, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + ), + ], + ), + ); + } + + String _networkLabel(String type) => switch (type) { + 'ethernet' => '网线', + 'wifi' => 'WiFi', + '2g' => '2G', + '3g' => '3G', + '4g' => '4G', + '5g' => '5G', + 'mobile' => '移动网络', + _ => '网络', + }; + + SvgAsset _networkIcon(String label) { + if (label == 'WiFi') return FAssets.icons.wifi; + if (label == '网线') return FAssets.icons.network; + return FAssets.icons.signal; + } + + String _formatSpeed(double bytesPerSecond) { + final speed = bytesPerSecond < 0 ? 0 : bytesPerSecond; + if (speed >= 1024 * 1024) { + return '${(speed / 1024 / 1024).toStringAsFixed(1)} MB/s'; + } + if (speed >= 1024) return '${(speed / 1024).toStringAsFixed(1)} KB/s'; + return '${speed.toStringAsFixed(0)} B/s'; + } + + double _smoothSpeed(double previous, double current) { + final normalized = current < 0 ? 0.0 : current; + if (previous == 0) return normalized; + return previous * 0.65 + normalized * 0.35; + } +} + +class TerminalSideNav extends StatelessWidget { + const TerminalSideNav({super.key, required this.active}); + + final TerminalNavType active; + + @override + Widget build(BuildContext context) { + final controller = Get.find(); + return Obx( + () { + final visibleItems = controller.menuOrder + .where(controller.isVisible) + .map(_terminalNavItem) + .toList(); + final showLogout = controller.isVisible(TerminalMenuItemType.logout); + return TerminalSurface( + // 项目支持全局字号缩放,原先 124 的侧栏无法同时容纳图标、间距和中文菜单。 + // 这里适当加宽并让整行参与点击,避免字号放大后出现溢出或难以点中的窄区域。 + width: 164.w, + padding: EdgeInsets.all(12.w), + child: Column( + children: [ + ...visibleItems.map((item) { + final selected = item.type == active.menuType; + return _TerminalNavButton( + item: item, + selected: selected, + onTap: selected + ? null + : () => TerminalNavigationTool.open(item.route), + ).marginOnly(bottom: 8.h); + }), + const Spacer(), + if (showLogout) + _TerminalNavButton( + item: _terminalNavItem(TerminalMenuItemType.logout), + danger: true, + onTap: TerminalNavigationTool.logout, + ), + ], + ), + ); + }, + ); + } +} + +_TerminalNavItem _terminalNavItem(TerminalMenuItemType type) => switch (type) { + TerminalMenuItemType.cashier => _TerminalNavItem( + type: type, + label: type.title, + icon: type.icon, + route: RouteNames.cashier, + ), + TerminalMenuItemType.reservation => _TerminalNavItem( + type: type, + label: type.title, + icon: type.icon, + route: RouteNames.canteenReserve, + ), + TerminalMenuItemType.verification => _TerminalNavItem( + type: type, + label: type.title, + icon: type.icon, + route: RouteNames.takeFood, + ), + TerminalMenuItemType.transactions => _TerminalNavItem( + type: type, + label: type.title, + icon: type.icon, + route: RouteNames.orderHistory, + ), + TerminalMenuItemType.statistics => _TerminalNavItem( + type: type, + label: type.title, + icon: type.icon, + route: RouteNames.canteenOrderTotal, + ), + TerminalMenuItemType.card => _TerminalNavItem( + type: type, + label: type.title, + icon: type.icon, + route: RouteNames.cardActive, + ), + TerminalMenuItemType.settings => _TerminalNavItem( + type: type, + label: type.title, + icon: type.icon, + route: RouteNames.setting, + ), + TerminalMenuItemType.logout => _TerminalNavItem( + type: type, + label: type.title, + icon: type.icon, + route: RouteNames.login, + ), + }; + +class TerminalSurface extends StatelessWidget { + const TerminalSurface({ + super.key, + required this.child, + this.padding, + this.width, + this.height, + this.color = XfjDesignTokens.surface, + this.clipBehavior = Clip.none, + }); + + final Widget child; + final EdgeInsetsGeometry? padding; + final double? width; + final double? height; + final Color color; + final Clip clipBehavior; + + @override + Widget build(BuildContext context) { + return Container( + width: width, + height: height, + padding: padding, + clipBehavior: clipBehavior, + decoration: BoxDecoration( + color: color, + border: Border.all(color: XfjDesignTokens.line), + borderRadius: BorderRadius.circular(XfjDesignTokens.cardRadius), + boxShadow: XfjDesignTokens.surfaceShadow, + ), + child: child, + ); + } +} + +class TerminalStatusPill extends StatelessWidget { + const TerminalStatusPill({ + super.key, + required this.label, + this.icon, + this.active = true, + this.showBorder = true, + }); + + final String label; + final SvgAsset? icon; + final bool active; + final bool showBorder; + + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + final sizeScale = XfjDesignTokens.buttonSizeScale; + return Container( + height: 36.h, + padding: EdgeInsets.symmetric(horizontal: 12.w * sizeScale), + decoration: BoxDecoration( + color: XfjDesignTokens.surface, + border: showBorder ? Border.all(color: XfjDesignTokens.line) : null, + borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) ...[ + FIcon(icon!, size: 19.w, color: XfjDesignTokens.ink), + SizedBox(width: 8.w), + ], + Container( + width: 8.w, + height: 8.w, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: active ? XfjDesignTokens.success : XfjDesignTokens.warning, + ), + ), + SizedBox(width: 8.w), + Text( + label, + style: theme.typography.sm.copyWith( + color: XfjDesignTokens.ink, + ), + ), + ], + ), + ); + } +} + +/// 顶栏仅监听管理会话秒数,避免倒计时导致整个终端页面每秒重建。 +class _TerminalAdminLockStatus extends StatelessWidget { + const _TerminalAdminLockStatus(); + + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + final session = Get.find(); + return Obx(() { + // 读取响应式秒数触发局部刷新,真实有效性仍以绝对过期时间为准。 + final seconds = session.remainingSeconds.value; + final unlocked = session.isUnlocked && seconds > 0; + final stateColor = + unlocked ? XfjDesignTokens.success : XfjDesignTokens.muted; + final stateIcon = unlocked ? FAssets.icons.lockOpen : FAssets.icons.lock; + final actionIcon = unlocked ? FAssets.icons.lock : FAssets.icons.lockOpen; + final label = unlocked ? session.remainingLabel : '已锁定'; + return Semantics( + container: true, + label: unlocked ? '管理操作已解锁,剩余$label' : '管理操作已锁定', + child: Container( + width: 164.w, + height: 40.h, + padding: EdgeInsets.only(left: 10.w, right: 4.w), + decoration: BoxDecoration( + color: unlocked ? const Color(0xFFF0FAF6) : XfjDesignTokens.control, + borderRadius: BorderRadius.circular( + XfjDesignTokens.controlRadius, + ), + ), + child: Row( + children: [ + FIcon(stateIcon, size: 19.w, color: stateColor), + SizedBox(width: 8.w), + Expanded( + child: Text( + label, + maxLines: 1, + textAlign: TextAlign.center, + style: theme.typography.sm.copyWith( + color: unlocked ? XfjDesignTokens.ink : stateColor, + fontWeight: FontWeight.w700, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + ), + SizedBox(width: 4.w), + Semantics( + button: true, + label: unlocked ? '立即锁定管理操作' : '解锁管理操作', + child: Material( + color: XfjDesignTokens.surface, + borderRadius: BorderRadius.circular( + XfjDesignTokens.controlRadius, + ), + child: InkWell( + onTap: () { + if (unlocked) { + session.lock(); + HapticFeedback.selectionClick(); + return; + } + TerminalNavigationTool.unlockAdminSession(); + }, + borderRadius: BorderRadius.circular( + XfjDesignTokens.controlRadius, + ), + child: SizedBox( + width: 40.w, + height: 40.h, + child: FIcon( + actionIcon, + size: 19.w, + color: unlocked + ? XfjDesignTokens.danger + : XfjDesignTokens.primary, + ).center(), + ), + ), + ), + ), + ], + ), + ), + ); + }); + } +} + +class TerminalSoftButton extends StatelessWidget { + const TerminalSoftButton({ + super.key, + required this.label, + required this.onTap, + this.icon, + this.danger = false, + }); + + final String label; + final SvgAsset? icon; + final VoidCallback onTap; + final bool danger; + + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + final sizeScale = XfjDesignTokens.buttonSizeScale; + final foreground = danger ? XfjDesignTokens.danger : XfjDesignTokens.ink; + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), + child: Container( + constraints: BoxConstraints(minHeight: 48.h), + padding: EdgeInsets.symmetric(horizontal: 16.w * sizeScale), + decoration: BoxDecoration( + color: danger ? const Color(0xFFFFF3F2) : const Color(0xFFF7F8FA), + border: Border.all( + color: danger + ? XfjDesignTokens.danger.withValues(alpha: 0.35) + : XfjDesignTokens.line, + ), + borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) ...[ + FIcon(icon!, size: 21.w, color: foreground), + SizedBox(width: 8.w), + ], + Text( + label, + style: theme.typography.base.copyWith( + color: foreground, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ); + } +} + +/// 收银页顶部只展示当前设备模式,不在页面内动态切换两套硬件 Controller。 +class TerminalModeBadge extends StatelessWidget { + const TerminalModeBadge({super.key, required this.label}); + + final String label; + + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + final sizeScale = XfjDesignTokens.buttonSizeScale; + return Container( + height: 44.h, + padding: EdgeInsets.symmetric(horizontal: 18.w * sizeScale), + alignment: Alignment.center, + decoration: BoxDecoration( + color: XfjDesignTokens.primarySoft, + border: Border.all(color: XfjDesignTokens.primary), + borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), + ), + child: Text( + label, + style: theme.typography.base.copyWith( + color: XfjDesignTokens.primary, + fontWeight: FontWeight.w700, + ), + ), + ); + } +} + +class _TerminalNavItem { + const _TerminalNavItem({ + required this.type, + required this.label, + required this.icon, + required this.route, + }); + + final TerminalMenuItemType type; + final String label; + final SvgAsset icon; + final String route; +} + +class _TerminalNavButton extends StatelessWidget { + const _TerminalNavButton({ + required this.item, + required this.onTap, + this.selected = false, + this.danger = false, + }); + + final _TerminalNavItem item; + final VoidCallback? onTap; + final bool selected; + final bool danger; + + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + final sizeScale = XfjDesignTokens.buttonSizeScale; + final color = danger + ? XfjDesignTokens.danger + : selected + ? XfjDesignTokens.primary + : const Color(0xFF344054); + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), + child: Container( + width: double.infinity, + constraints: BoxConstraints(minHeight: 60.h), + padding: EdgeInsets.symmetric(horizontal: 12.w * sizeScale), + decoration: BoxDecoration( + color: + selected ? XfjDesignTokens.primarySoft : XfjDesignTokens.surface, + border: danger ? Border.all(color: XfjDesignTokens.line) : null, + borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), + ), + child: Row( + children: [ + FIcon(item.icon, size: 26.w, color: color), + SizedBox(width: 12.w), + Expanded( + child: Text( + item.label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.typography.base.copyWith( + color: color, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ), + ); + } +} + +class _TerminalClock extends StatefulWidget { + const _TerminalClock(); + + @override + State<_TerminalClock> createState() => _TerminalClockState(); +} + +class _TerminalClockState extends State<_TerminalClock> { + late DateTime _now; + Timer? _timer; + + @override + void initState() { + super.initState(); + _now = DateTime.now(); + _timer = Timer.periodic(const Duration(seconds: 30), (_) { + if (mounted) setState(() => _now = DateTime.now()); + }); + } + + @override + void dispose() { + _timer?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final hour = _now.hour.toString().padLeft(2, '0'); + final minute = _now.minute.toString().padLeft(2, '0'); + return Text( + '$hour:$minute', + style: FTheme.of(context).typography.lg.copyWith( + color: XfjDesignTokens.ink, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ); + } +} diff --git a/lib/common/widgets/app/terminal_toolbar.dart b/lib/common/widgets/app/terminal_toolbar.dart new file mode 100644 index 0000000..47cbfab --- /dev/null +++ b/lib/common/widgets/app/terminal_toolbar.dart @@ -0,0 +1,108 @@ +import 'package:ducafe_ui_core/ducafe_ui_core.dart'; +import 'package:flutter/material.dart'; +import 'package:forui/forui.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +enum TerminalToolbarButtonStyle { primary, secondary, danger } + +/// 统一搜索、重置、删除、扫码等工具操作的高度,避免主题尺寸变化后相邻控件错位。 +class TerminalToolbarButton extends StatelessWidget { + const TerminalToolbarButton({ + super.key, + required this.label, + required this.onTap, + this.icon, + this.style = TerminalToolbarButtonStyle.primary, + this.minWidth, + }); + + final String label; + final VoidCallback? onTap; + final SvgAsset? icon; + final TerminalToolbarButtonStyle style; + final double? minWidth; + + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + final enabled = onTap != null; + final background = switch (style) { + TerminalToolbarButtonStyle.primary => XfjDesignTokens.primary, + TerminalToolbarButtonStyle.secondary => XfjDesignTokens.surface, + TerminalToolbarButtonStyle.danger => const Color(0xFFFFF3F2), + }; + final foreground = switch (style) { + TerminalToolbarButtonStyle.primary => Colors.white, + TerminalToolbarButtonStyle.secondary => XfjDesignTokens.ink, + TerminalToolbarButtonStyle.danger => XfjDesignTokens.danger, + }; + final border = switch (style) { + TerminalToolbarButtonStyle.primary => XfjDesignTokens.primary, + TerminalToolbarButtonStyle.secondary => XfjDesignTokens.line, + TerminalToolbarButtonStyle.danger => + XfjDesignTokens.danger.withValues(alpha: 0.35), + }; + return Opacity( + opacity: enabled ? 1 : 0.45, + child: Material( + color: background, + borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), + child: Container( + height: XfjDesignTokens.controlHeight.h, + constraints: BoxConstraints(minWidth: minWidth ?? 92.w), + padding: EdgeInsets.symmetric( + horizontal: 18.w * XfjDesignTokens.buttonSizeScale, + ), + decoration: BoxDecoration( + border: Border.all(color: border), + borderRadius: + BorderRadius.circular(XfjDesignTokens.controlRadius), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (icon != null) ...[ + FIcon(icon!, size: 19.sp, color: foreground), + SizedBox(width: 8.w), + ], + Flexible( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.typography.base.copyWith( + color: foreground, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ), + ), + ), + ); + } +} + +/// 给输入框、时间和餐段选择器提供与按钮一致的外部高度约束。 +class TerminalToolbarControl extends StatelessWidget { + const TerminalToolbarControl({super.key, required this.child, this.width}); + + final Widget child; + final double? width; + + @override + Widget build(BuildContext context) { + return SizedBox( + width: width, + height: XfjDesignTokens.controlHeight.h, + child: child, + ); + } +} diff --git a/lib/common/widgets/card/card_common.dart b/lib/common/widgets/card/card_common.dart new file mode 100644 index 0000000..3ba7398 --- /dev/null +++ b/lib/common/widgets/card/card_common.dart @@ -0,0 +1,80 @@ +import 'package:ducafe_ui_core/ducafe_ui_core.dart'; +import 'package:flutter/material.dart'; +import 'package:forui/forui.dart'; +import 'package:get/get.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +Future cardDialog({ + required String title, + required Rx loading, + required VoidCallback doSubmit, + required Rx detail, +}) async { + showDialog( + context: Get.context!, + barrierDismissible: false, + builder: (BuildContext context) { + final width = context.width; + final height = context.height; + final theme = FTheme.of(context); + final textStyle = theme.typography.xl; + final titleTheme = theme.tileGroupStyle.tileStyle.copyWith( + border: Border.all( + color: Colors.transparent, + ), + ); + return FDialog( + direction: Axis.vertical, + title: Text(title, style: theme.typography.lg), + body: [ + Obx( + () => Visibility( + visible: detail.value == null, + replacement: [ + FTile( + style: titleTheme, + prefixIcon: FIcon(FAssets.icons.user), + title: Text('姓名', style: textStyle), + details: Text( + "${detail.value?.username}", + style: textStyle, + ), + ), + FTile( + style: titleTheme, + prefixIcon: FIcon(FAssets.icons.badgeDollarSign), + title: Text('卡号', style: textStyle), + details: Text( + "${detail.value?.cardNumber}", + style: textStyle, + ), + ), + ].toColumn( + mainAxisAlignment: MainAxisAlignment.center, + ), + child: appEmpty( + context, + loading: loading.value, + loadingText: "扫描中", + emptyText: "请让用户放置饭卡", + emptyIcon: FAssets.icons.idCard, + ), + ), + ).width(width * 0.5).height(height * 0.5), + ].toColumn(), + actions: [ + FButton( + label: Text( + "确定", + style: theme.typography.xl.copyWith( + color: theme.colorScheme.secondary, + ), + ), + onPress: () => doSubmit(), + ).flexible(flex: 1), + ], + ); + }, + ); +} diff --git a/lib/common/widgets/cashier/cashier_workspace.dart b/lib/common/widgets/cashier/cashier_workspace.dart new file mode 100644 index 0000000..1b6906f --- /dev/null +++ b/lib/common/widgets/cashier/cashier_workspace.dart @@ -0,0 +1,1556 @@ +import 'package:ducafe_ui_core/ducafe_ui_core.dart'; +import 'package:flutter/material.dart'; +import 'package:forui/forui.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +/// 收银商品区使用的统一展示模型,避免超市和食堂页面各自复制一整套卡片布局。 +class CashierProductData { + const CashierProductData({ + required this.id, + required this.name, + required this.image, + required this.price, + this.promotionPrice, + this.barcode, + this.stock, + this.badge, + }); + + final String id; + final String name; + final String? image; + final int price; + final int? promotionPrice; + final String? barcode; + final String? stock; + final String? badge; +} + +/// `#/main` 的公共收银工作区。 +/// +/// 页面只负责把原 Controller 数据适配进来;订单、支付、称重、硬件和副屏状态仍由 +/// 原有 Controller 管理,UI 重画不会复制或改变业务状态机。 +class CashierWorkspace extends StatelessWidget { + const CashierWorkspace({ + super.key, + required this.categories, + required this.selectedCategory, + required this.onCategoryChanged, + required this.products, + required this.onProductTap, + required this.cartItems, + required this.user, + required this.totalPrice, + required this.extraPrice, + required this.productCount, + required this.onQuantityChanged, + required this.onRemoveCartItem, + required this.onClearCart, + required this.onPay, + required this.onCancel, + this.searchController, + this.searchFocusNode, + this.searchHint = '搜索商品、自编码或扫码', + this.onSearch, + this.onReset, + this.onScan, + this.headerTrailing, + this.productAreaReplacement, + this.totalProductCount, + this.currentPage, + this.pageSize, + this.onPageChanged, + this.onWeight, + this.onHold, + this.onMore, + this.cartTitle = '已选商品', + this.showScaleCard = false, + }); + + final List categories; + final int selectedCategory; + final ValueChanged onCategoryChanged; + final List products; + final ValueChanged onProductTap; + final List cartItems; + final ServiceUser? user; + final int totalPrice; + final int extraPrice; + final int productCount; + final void Function(ShopCardEntity item, int quantity) onQuantityChanged; + final ValueChanged onRemoveCartItem; + final VoidCallback onClearCart; + final VoidCallback onPay; + final VoidCallback onCancel; + final TextEditingController? searchController; + final FocusNode? searchFocusNode; + final String searchHint; + final VoidCallback? onSearch; + final VoidCallback? onReset; + final VoidCallback? onScan; + final Widget? headerTrailing; + final Widget? productAreaReplacement; + final int? totalProductCount; + final int? currentPage; + final int? pageSize; + final ValueChanged? onPageChanged; + final ValueChanged? onWeight; + final VoidCallback? onHold; + final VoidCallback? onMore; + final String cartTitle; + final bool showScaleCard; + + @override + Widget build(BuildContext context) { + return Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded( + child: Column( + children: [ + CashierSearchPanel( + categories: categories, + selectedCategory: selectedCategory, + onCategoryChanged: onCategoryChanged, + controller: searchController, + focusNode: searchFocusNode, + hint: searchHint, + onSearch: onSearch, + onReset: onReset, + onScan: onScan, + trailing: headerTrailing, + showScaleCard: showScaleCard, + ), + SizedBox(height: XfjDesignTokens.cardGap.h), + Expanded( + child: productAreaReplacement ?? + CashierProductGrid( + products: products, + onProductTap: onProductTap, + ), + ), + if (totalProductCount != null && + currentPage != null && + pageSize != null && + onPageChanged != null) ...[ + SizedBox(height: XfjDesignTokens.cardGap.h), + CashierPagination( + total: totalProductCount!, + currentPage: currentPage!, + pageSize: pageSize!, + onPageChanged: onPageChanged!, + ), + ], + ], + ), + ), + SizedBox(width: XfjDesignTokens.cardGap.w), + SizedBox( + width: 500.w, + child: CashierCartPanel( + title: cartTitle, + items: cartItems, + user: user, + totalPrice: totalPrice, + extraPrice: extraPrice, + productCount: productCount, + onQuantityChanged: onQuantityChanged, + onRemove: onRemoveCartItem, + onWeight: onWeight, + onClear: onClearCart, + onPay: onPay, + onCancel: onCancel, + onHold: onHold, + onMore: onMore, + ), + ), + ], + ); + } +} + +class CashierSearchPanel extends StatelessWidget { + const CashierSearchPanel({ + super.key, + required this.categories, + required this.selectedCategory, + required this.onCategoryChanged, + required this.hint, + required this.showScaleCard, + this.controller, + this.focusNode, + this.onSearch, + this.onReset, + this.onScan, + this.trailing, + }); + + final List categories; + final int selectedCategory; + final ValueChanged onCategoryChanged; + final TextEditingController? controller; + final FocusNode? focusNode; + final String hint; + final VoidCallback? onSearch; + final VoidCallback? onReset; + final VoidCallback? onScan; + final Widget? trailing; + final bool showScaleCard; + + @override + Widget build(BuildContext context) { + final searchCard = TerminalSurface( + padding: EdgeInsets.all(12.w), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + if (controller != null) + Expanded( + child: TerminalToolbarControl( + child: FTextField( + controller: controller, + focusNode: focusNode, + textInputAction: TextInputAction.none, + keyboardType: TextInputType.none, + hint: hint, + maxLines: 1, + onSubmit: (_) => onSearch?.call(), + ), + ), + ) + else + Expanded( + child: Container( + height: XfjDesignTokens.controlHeight.h, + padding: EdgeInsets.symmetric(horizontal: 14.w), + decoration: BoxDecoration( + color: XfjDesignTokens.control, + border: Border.all(color: XfjDesignTokens.line), + borderRadius: BorderRadius.circular( + XfjDesignTokens.controlRadius, + ), + ), + child: Row( + children: [ + FIcon( + FAssets.icons.search, + size: 20.w, + color: XfjDesignTokens.muted, + ), + SizedBox(width: 10.w), + Text( + hint, + style: FTheme.of(context).typography.base.copyWith( + color: XfjDesignTokens.muted, + ), + ), + ], + ), + ), + ), + if (onScan != null) ...[ + SizedBox(width: 8.w), + _CashierIconButton( + icon: FAssets.icons.barcode, + label: '扫码', + onTap: onScan!, + ), + ], + if (onSearch != null) ...[ + SizedBox(width: 8.w), + _CashierButton(label: '搜索', onTap: onSearch!), + ], + if (onReset != null) ...[ + SizedBox(width: 8.w), + _CashierButton( + label: '重置', + onTap: onReset!, + style: _CashierButtonStyle.soft, + ), + ], + if (trailing != null) ...[ + SizedBox(width: 12.w), + trailing!, + ], + ], + ), + SizedBox(height: 12.h), + SizedBox( + height: XfjDesignTokens.controlHeight.h, + child: ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: categories.length, + separatorBuilder: (_, __) => SizedBox(width: 10.w), + itemBuilder: (context, index) { + final category = categories[index]; + return _CashierCategoryChip( + label: category.name, + selected: selectedCategory == index, + onTap: () => onCategoryChanged(index), + ); + }, + ), + ), + ], + ), + ); + + if (!showScaleCard) return searchCard; + return Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded(child: searchCard), + SizedBox(width: XfjDesignTokens.cardGap.w), + SizedBox(width: 330.w, child: const CashierScaleStatusCard()), + ], + ); + } +} + +class CashierScaleStatusCard extends StatelessWidget { + const CashierScaleStatusCard({super.key}); + + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + return TerminalSurface( + padding: EdgeInsets.all(16.w), + child: Row( + children: [ + Container( + width: 92.w, + height: 76.h, + alignment: Alignment.center, + decoration: BoxDecoration( + color: XfjDesignTokens.control, + borderRadius: BorderRadius.circular( + XfjDesignTokens.controlRadius, + ), + ), + child: FIcon( + FAssets.icons.scale, + size: 48.w, + color: XfjDesignTokens.muted, + ), + ), + SizedBox(width: 14.w), + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + FIcon( + FAssets.icons.check, + size: 18.w, + color: XfjDesignTokens.success, + ), + SizedBox(width: 6.w), + Text( + '电子秤已连接', + style: theme.typography.sm.copyWith( + color: XfjDesignTokens.success, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + SizedBox(height: 3.h), + Text( + '0.000 kg', + maxLines: 1, + style: theme.typography.xl3.copyWith( + color: const Color(0xFF129A3C), + fontWeight: FontWeight.w700, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + Text( + '选择称重商品后开始称重', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.typography.xs.copyWith( + color: XfjDesignTokens.muted, + ), + ), + ], + ), + ), + ], + ), + ); + } +} + +class CashierProductGrid extends StatelessWidget { + const CashierProductGrid({ + super.key, + required this.products, + required this.onProductTap, + }); + + final List products; + final ValueChanged onProductTap; + + @override + Widget build(BuildContext context) { + if (products.isEmpty) { + return const _CashierEmptyState( + icon: null, + title: '暂无商品', + description: '请切换分类或检查商品同步状态', + ); + } + return LayoutBuilder( + builder: (context, constraints) { + // 1920 横屏原型固定四列;高度不足时允许商品区独立滚动,避免卡片被压扁。 + final cardWidth = (constraints.maxWidth - 36.w) / 4; + final cardHeight = (constraints.maxHeight - 12.h) / 2; + final ratio = cardWidth / cardHeight.clamp(176.h, 250.h); + return GridView.builder( + padding: EdgeInsets.zero, + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 4, + mainAxisSpacing: XfjDesignTokens.cardGap.h, + crossAxisSpacing: XfjDesignTokens.cardGap.w, + childAspectRatio: ratio, + ), + itemCount: products.length, + itemBuilder: (context, index) { + final product = products[index]; + return CashierProductCard( + product: product, + onTap: () => onProductTap(product), + ); + }, + ); + }, + ); + } +} + +class CashierProductCard extends StatelessWidget { + const CashierProductCard({ + super.key, + required this.product, + required this.onTap, + }); + + final CashierProductData product; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + final promotion = product.promotionPrice ?? 0; + return Material( + color: XfjDesignTokens.surface, + borderRadius: BorderRadius.circular(XfjDesignTokens.cardRadius), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(XfjDesignTokens.cardRadius), + child: Ink( + decoration: BoxDecoration( + color: XfjDesignTokens.surface, + border: Border.all(color: XfjDesignTokens.line), + borderRadius: BorderRadius.circular(XfjDesignTokens.cardRadius), + boxShadow: XfjDesignTokens.surfaceShadow, + ), + child: Stack( + children: [ + Padding( + padding: EdgeInsets.all(12.w), + child: Row( + children: [ + _CashierProductImage( + image: product.image, + label: product.name, + width: 104.w, + height: double.infinity, + ), + SizedBox(width: 12.w), + Expanded( + child: Padding( + padding: EdgeInsets.symmetric(vertical: 3.h), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + product.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.typography.lg.copyWith( + color: XfjDesignTokens.ink, + fontWeight: FontWeight.w600, + ), + ), + SizedBox(height: 8.h), + _CashierMoney( + amount: product.price, + style: theme.typography.xl.copyWith( + fontWeight: FontWeight.w700, + ), + ), + if (promotion > 0 && + promotion != product.price) ...[ + SizedBox(height: 5.h), + Container( + padding: EdgeInsets.symmetric( + horizontal: 8.w, + vertical: 3.h, + ), + decoration: BoxDecoration( + color: const Color(0xFFFFF6F5), + border: Border.all( + color: XfjDesignTokens.danger.withValues( + alpha: 0.3, + ), + ), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + '促销 ${_moneyText(promotion)}', + style: theme.typography.xs.copyWith( + color: XfjDesignTokens.danger, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + const Spacer(), + if (product.barcode?.isNotEmpty == true) + Text( + '条码 ${product.barcode}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.typography.xs.copyWith( + color: XfjDesignTokens.muted, + ), + ), + if (product.stock?.isNotEmpty == true) ...[ + SizedBox(height: 4.h), + Text( + '库存 ${product.stock}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.typography.xs.copyWith( + color: XfjDesignTokens.muted, + ), + ), + ], + if (product.badge?.isNotEmpty == true) ...[ + SizedBox(height: 4.h), + Text( + product.badge!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.typography.xs.copyWith( + color: XfjDesignTokens.primary, + ), + ), + ], + ], + ), + ), + ), + ], + ), + ), + Positioned( + right: 12.w, + bottom: 12.h, + child: Container( + width: 36.w, + height: 36.w, + decoration: const BoxDecoration( + color: XfjDesignTokens.primary, + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: Color(0x330094FF), + blurRadius: 8, + offset: Offset(0, 3), + ), + ], + ), + child: FIcon( + FAssets.icons.plus, + size: 20.w, + color: Colors.white, + ), + ), + ), + ], + ), + ), + ), + ); + } +} + +class CashierPagination extends StatelessWidget { + const CashierPagination({ + super.key, + required this.total, + required this.currentPage, + required this.pageSize, + required this.onPageChanged, + }); + + final int total; + final int currentPage; + final int pageSize; + final ValueChanged onPageChanged; + + @override + Widget build(BuildContext context) { + return TerminalPagination( + total: total, + currentPage: currentPage, + pageSize: pageSize, + itemLabel: '个商品', + onPageChanged: onPageChanged, + ); + } +} + +class CashierCartPanel extends StatelessWidget { + const CashierCartPanel({ + super.key, + required this.title, + required this.items, + required this.user, + required this.totalPrice, + required this.extraPrice, + required this.productCount, + required this.onQuantityChanged, + required this.onRemove, + required this.onClear, + required this.onPay, + required this.onCancel, + this.onWeight, + this.onHold, + this.onMore, + }); + + final String title; + final List items; + final ServiceUser? user; + final int totalPrice; + final int extraPrice; + final int productCount; + final void Function(ShopCardEntity item, int quantity) onQuantityChanged; + final ValueChanged onRemove; + final ValueChanged? onWeight; + final VoidCallback onClear; + final VoidCallback onPay; + final VoidCallback onCancel; + final VoidCallback? onHold; + final VoidCallback? onMore; + + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + final payable = totalPrice + extraPrice; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + CashierUserCard(user: user), + SizedBox(height: XfjDesignTokens.cardGap.h), + Expanded( + child: TerminalSurface( + clipBehavior: Clip.antiAlias, + child: Column( + children: [ + Container( + height: 52.h, + padding: EdgeInsets.symmetric(horizontal: 16.w), + decoration: const BoxDecoration( + border: Border( + bottom: BorderSide(color: XfjDesignTokens.line), + ), + ), + child: Row( + children: [ + Text( + title, + style: theme.typography.lg.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const Spacer(), + Text( + '$productCount 件', + style: theme.typography.sm.copyWith( + color: XfjDesignTokens.muted, + ), + ), + SizedBox(width: 14.w), + Container( + width: 1, + height: 24.h, + color: XfjDesignTokens.line, + ), + SizedBox(width: 14.w), + InkWell( + onTap: items.isEmpty ? null : onClear, + child: Padding( + padding: EdgeInsets.symmetric(vertical: 10.h), + child: Text( + '清空', + style: theme.typography.sm.copyWith( + color: items.isEmpty + ? XfjDesignTokens.muted + : XfjDesignTokens.primary, + ), + ), + ), + ), + ], + ), + ), + Expanded( + child: items.isEmpty + ? const _CashierEmptyState( + icon: null, + title: '购物车为空', + description: '点击左侧商品即可加入购物车', + ) + : ListView.separated( + padding: EdgeInsets.zero, + itemCount: items.length, + separatorBuilder: (_, __) => const Divider( + height: 1, + thickness: 1, + color: XfjDesignTokens.line, + ), + itemBuilder: (context, index) { + final item = items[index]; + return CashierCartItem( + item: item, + onQuantityChanged: (quantity) => + onQuantityChanged(item, quantity), + onRemove: () => onRemove(item), + onWeight: onWeight == null + ? null + : () => onWeight!(item), + ); + }, + ), + ), + ], + ), + ), + ), + SizedBox(height: XfjDesignTokens.cardGap.h), + TerminalSurface( + // 两项金额已经改为同一行展示,结算卡只保留内容所需高度, + // 同时预留屏幕缩放后的取整余量,避免固定高度相加产生底部溢出告警。 + height: 120.h, + padding: EdgeInsets.symmetric(horizontal: 20.w, vertical: 8.h), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SizedBox( + height: 32.h, + child: Row( + children: [ + Expanded( + child: _CashierAmountRow( + label: '商品总价', + amount: totalPrice, + ), + ), + SizedBox(width: 16.w), + const VerticalDivider( + width: 1, + thickness: 1, + color: XfjDesignTokens.line, + ), + SizedBox(width: 16.w), + Expanded( + child: _CashierAmountRow( + label: '额外支付', + amount: extraPrice, + color: extraPrice > 0 + ? XfjDesignTokens.danger + : XfjDesignTokens.ink, + ), + ), + ], + ), + ), + SizedBox(height: 4.h), + const Divider( + height: 1, + thickness: 1, + color: XfjDesignTokens.line, + ), + SizedBox( + height: 56.h, + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text( + '应收', + style: theme.typography.lg.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const Spacer(), + SizedBox( + width: 270.w, + child: FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerRight, + child: _CashierMoney( + amount: payable, + color: const Color(0xFF0966E8), + style: theme.typography.xl3.copyWith( + fontWeight: FontWeight.w800, + letterSpacing: 0.5, + ), + ), + ), + ), + ], + ), + ), + ], + ), + ), + SizedBox(height: XfjDesignTokens.cardGap.h), + TerminalSurface( + height: 48.h, + padding: EdgeInsets.symmetric(horizontal: 16.w), + child: Row( + children: [ + FIcon( + FAssets.icons.package, + size: 20.w, + color: XfjDesignTokens.warning, + ), + SizedBox(width: 8.w), + Text( + AppNetworkTool.isAutoNetwork() ? '离线订单 0' : '当前离线收银', + style: theme.typography.base.copyWith( + color: XfjDesignTokens.muted, + ), + ), + const Spacer(), + Container(width: 1, height: 24.h, color: XfjDesignTokens.line), + const Spacer(), + FIcon( + FAssets.icons.refreshCw, + size: 20.w, + color: AppNetworkTool.isAutoNetwork() + ? XfjDesignTokens.success + : XfjDesignTokens.warning, + ), + SizedBox(width: 8.w), + Text( + AppNetworkTool.isAutoNetwork() ? '同步正常' : '等待网络恢复', + style: theme.typography.base.copyWith( + color: XfjDesignTokens.muted, + ), + ), + ], + ), + ), + SizedBox(height: 8.h), + // 主支付和取消订单采用同一行两列,减少底部操作区高度并扩大购物车空间。 + Row( + children: [ + Expanded( + child: _CashierButton( + label: '立即支付 ${_moneyText(payable)}', + onTap: onPay, + height: 56.h, + textStyle: theme.typography.lg.copyWith( + color: Colors.white, + fontWeight: FontWeight.w700, + ), + ), + ), + SizedBox(width: 10.w), + Expanded( + child: _CashierButton( + label: '取消订单', + onTap: onCancel, + height: 56.h, + style: _CashierButtonStyle.danger, + textStyle: theme.typography.lg.copyWith( + color: Colors.white, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + SizedBox(height: 8.h), + Row( + children: [ + Expanded( + child: _CashierButton( + label: '挂单', + onTap: onHold, + style: _CashierButtonStyle.outline, + ), + ), + SizedBox(width: 10.w), + Expanded( + child: _CashierButton( + label: '更多', + icon: FAssets.icons.circleEllipsis, + onTap: onMore, + style: _CashierButtonStyle.outline, + ), + ), + ], + ), + ], + ); + } +} + +class CashierUserCard extends StatelessWidget { + const CashierUserCard({super.key, required this.user}); + + final ServiceUser? user; + + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + final username = user?.username?.trim(); + final hasUser = username?.isNotEmpty == true; + return TerminalSurface( + height: 94.h, + padding: EdgeInsets.symmetric(horizontal: 18.w, vertical: 12.h), + child: Row( + children: [ + ClipOval( + child: Container( + width: 58.w, + height: 58.w, + color: XfjDesignTokens.primarySoft, + child: user?.userAvatar?.isNotEmpty == true + ? appImg( + user!.userAvatar, + width: 58.w, + height: 58.w, + fit: BoxFit.cover, + ) + : FIcon( + FAssets.icons.user, + size: 34.w, + color: XfjDesignTokens.primary, + ), + ), + ), + SizedBox(width: 16.w), + Expanded( + child: LayoutBuilder( + builder: (context, constraints) { + return FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerLeft, + child: SizedBox( + width: constraints.maxWidth, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Flexible( + child: Text( + hasUser ? username! : '暂未识别用户', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.typography.lg.copyWith( + fontWeight: FontWeight.w800, + height: 1.15, + ), + ), + ), + if (hasUser) ...[ + SizedBox(width: 8.w), + Container( + padding: EdgeInsets.symmetric( + horizontal: 8.w, + vertical: 3.h, + ), + decoration: BoxDecoration( + color: XfjDesignTokens.primarySoft, + borderRadius: BorderRadius.circular(20), + ), + child: Text( + '已识别', + style: theme.typography.xs.copyWith( + color: XfjDesignTokens.primary, + ), + ), + ), + ], + ], + ), + SizedBox(height: 4.h), + Text( + hasUser + ? '用户信息已识别 · 余额 ${_moneyText(user?.walletBalance ?? 0)}' + : '支付时可刷卡、扫码或人脸识别', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.typography.xs.copyWith( + color: XfjDesignTokens.muted, + height: 1.1, + fontFeatures: const [ + FontFeature.tabularFigures(), + ], + ), + ), + ], + ), + ), + ); + }, + ), + ), + ], + ), + ); + } +} + +class CashierCartItem extends StatelessWidget { + const CashierCartItem({ + super.key, + required this.item, + required this.onQuantityChanged, + required this.onRemove, + this.onWeight, + }); + + final ShopCardEntity item; + final ValueChanged onQuantityChanged; + final VoidCallback onRemove; + final VoidCallback? onWeight; + + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + final isWeight = item.nature == ProductNature.WEIGHT; + return SizedBox( + height: 104.h, + child: Padding( + padding: EdgeInsets.only(left: 18.w, right: 8.w), + child: Row( + children: [ + _CashierProductImage( + image: item.productPic, + label: item.productName, + width: 64.w, + height: 64.h, + compact: true, + ), + SizedBox(width: 14.w), + SizedBox( + width: 132.w, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.productName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.typography.base.copyWith( + fontWeight: FontWeight.w700, + ), + ), + SizedBox(height: 6.h), + Text( + isWeight + ? '${_moneyText(item.productPrice)}/kg' + : _moneyText(item.productPrice), + style: theme.typography.sm.copyWith( + color: XfjDesignTokens.muted, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + ], + ), + ), + Expanded( + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + if (isWeight) + Expanded( + child: InkWell( + onTap: onWeight, + borderRadius: BorderRadius.circular(8), + child: Container( + height: 38.h, + alignment: Alignment.center, + decoration: BoxDecoration( + color: XfjDesignTokens.primarySoft, + border: Border.all( + color: XfjDesignTokens.primary.withValues( + alpha: 0.3, + ), + ), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + item.productWidget > 0 + ? '${item.productWidget.toStringAsFixed(2)} kg' + : '点击称重', + style: theme.typography.sm.copyWith( + color: XfjDesignTokens.primary, + fontWeight: FontWeight.w600, + fontFeatures: const [ + FontFeature.tabularFigures(), + ], + ), + ), + ), + ), + ) + else + _CashierStepper( + quantity: item.productNumber, + onChanged: onQuantityChanged, + ), + SizedBox( + width: 88.w, + child: Text( + _moneyText(item.totalPrice), + textAlign: TextAlign.right, + maxLines: 1, + style: theme.typography.base.copyWith( + fontWeight: FontWeight.w700, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + ), + // 删除图标缩小但保留完整触控热区,并固定贴齐商品行最右侧。 + InkWell( + onTap: onRemove, + borderRadius: BorderRadius.circular(8), + child: SizedBox( + width: 48.w, + height: 48.h, + child: Align( + alignment: Alignment.centerRight, + child: FIcon( + FAssets.icons.x, + size: 20.w, + color: XfjDesignTokens.muted, + ), + ), + ), + ), + ], + ), + ), + ], + ), + ), + ); + } +} + +class _CashierStepper extends StatelessWidget { + const _CashierStepper({required this.quantity, required this.onChanged}); + + final int quantity; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + return Container( + height: 38.h, + decoration: BoxDecoration( + border: Border.all(color: XfjDesignTokens.line), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + _CashierStepperButton( + icon: FAssets.icons.minus, + onTap: () => onChanged((quantity - 1).clamp(0, 100)), + ), + SizedBox( + width: 34.w, + child: Text( + '$quantity', + textAlign: TextAlign.center, + style: theme.typography.sm.copyWith( + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + ), + _CashierStepperButton( + icon: FAssets.icons.plus, + onTap: () => onChanged((quantity + 1).clamp(0, 100)), + ), + ], + ), + ); + } +} + +class _CashierStepperButton extends StatelessWidget { + const _CashierStepperButton({required this.icon, required this.onTap}); + + final SvgAsset icon; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onTap, + child: SizedBox( + width: 36.w, + height: 38.h, + child: FIcon(icon, size: 16.w, color: XfjDesignTokens.ink), + ), + ); + } +} + +class _CashierProductImage extends StatelessWidget { + const _CashierProductImage({ + required this.image, + required this.label, + required this.width, + required this.height, + this.compact = false, + }); + + final String? image; + final String label; + final double width; + final double height; + final bool compact; + + @override + Widget build(BuildContext context) { + final shortLabel = + label.trim().isEmpty ? '商品' : label.trim().substring(0, 1); + return ClipRRect( + borderRadius: BorderRadius.circular(compact ? 10 : 12), + child: Container( + width: width, + height: height, + color: XfjDesignTokens.control, + child: image?.isNotEmpty == true + ? appImg( + image, + width: width, + height: height, + fit: BoxFit.cover, + ) + : Center( + child: Text( + shortLabel, + style: FTheme.of(context).typography.xl.copyWith( + color: XfjDesignTokens.muted, + fontWeight: FontWeight.w700, + ), + ), + ), + ), + ); + } +} + +class _CashierCategoryChip extends StatelessWidget { + const _CashierCategoryChip({ + required this.label, + required this.selected, + required this.onTap, + }); + + final String label; + final bool selected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + final sizeScale = XfjDesignTokens.buttonSizeScale; + return Material( + color: selected ? XfjDesignTokens.primarySoft : XfjDesignTokens.surface, + borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), + child: ConstrainedBox( + constraints: BoxConstraints(minWidth: 84.w, minHeight: 48.h), + child: Ink( + padding: EdgeInsets.symmetric(horizontal: 18.w * sizeScale), + decoration: BoxDecoration( + border: Border.all( + color: + selected ? XfjDesignTokens.primary : XfjDesignTokens.line, + ), + borderRadius: + BorderRadius.circular(XfjDesignTokens.controlRadius), + ), + child: Center( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.typography.base.copyWith( + color: + selected ? XfjDesignTokens.primary : XfjDesignTokens.ink, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ), + ), + ); + } +} + +enum _CashierButtonStyle { primary, soft, outline, danger } + +class _CashierButton extends StatelessWidget { + const _CashierButton({ + required this.label, + required this.onTap, + this.icon, + this.style = _CashierButtonStyle.primary, + this.height, + this.textStyle, + }); + + final String label; + final VoidCallback? onTap; + final SvgAsset? icon; + final _CashierButtonStyle style; + final double? height; + final TextStyle? textStyle; + + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + final sizeScale = XfjDesignTokens.buttonSizeScale; + final enabled = onTap != null; + final background = !enabled + ? XfjDesignTokens.control + : switch (style) { + _CashierButtonStyle.primary => XfjDesignTokens.primary, + _CashierButtonStyle.soft => XfjDesignTokens.control, + _CashierButtonStyle.outline => XfjDesignTokens.surface, + _CashierButtonStyle.danger => XfjDesignTokens.danger, + }; + final foreground = !enabled + ? XfjDesignTokens.muted + : switch (style) { + _CashierButtonStyle.primary => Colors.white, + _CashierButtonStyle.soft => XfjDesignTokens.ink, + _CashierButtonStyle.outline => XfjDesignTokens.ink, + _CashierButtonStyle.danger => Colors.white, + }; + final border = !enabled + ? XfjDesignTokens.line + : switch (style) { + _CashierButtonStyle.primary => XfjDesignTokens.primary, + _CashierButtonStyle.soft => XfjDesignTokens.line, + _CashierButtonStyle.outline => XfjDesignTokens.line, + _CashierButtonStyle.danger => XfjDesignTokens.danger, + }; + return Semantics( + button: true, + enabled: enabled, + child: Material( + color: background, + borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), + child: Ink( + height: height ?? XfjDesignTokens.controlHeight.h, + padding: EdgeInsets.symmetric(horizontal: 18.w * sizeScale), + decoration: BoxDecoration( + border: Border.all(color: border), + borderRadius: + BorderRadius.circular(XfjDesignTokens.controlRadius), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) ...[ + FIcon(icon!, size: 19.w, color: foreground), + SizedBox(width: 7.w), + ], + Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: textStyle?.copyWith(color: foreground) ?? + theme.typography.base.copyWith( + color: foreground, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ), + ), + ); + } +} + +class _CashierIconButton extends StatelessWidget { + const _CashierIconButton({ + required this.icon, + required this.label, + required this.onTap, + }); + + final SvgAsset icon; + final String label; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Semantics( + button: true, + label: label, + child: Material( + color: XfjDesignTokens.surface, + borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), + child: Ink( + width: XfjDesignTokens.controlHeight.h, + height: XfjDesignTokens.controlHeight.h, + decoration: BoxDecoration( + border: Border.all(color: XfjDesignTokens.line), + borderRadius: BorderRadius.circular( + XfjDesignTokens.controlRadius, + ), + ), + child: FIcon(icon, size: 22.w, color: XfjDesignTokens.ink), + ), + ), + ), + ); + } +} + +class _CashierAmountRow extends StatelessWidget { + const _CashierAmountRow({ + required this.label, + required this.amount, + this.color = XfjDesignTokens.ink, + }); + + final String label; + final int amount; + final Color color; + + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + return Row( + children: [ + Text(label, style: theme.typography.base), + const Spacer(), + _CashierMoney( + amount: amount, + color: color, + style: theme.typography.base.copyWith(fontWeight: FontWeight.w600), + ), + ], + ); + } +} + +class _CashierMoney extends StatelessWidget { + const _CashierMoney({ + required this.amount, + required this.style, + this.color = XfjDesignTokens.ink, + }); + + final int amount; + final TextStyle style; + final Color color; + + @override + Widget build(BuildContext context) { + return Text( + _moneyText(amount), + maxLines: 1, + style: style.copyWith( + color: color, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ); + } +} + +class _CashierEmptyState extends StatelessWidget { + const _CashierEmptyState({ + required this.icon, + required this.title, + required this.description, + }); + + final SvgAsset? icon; + final String title; + final String description; + + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + return TerminalSurface( + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + FIcon( + icon ?? FAssets.icons.packageOpen, + size: 42.w, + color: XfjDesignTokens.muted, + ), + SizedBox(height: 10.h), + Text( + title, + style: theme.typography.base.copyWith( + color: XfjDesignTokens.muted, + fontWeight: FontWeight.w600, + ), + ), + SizedBox(height: 4.h), + Text( + description, + style: theme.typography.xs.copyWith( + color: XfjDesignTokens.muted, + ), + ), + ], + ), + ), + ); + } +} + +String _moneyText(num amount) => '¥${(amount / 100).toStringAsFixed(2)}'; diff --git a/lib/common/widgets/index.dart b/lib/common/widgets/index.dart new file mode 100644 index 0000000..2449719 --- /dev/null +++ b/lib/common/widgets/index.dart @@ -0,0 +1,36 @@ +library; + +export './toast/toast_widget.dart'; + +export './app/app_date_picker.dart'; +export './app/app_divider_title.dart'; +export './app/app_empty.dart'; +export './app/app_hardware_input_dialog.dart'; +export './app/app_img.dart'; +export './app/app_message_dialog.dart'; +export './app/app_page.dart'; +export './app/app_price.dart'; +export './app/app_qr_code_dialog.dart'; +export './app/app_radio_hour_type_dialog.dart'; +export './app/app_wallet_selector.dart'; +export './app/terminal_shell.dart'; +export './app/terminal_toolbar.dart'; + +export './cashier/cashier_workspace.dart'; + +export './card/card_common.dart'; + +export './order/mixin/order_mixin.dart'; +export './order/select_pay_type_dialog.dart'; +export './order/steelyard_container.dart'; +export './order/order_caneen_common.dart'; +export './order/order_common.dart'; +export './order/order_supermarket_common.dart'; +export './order/select_hold_bill_dialog.dart'; +export './order/order_more_dialog.dart'; + +export './setting/setting_password_dialog.dart'; +export './setting/terminal_setting_tile.dart'; + +export './user/user_info.dart'; +export './user/user_tenant_dialog.dart'; diff --git a/lib/common/widgets/order/mixin/order_mixin.dart b/lib/common/widgets/order/mixin/order_mixin.dart new file mode 100644 index 0000000..e178a41 --- /dev/null +++ b/lib/common/widgets/order/mixin/order_mixin.dart @@ -0,0 +1,598 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:math'; + +import 'package:common_utils/common_utils.dart'; +import 'package:flutter_easyloading/flutter_easyloading.dart'; +import 'package:flutter_subscreen_plugin/flutter_subscreen_plugin.dart'; +import 'package:get/get.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +mixin OrderMixin { + static const _tag = "OrderMixin"; + + final appC = Get.find(); + + /// **************** start 需要重制 start **************** + + // 时间类型 + Rx hourType = Rx(ProductHourType.MORNING); + + // 设备类型 + SysType _sysType = SysType.CANTEEN; + + /// **************** end 需要重制 end **************** + + // 分类 + RxList classifyList = RxList([]); + + // 食堂商品 + RxList canteenSpuList = RxList([]); + + // 超市商品 + RxList supermarketSpuList = RxList([]); + + // 总价 + RxInt totalPrice = RxInt(0); + + // 商品总件数量 + RxInt totalProduct = RxInt(0); + + // 额外需要支付的金额土豪税 + RxInt amountMealPrice = RxInt(0); + + // 购物车 + RxList shopCardList = RxList([]); + + // 订单类型 + OrderType orderType = OrderType.BELOW; + + // 订单支付类型 + Rxn orderPayType = Rxn(OrderPayType.FACE); + + // 显示的支付类型 + Rxn orderPayDisplayType = Rxn( + OrderPayDisplayType.qrCode, + ); + + // 当前的tab + int currentTabIndex = 0; + + // 购买用户 + Rxn payUser = Rxn(null); + + // 二维码识别结果 + ServiceQRCode? globalQrCode; + + // 本地订单id + int? sqlOrderId; + + // 自编吗 + String barCode = ""; + + RxInt currentPage = RxInt(1); + RxInt totalPage = RxInt(0); + RxInt pageSize = RxInt(15); + + Future? putOffSendSubScreenDelayed; + + /// **************** start 更新属性 start **************** + + set sysType(SysType type) { + _sysType = type; + sendSubScreenType(); + } + + /// **************** end 更新属性 end **************** + + // 点击分类tab + Future doTab(int i) async { + if (currentTabIndex == i) return; + EasyLoading.show( + status: '切换中...', + maskType: EasyLoadingMaskType.black, + ); + currentTabIndex = i; + if (i <= classifyList.length - 1) { + currentPage.value = 1; + await getProduct(); + } + EasyLoading.dismiss(); + } + + // 获取分类 + Future getClassify() async { + final list = await appC.dataBase.getClassifyDao.getAll( + _sysType.name, + hourType.value.code, + ); + list.insert( + 0, + ClassifyEntity( + serviceUniqueCode: '-1', + sysType: _sysType, + name: '全部', + createTime: DateTime.now(), + updateTime: DateTime.now(), + sort: 0, + categoryType: "-1", + delFlag: false, + ), + ); + classifyList.value = list; + classifyList.refresh(); + } + + // 获取商品 + Future getProduct() async { + String classifyCode = classifyList[currentTabIndex].serviceUniqueCode; + if (_sysType == SysType.SUPERMARKET) { + final supermarketSkuDao = appC.dataBase.getSupermarketSkuDao; + String currentBarCode = ObjectUtil.isEmpty(barCode) ? "-1" : barCode; + final total = await supermarketSkuDao.getCount( + currentBarCode, + classifyCode, + ); + totalPage.value = total ?? 0; + final list = await supermarketSkuDao.getAllPage( + currentBarCode, + classifyCode, + pageSize.value, + (currentPage.value - 1) * pageSize.value, + ); + supermarketSpuList.value = list; + supermarketSpuList.refresh(); + classifyList.refresh(); + } else if (_sysType == SysType.CANTEEN) { + final saleDateStr = DateUtil.formatDate(DateTime.now()); + final list = await appC.dataBase.getCanteenSpuDao.getAll( + classifyCode, + hourType.value.code, + saleDateStr, + ); + canteenSpuList.value = list; + canteenSpuList.refresh(); + classifyList.refresh(); + } + } + + // 创建本地订单 + Future createSqlOrder() async { + final orderSourceTypeId = getOrderSourceTypeId(); + try { + final isNetwork = AppNetworkTool.isAutoNetwork(); + final order = OrderEntity( + uniqueCode: await _getUniqueCode(), + serviceUniqueCode: '001', + sysType: _sysType, + userCode: '001', + username: "", + hourType: hourType.value.code, + payType: orderPayType.value!, + walletId: payUser.value?.walletId, + orderSourceTypeId: orderSourceTypeId, + type: orderType, + status: isNetwork ? OrderStatus.CREATE : OrderStatus.OFFLINE_CREATE, + price: getTotalPrice(), + extraPrice: amountMealPrice.value, + payPrice: 0, + productDesc: getProductDesc(), + count: getProductCount(), + weight: getProductWeight(), + createTime: DateTime.now(), + ); + final id = await appC.dataBase.getOrderDao.save(order); + if (id == null) { + logger.e("|$_tag|创建本地订单,创建父订单错误"); + return null; + } + logger.i("|$_tag|创建本地订单成功orderId:|$id|;"); + sqlOrderId = id; + return id; + } catch (e) { + logger.e("|$_tag|创建本地订单error:|$e|"); + } + return null; + } + + // 创建本地购物车 + Future createSqlChildCar() async { + if (ObjectUtil.isEmpty(sqlOrderId)) { + logger.e("|$_tag|创建本地购物车,失败本地订单id不存在"); + return false; + } + final orderDao = appC.dataBase.getOrderDao; + final sqlOrderDetail = await orderDao.findById(sqlOrderId!); + if (ObjectUtil.isEmpty(sqlOrderDetail)) { + logger.e("|$_tag|创建本地购物车,失败本地订单不存在,sqlOrderDetail:|$sqlOrderDetail|;"); + return false; + } + try { + final cardList = shopCardList.map((e) { + e.orderId = sqlOrderId!; + e.productWidget = double.parse(e.productWidget.toStringAsFixed(2)); + return e; + }).toList(); + await appC.dataBase.getShopCardDao.batchSave(cardList); + return true; + } catch (e) { + logger.i("|$_tag|创建本地购物车,失败orderId:|$sqlOrderId|;error:|$e|"); + } + return false; + } + + // 更新本地订单 + Future updateSqlOrder() async { + if (ObjectUtil.isEmpty(sqlOrderId)) { + logger.e("|$_tag|更新本地订单,失败本地订单id不存在"); + return false; + } + final orderDao = appC.dataBase.getOrderDao; + final sqlOrderDetail = await orderDao.findById(sqlOrderId!); + if (ObjectUtil.isEmpty(sqlOrderDetail)) { + logger.e("|$_tag|更新本地订单,失败本地订单不存在,sqlOrderDetail:|$sqlOrderDetail|;"); + return false; + } + try { + // 更新本地订单 + sqlOrderDetail!.username = payUser.value?.username ?? "未知"; + sqlOrderDetail.userCode = payUser.value!.userId.toString(); + sqlOrderDetail.productDesc = getProductDesc(); + sqlOrderDetail.count = getProductCount(); + sqlOrderDetail.weight = getProductWeight(); + sqlOrderDetail.price = getTotalPrice(); + sqlOrderDetail.extraPrice = amountMealPrice.value; + sqlOrderDetail.hourType = hourType.value.code; + sqlOrderDetail.type = orderType; + sqlOrderDetail.payType = orderPayType.value!; + sqlOrderDetail.status = OrderStatus.PAY; + await orderDao.edit(sqlOrderDetail); + logger.i("|$_tag|更新本地订单,成功orderId:|$sqlOrderId|;"); + await createSqlChildCar(); + return true; + } catch (e) { + logger.i("|$_tag|更新本地订单,失败orderId:|$sqlOrderId|;error:|$e|"); + } + return false; + } + + Future createApiOrderValidate() async { + logger.i("|$_tag|开始创建api订单payUserId:|${payUser.value?.userId}|"); + final appSetting = await AppSettingTool.getSysSetting(); + final serviceId = appSetting.serviceId; + if (ObjectUtil.isEmpty(serviceId)) { + logger.e("|$_tag|开始创建api订单,失败serviceId不存在"); + Toast.error(msg: "创建订单失败,改设备没有进行绑定"); + return null; + } + final serviceMerchantId = appSetting.serviceMerchantId; + if (ObjectUtil.isEmpty(serviceMerchantId)) { + logger.e("|$_tag|开始创建api订单,失败serviceMerchantId不存在"); + Toast.error(msg: "创建订单失败,改设备没有进行绑定"); + return null; + } + if (ObjectUtil.isEmpty(sqlOrderId)) { + logger.e("|$_tag|开始创建api订单,失败本地订单id不存在"); + Toast.error(msg: "创建订单失败,本地订单id不存在"); + return null; + } + final orderDao = appC.dataBase.getOrderDao; + final sqlOrderDetail = await orderDao.findById(sqlOrderId!); + if (ObjectUtil.isEmpty(sqlOrderDetail)) { + logger.e("|$_tag|开始创建api订单,失败本地订单不存在,sqlOrderDetail:|$sqlOrderDetail|"); + Toast.error(msg: "创建订单失败,本地订单不存在"); + return null; + } + return sqlOrderDetail; + } + + // |接口|创建api订单 + Future createApiOrder() async { + final sqlOrderDetail = await createApiOrderValidate(); + if (ObjectUtil.isEmpty(sqlOrderDetail)) return null; + + final appSetting = await AppSettingTool.getSysSetting(); + final serviceId = appSetting.serviceId; + final serviceMerchantId = appSetting.serviceMerchantId; + + final orderDao = appC.dataBase.getOrderDao; + + // 获取下单商品 + List> shopList = []; + if (_sysType == SysType.CANTEEN) { + shopList = shopCardList.where((v) => v.productNumber > 0).map((v) { + return { + "productId": v.productServiceUniqueCode, + "productCount": v.productNumber, + "productWeight": 0, + "productNature": ProductNature.DEFAULT.name, + "isPriceIncreased": v.isPriceIncreased, + }; + }).toList(); + } else if (_sysType == SysType.SUPERMARKET) { + shopList = shopCardList.map((v) { + return { + "productId": v.productServiceUniqueCode, + "productCount": v.productNumber, + "productWeight": v.productWidget, + "productNature": v.nature.name, + "isPriceIncreased": v.isPriceIncreased, + }; + }).toList(); + } + final isNetwork = AppNetworkTool.isAutoNetwork(); + var pa = { + "androidOrderId": sqlOrderId, + "androidOrderCode": sqlOrderDetail!.uniqueCode, + "sysType": _sysType.name, + "userId": payUser.value?.userId ?? "-1", + "hourType": hourType.value.code, + "hardwareId": serviceId, + "merchantId": serviceMerchantId!, + "orderType": orderType.name, + "orderSourceTypeId": getOrderSourceTypeId(), + "orderPayType": orderPayType.value?.name, + "list": shopList, + "extraPrice": amountMealPrice.value, + }; + if (orderPayType.value != null && + appSetting.sysType == SysType.CANTEEN && + (orderPayType.value == OrderPayType.WALLET_QR_CODE)) { + pa["walletId"] = globalQrCode?.id; + } else if (OrderPayDisplayType.isSelectWalletPayType( + orderPayDisplayType.value!)) { + if (payUser.value?.walletId != null) { + pa["walletId"] = payUser.value?.walletId; + } + } + logger.i("|$_tag|创建api订单,下单参数:|${json.encode(pa)}|"); + T? result; + try { + if (isNetwork) { + if (orderPayType.value == OrderPayType.WALLET_QR_CODE_SCAN) { + final data = await HardApi.createHardQrCodeScanOrder(pa); + if (data == null) throw Error(); + if (ObjectUtil.isEmpty(data)) throw Error(); + logger.i("|$_tag||有网络|创建api订单,下单成功res:|$data|"); + sqlOrderDetail.status = OrderStatus.CREATE; + sqlOrderDetail.serviceUniqueCode = data.orderId; + result = data as T; + } else { + final data = await HardApi.createHardwareOrder(pa); + if (data == null) throw Error(); + if (ObjectUtil.isEmpty(data)) throw Error(); + logger.i("|$_tag||有网络|创建api订单,下单成功res:|$data|"); + sqlOrderDetail.status = OrderStatus.PAY; + sqlOrderDetail.serviceUniqueCode = data; + result = data as T; + } + } else { + sqlOrderDetail.status = OrderStatus.OFFLINE_PAY; + } + // 更新本地订单 + final payPrice = sqlOrderDetail.price + sqlOrderDetail.extraPrice; + sqlOrderDetail.payPrice = payPrice; + sqlOrderDetail.payTime = DateTime.now(); + await orderDao.edit(sqlOrderDetail); + if (orderPayType.value != OrderPayType.WALLET_QR_CODE_SCAN) { + if (sqlOrderDetail.payPrice != 0) { + final price = AppPriceTool.fenToYuan(sqlOrderDetail.payPrice); + TtsManage.speak("购买成功总金额$price元"); + } + Toast.success(msg: "购买成功"); + } + return result; + } catch (e, stack) { + Toast.error(msg: "结算订单失败"); + logger.e("|$_tag|创建api订单,下单失败error:|$e|stack:|$stack|"); + } + } + + // 获取商品渠道id + String getOrderSourceTypeId() { + String orderSourceTypeId = ""; + if (globalQrCode == null) return ""; + switch (orderType) { + case OrderType.BELOW: + if (ObjectUtil.isNotEmpty(globalQrCode?.id)) { + if (OrderPayType.isExternalPay(orderPayType.value!)) { + orderSourceTypeId = globalQrCode!.id.toString(); + } + } + break; + case OrderType.RESERVE: + if (ObjectUtil.isEmpty(globalQrCode!.id)) { + Toast.error(msg: "二维码错误"); + return ""; + } + orderSourceTypeId = globalQrCode!.id.toString(); + break; + case OrderType.FOREIGN_TICKET: + if (ObjectUtil.isEmpty(globalQrCode!.id)) { + Toast.error(msg: "二维码错误"); + return ""; + } + orderSourceTypeId = globalQrCode!.id.toString(); + break; + case OrderType.STRIDE_DEPT: + if (ObjectUtil.isEmpty(globalQrCode!.id)) { + Toast.error(msg: "二维码错误"); + return ""; + } + orderSourceTypeId = globalQrCode!.id.toString(); + break; + default: + break; + } + return orderSourceTypeId; + } + + // 获取商品的描述信息 + String getProductDesc() { + String desc = ""; + for (final v in shopCardList) { + if (v.nature == ProductNature.DEFAULT) { + desc += "${v.productName}x${v.productNumber};"; + } else if (v.nature == ProductNature.WEIGHT) { + desc += "${v.productName}x${v.productWidget}/kg;"; + } + } + return desc; + } + + // 获取商品的总数 + int getProductCount() { + int total = 0; + if (_sysType == SysType.SUPERMARKET) return shopCardList.length; + for (var e in shopCardList) { + total += e.productNumber; + } + return total; + } + + // 获取商品的总重量 + double getProductWeight() { + double total = 0; + if (_sysType == SysType.CANTEEN) return total; + for (var e in shopCardList) { + total += e.productWidget; + } + return double.tryParse(total.toStringAsFixed(2))!; + } + + // 获取总价格 + int getTotalPrice() { + return totalPrice.value; + } + + // 计算总价 + void computedTotalPrice({isSendSubScreen = true}) { + int currentTotalPrice = 0; + int currentTotalProduct = 0; + for (var e in shopCardList) { + currentTotalPrice += e.totalPrice; + currentTotalProduct += e.productNumber; + } + totalPrice.value = currentTotalPrice; + totalProduct.value = currentTotalProduct; + if (isSendSubScreen) sendSubScreen(); + } + + /// **************** start 重制相关 start **************** + + /// **************** end 重制相关 end **************** + + /// **************** start 副屏幕相关 start **************** + + void putOffSendSubScreen({int seconds = 3}) { + // 如果有商品就直接反回 + if (shopCardList.isNotEmpty) { + putOffSendSubScreenDelayed = null; + return; + } + if (putOffSendSubScreenDelayed != null) { + sendSubScreenBack(); + putOffSendSubScreenDelayed = null; + return; + } + putOffSendSubScreenDelayed = Future.delayed(Duration(seconds: seconds), () { + // 如果有商品就直接反回 + if (shopCardList.isNotEmpty) { + putOffSendSubScreenDelayed = null; + return; + } + sendSubScreenBack(); + putOffSendSubScreenDelayed = null; + }); + } + + Future sendSubScreenBack() async { + await SubScreenPlugin.sendMsgToViceScreen( + SubScreenAction.jumpSubBack.name, + ); + } + + Future sendSubScreen() async { + if (shopCardList.isEmpty) { + await SubScreenPlugin.sendMsgToViceScreen( + SubScreenAction.jumpSubBack.name, + ); + return; + } + final json = SubScreenProductEvent( + shopList: shopCardList.map((e) => e.toJson()).toList(), + sysType: _sysType, + payUser: payUser.value?.toJson(), + totalPrice: totalPrice.value, + amountMealPrice: amountMealPrice.value, + ).toJson(); + await SubScreenPlugin.sendMsgToViceScreen( + SubScreenAction.updateCard.name, + params: json, + ); + } + + void sendSubScreenType() { + SubScreenPlugin.sendMsgToViceScreen( + SubScreenAction.updateCard.name, + params: {"type": _sysType.name}, + ); + } + + /// **************** end 副屏幕相关 end **************** + + // 生成安卓唯一的code + Future _getUniqueCode() async { + String code = ""; + final appSetting = await AppSettingTool.getSysSetting(); + final macAddress = appSetting.androidId; + if (ObjectUtil.isNotEmpty(macAddress)) { + code += "${macAddress}_"; + } + int timestamp = DateTime.now().millisecondsSinceEpoch; + code += "${timestamp}_"; + Random random = Random(); + final randomNumber = + List.generate(6, (index) => random.nextInt(10).toString()).join(''); + code += randomNumber; + return code; + } + + // 获取额外需要的金额 + Future getAmountMealPrice({ + required price, + }) async { + try { + var setting = await AppSettingTool.getCanteenExtraPriceConfig(); + final isNetwork = AppNetworkTool.isAutoNetwork(); + // 有网络 + if (isNetwork) { + final data = await WalletApi.getPaySetting(); + if (data == null) throw Error(); + final storageData = CanteenExtraPriceSetting( + payAmountEnable: data.payAmountEnable!, + payAmountMealLimit: data.payAmountMealLimit!, + payAmountMealTimes: data.payAmountMealTimes!, + afterSaleReturnReasons: data.afterSaleReturnReasons, + ); + if (setting == null || setting != storageData) { + await AppSettingTool.setCanteenExtraPriceConfig(storageData); + setting = storageData; + } + } + if (setting == null) return null; + if (!setting.payAmountEnable) return null; + double amountMealPrice = 0; + if (price > setting.payAmountMealLimit) { + amountMealPrice = + setting.payAmountMealTimes * (price - setting.payAmountMealLimit); + return amountMealPrice.round(); + } + return 0; + } catch (e, stack) { + logger.e("结算订单失败error:|$e|stack:|$stack|"); + } + return null; + } +} diff --git a/lib/common/widgets/order/order_caneen_common.dart b/lib/common/widgets/order/order_caneen_common.dart new file mode 100644 index 0000000..76cdff4 --- /dev/null +++ b/lib/common/widgets/order/order_caneen_common.dart @@ -0,0 +1,35 @@ +import 'package:ducafe_ui_core/ducafe_ui_core.dart'; +import 'package:flutter/material.dart'; +import 'package:forui/forui.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +/// 商品卡片 +Widget orderCaneenSpuCard( + BuildContext context, { + String? image = "", + String name = "", + num price = 0.0, +}) { + final theme = FTheme.of(context); + return FCard( + style: theme.cardStyle.copyWith( + contentStyle: theme.cardStyle.contentStyle.copyWith( + padding: EdgeInsets.all(0), + ), + ), + image: appImg(image, width: double.infinity, height: 150.h), + subtitle: [ + Text( + name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: theme.typography.base.copyWith(height: 1), + ), + SizedBox(height: 5.h), + appPrice(context, price), + ] + .toColumn(crossAxisAlignment: CrossAxisAlignment.center) + .width(double.infinity), + ).width(double.infinity); +} diff --git a/lib/common/widgets/order/order_common.dart b/lib/common/widgets/order/order_common.dart new file mode 100644 index 0000000..b953d64 --- /dev/null +++ b/lib/common/widgets/order/order_common.dart @@ -0,0 +1,272 @@ +import 'package:ducafe_ui_core/ducafe_ui_core.dart'; +import 'package:flutter/material.dart'; +import 'package:forui/forui.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +/// 菜品总价 +/// 额外支付 +/// 实际扣款 +Widget orderAllPrice( + BuildContext context, { + required num totalPrice, + required num amountMealPrice, + num? payPrice, + num? productCount, + String productPrice = "菜品总价", + bool visibleProductPrice = true, + bool visibleAmountMealPrice = true, + bool visibleDivider = true, +}) { + final theme = FTheme.of(context); + payPrice ??= totalPrice + amountMealPrice; + return [ + FDivider( + style: theme.dividerStyles.verticalStyle.copyWith( + color: theme.colorScheme.primary, + padding: EdgeInsets.zero, + ), + ), + [ + [ + [ + Text( + productPrice, + style: theme.typography.lg, + ), + appPrice(context, totalPrice), + ].toRow().offstage(offstage: !visibleProductPrice), + [ + Text( + "额外支付", + style: theme.typography.lg, + ), + appPrice(context, amountMealPrice), + ].toRow().offstage(offstage: !visibleAmountMealPrice), + [ + Text( + "实际扣款", + style: theme.typography.lg, + ), + appPrice( + context, + payPrice, + ), + ].toRow(), + ].toColumn(), + [ + [ + Text( + "件数", + style: theme.typography.lg, + ), + Text( + "$productCount", + style: theme.typography.lg.copyWith( + color: theme.colorScheme.error, + ), + ), + ].toRow().offstage(offstage: productCount == null), + ].toColumn() + ].toRow( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.start), + FDivider( + style: theme.dividerStyles.verticalStyle.copyWith( + color: theme.colorScheme.primary, + padding: EdgeInsets.zero, + ), + ).offstage(offstage: !visibleDivider), + ].toColumn(); +} + +/// 商品选择面板 +Widget orderSelectPanel( + BuildContext context, { + void Function(int, int)? onPlusChange, + void Function(double, int)? onWidgetChange, + String label = "", + int total = 1, + double widget = 0, + num price = 0, + num totalPrice = 0, + int index = 0, + double height = 50, + showPlus = false, + showWidget = false, +}) { + final theme = FTheme.of(context); + const btnWidth = 50; + const textWidth = 120; + return LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + final maxWidth = constraints.maxWidth; + return [ + [ + Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.typography.lg, + ).width(maxWidth * 0.28), + appPrice(context, price).expanded(), + ].toRow(mainAxisAlignment: MainAxisAlignment.spaceBetween).expanded(), + SizedBox(width: 5.w), + showPlus + ? [ + FIcon( + FAssets.icons.plus, + color: theme.colorScheme.secondary, + ) + .width(btnWidth.w) + .height(double.infinity) + .decorated(color: theme.colorScheme.primary) + .onTap( + () { + if (total < 100) { + if (onPlusChange != null) { + onPlusChange(total + 1, index); + } + } + }, + ), + Text( + total.toString(), + style: theme.typography.lg.copyWith(height: 1), + ).center().width(textWidth.w).height(double.infinity).decorated( + color: theme.colorScheme.secondary, + borderRadius: theme.style.borderRadius / 2, + ), + FIcon( + FAssets.icons.minus, + color: theme.colorScheme.secondary, + ) + .width(btnWidth.w) + .height(double.infinity) + .decorated(color: theme.colorScheme.error) + .onTap( + () { + if (total > 0) { + if (onPlusChange != null) { + onPlusChange(total - 1, index); + } + } + }, + ), + ].toRow(mainAxisAlignment: MainAxisAlignment.end) + : const SizedBox.shrink(), + showWidget + ? [ + Text( + "称重", + style: theme.typography.base.copyWith( + height: 1, + color: theme.colorScheme.secondary, + ), + ) + .center() + .width(btnWidth.w) + .height(double.infinity) + .decorated(color: theme.colorScheme.primary) + .onTap( + () { + if (onWidgetChange != null) { + onWidgetChange(widget, index); + } + }, + ), + [ + Text( + "${widget.toString()}/kg", + style: theme.typography.sm.copyWith(height: 1), + textAlign: TextAlign.center, + ), + appPrice(context, totalPrice, textStyle: theme.typography.sm), + ] + .toColumn( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.center, + ) + .width(textWidth.w) + .height(double.infinity) + .decorated( + color: theme.colorScheme.secondary, + borderRadius: theme.style.borderRadius / 2, + ), + FIcon( + FAssets.icons.minus, + color: theme.colorScheme.secondary, + ) + .width(btnWidth.w) + .height(double.infinity) + .decorated(color: theme.colorScheme.error) + .onTap( + () { + if (onPlusChange != null) { + onPlusChange(total - 1, index); + } + }, + ), + ].toRow(mainAxisAlignment: MainAxisAlignment.end) + : const SizedBox.shrink(), + ] + .toRow(mainAxisAlignment: MainAxisAlignment.center) + .height(height.h) + .padding(vertical: 5.h, horizontal: 5.w) + .decorated( + border: Border.all( + color: theme.colorScheme.border, + width: 1, + ), + borderRadius: theme.style.borderRadius / 2, + ); + }, + ); +} + +Widget orderRowClassify( + BuildContext context, { + required List classifyList, + required Function(int) onChange, + int currentTabIndex = 0, +}) { + final theme = FTheme.of(context); + return classifyList + .map((v) { + final target = classifyList[currentTabIndex]; + final active = target.id == v.id; + return Text( + v.name, + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.typography.xl2.copyWith( + height: 1, + color: active + ? theme.colorScheme.background + : theme.colorScheme.primary, + ), + ) + .center() + .width(180.w) + .height(60.h) + .decorated( + color: active + ? theme.colorScheme.primary + : theme.colorScheme.background, + border: Border.all( + color: theme.colorScheme.primary, + width: 1, + ), + borderRadius: theme.style.borderRadius / 2, + ) + .onTap( + () { + final index = classifyList.indexWhere((i) => i.id == v.id); + onChange(index); + }, + ); + }) + .toList() + .toRowSpace(space: 10.w); +} diff --git a/lib/common/widgets/order/order_more_dialog.dart b/lib/common/widgets/order/order_more_dialog.dart new file mode 100644 index 0000000..61414f3 --- /dev/null +++ b/lib/common/widgets/order/order_more_dialog.dart @@ -0,0 +1,65 @@ +import 'package:ducafe_ui_core/ducafe_ui_core.dart'; +import 'package:flutter/material.dart'; +import 'package:forui/forui.dart'; +import 'package:get/get.dart'; +import 'package:pj_supermarket_cloud/common/index.dart'; + +List panelBtnList = [ + OrderMoreItem( + title: "加入挂单", + visible: true, + type: OrderMoreTypeEnum.addHoldBill, + ), + OrderMoreItem( + title: "挂单列表", + visible: true, + type: OrderMoreTypeEnum.holdBillList, + ), +].obs; + +Future selectOrderMoreDialog() async { + final context = Get.context!; + final theme = FTheme.of(context); + + final width = Get.context!.width; + final height = Get.context!.height; + final dialogWidth = width * 0.3; + final dialogHeight = height * 0.25; + return showDialog( + context: context, + barrierDismissible: true, + builder: (BuildContext _) { + return FDialog( + title: Text("选择更多操作", style: theme.typography.lg) + .marginOnly(bottom: 20.h), + style: theme.dialogStyle.copyWith( + insetPadding: EdgeInsets.zero, + maxWidth: dialogWidth, + minWidth: dialogWidth, + horizontalStyle: theme.dialogStyle.horizontalStyle.copyWith( + padding: EdgeInsets.all(20.w), + ), + ), + body: [ + ...panelBtnList + .where((v) => v.visible) + .map( + (v) => FButton( + label: Text( + v.title, + style: theme.typography.lg.copyWith( + color: theme.colorScheme.secondary, + ), + ), + onPress: () => Get.back(result: v.type), + ).marginOnly(bottom: 20.h), + ) + .toList(), + ] + .toColumn(mainAxisAlignment: MainAxisAlignment.center) + .width(double.infinity) + .height(dialogHeight), + actions: [], + ); + }); +} diff --git a/lib/common/widgets/order/order_supermarket_common.dart b/lib/common/widgets/order/order_supermarket_common.dart new file mode 100644 index 0000000..d902954 --- /dev/null +++ b/lib/common/widgets/order/order_supermarket_common.dart @@ -0,0 +1,70 @@ +import 'package:ducafe_ui_core/ducafe_ui_core.dart'; +import 'package:flutter/material.dart'; +import 'package:forui/forui.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +Widget orderSupermarketSpuCard( + BuildContext context, { + required SupermarketSpuEntity supermarketSpu, + bool visibleProductImg = false, +}) { + final theme = FTheme.of(context); + final textStyle = theme.typography.xs.copyWith( + color: theme.colorScheme.primary, + ); + return FCard( + style: theme.cardStyle.copyWith( + contentStyle: theme.cardStyle.contentStyle.copyWith( + padding: EdgeInsets.all(0), + ), + ), + image: appImg(supermarketSpu.picUrl, width: double.infinity, height: 100.h) + .center() + .offstage(offstage: !visibleProductImg), + subtitle: [ + Text( + supermarketSpu.productName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.typography.base, + ), + SizedBox(height: 5.h), + FBadge( + style: FBadgeStyle.secondary, + label: Text(supermarketSpu.productNature.title, style: textStyle), + ), + SizedBox(height: 5.h), + [ + Text("零售价:", style: textStyle), + appPrice(context, supermarketSpu.retailPrice), + ].toRow(mainAxisAlignment: MainAxisAlignment.center), + [ + Text("促销价:", style: textStyle), + appPrice(context, supermarketSpu.promotionPrice), + ].toRow(mainAxisAlignment: MainAxisAlignment.center), + [ + Text("货号:", style: textStyle), + Text(supermarketSpu.productCode, style: textStyle), + ].toRow(mainAxisAlignment: MainAxisAlignment.center), + [ + Text("包装单位:", style: textStyle), + Text("${supermarketSpu.packageNum}", style: textStyle), + Text("/"), + Text(supermarketSpu.unit, style: textStyle), + ].toRow(mainAxisAlignment: MainAxisAlignment.center), + [ + Text("条码:", style: textStyle), + Text( + supermarketSpu.barCode ?? "", + style: textStyle, + ), + ].toRow(mainAxisAlignment: MainAxisAlignment.center), + ] + .toColumn( + crossAxisAlignment: CrossAxisAlignment.center, + ) + .padding(horizontal: 10.w) + .width(double.infinity), + ).width(double.infinity); +} diff --git a/lib/common/widgets/order/select_hold_bill_dialog.dart b/lib/common/widgets/order/select_hold_bill_dialog.dart new file mode 100644 index 0000000..8bef1fc --- /dev/null +++ b/lib/common/widgets/order/select_hold_bill_dialog.dart @@ -0,0 +1,373 @@ +import 'dart:convert'; + +import 'package:common_utils/common_utils.dart'; +import 'package:data_table_2/data_table_2.dart'; +import 'package:ducafe_ui_core/ducafe_ui_core.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_easyloading/flutter_easyloading.dart'; +import 'package:forui/forui.dart'; +import 'package:get/get.dart'; +import 'package:pj_supermarket_cloud/common/index.dart'; + +class SelectHoldBillController extends GetxController { + static const String _tag = "挂单管理 Controller"; + + final appC = Get.find(); + + // 选择的时间 + RxList selectDateTime = [ + DateTime.now(), + DateTime.now().add(const Duration(days: 1)), + ].obs; + + RxInt currentPage = RxInt(1); + + RxInt totalPage = RxInt(0); + + RxInt pageSize = RxInt(5); + + RxList holdBillList = RxList(); + Rxn selectedId = Rxn(null); + + @override + void onInit() async { + super.onInit(); + await getHoldBillList(); + } + + @override + void onClose() {} + + Future doDateRangeDate(List? dateTime) async { + if (ObjectUtil.isEmptyList(dateTime)) { + return; + } + selectDateTime.value = dateTime!; + currentPage.value = 1; + await doPage(); + } + + /// 搜索 + Future doPage({int? page}) async { + if (page != null) currentPage.value = page; + await getHoldBillList(isLoading: true); + } + + /// 获取挂单列表 + Future getHoldBillList({bool isLoading = false}) async { + if (isLoading) { + EasyLoading.show( + status: '查询中...', + maskType: EasyLoadingMaskType.black, + ); + } + final orderHoldBillDao = appC.dataBase.getOrderHoldBillDao; + try { + final startTime = DateUtil.formatDate( + DateTime(selectDateTime[0].year, selectDateTime[0].month, + selectDateTime[0].day, 0, 0, 0), + ); + final endTime = DateUtil.formatDate( + DateTime(selectDateTime[1].year, selectDateTime[1].month, + selectDateTime[1].day, 23, 59, 59), + ); + final total = await orderHoldBillDao.count( + startTime, + endTime, + ); + totalPage.value = total ?? 0; + final list = await orderHoldBillDao.list( + startTime, + endTime, + pageSize.value, + (currentPage.value - 1) * pageSize.value, + ); + if (ObjectUtil.isEmptyList(list)) { + holdBillList.value = []; + return; + } + holdBillList.value = list; + } catch (error, stack) { + LogUtil.e("|$_tag|,获取挂单列表意外的错误error:|$error|,stack:|$stack|"); + } finally { + if (isLoading) { + Future.delayed(const Duration(seconds: 1), () { + EasyLoading.dismiss(); + }); + } + // 只保留7天内的挂单数据 + orderHoldBillDao.deleteOlderThan7Days(); + } + } +} + +Future selectHoldBillDialog() async { + final context = Get.context!; + final theme = FTheme.of(context); + final holdBillC = Get.put( + SelectHoldBillController(), + ); + await holdBillC.getHoldBillList(isLoading: true); + if (!context.mounted) return null; + + final width = Get.context!.width; + final height = Get.context!.height; + final dialogWidth = width * 0.9; + final dialogHeight = height * 0.7; + + try { + return await showDialog( + context: context, + barrierDismissible: true, + builder: (BuildContext _) { + return FDialog( + title: Text( + "挂单列表", + style: theme.typography.lg, + ).marginOnly(bottom: 20.h), + style: theme.dialogStyle.copyWith( + insetPadding: EdgeInsets.zero, + maxWidth: dialogWidth, + minWidth: dialogWidth, + horizontalStyle: theme.dialogStyle.horizontalStyle.copyWith( + padding: EdgeInsets.all(20.w), + ), + ), + body: [ + _buildHoldBillSearch(context), + _buildHoldBillList(context).expanded(), + Obx( + () => appPage( + context, + pageNo: holdBillC.currentPage.value, + pageSize: holdBillC.pageSize.value, + total: holdBillC.totalPage.value, + onPageChanged: (pageNo) => holdBillC.doPage(page: pageNo), + ), + ) + ].toColumn().width(double.infinity).height(dialogHeight), + actions: [ + [ + FButton( + label: Text( + '确定', + style: theme.typography.base.copyWith( + color: theme.colorScheme.secondary, + ), + ), + onPress: () { + final holdBillC = Get.find(); + final selected = holdBillC.holdBillList.firstWhereOrNull( + (v) => v.id == holdBillC.selectedId.value, + ); + if (selected == null) { + EasyLoading.showInfo("请先选择一条挂单"); + return; + } + Get.back(result: selected); + }, + ).flexible(flex: 1), + SizedBox(width: 10.w), + FButton( + style: FButtonStyle.outline, + label: Text('取消', style: theme.typography.base), + onPress: () => Get.back(result: null), + ).flexible(flex: 1), + ].toRow() + ], + ); + }, + ); + } finally { + if (Get.isRegistered()) { + Get.delete(); + } + } +} + +Widget _buildHoldBillSearch(BuildContext context) { + final holdBillC = Get.find(); + return [ + const Spacer(), + Obx( + () => appDatePicker( + context, + datetimeRange: holdBillC.selectDateTime, + onChnage: (dateList) => holdBillC.doDateRangeDate(dateList), + ).width(400.w).marginOnly(right: 15.w), + ), + ].toRow(); +} + +Widget _buildHoldBillList(BuildContext context) { + final theme = FTheme.of(context); + final holdBillC = Get.find(); + return LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + final width = constraints.maxWidth; + // 计算列可用总宽度 = 父容器宽度 - 左右边距 - 每列之间的间距 + const int colCount = 4; + final double spacing = 12.w; // columnSpacing + final double marginH = 12.w; // horizontalMargin + final double totalColAvailableWidth = + width - 2 * marginH - spacing * (colCount - 1); + final hdWidth = totalColAvailableWidth / colCount; + + final height = constraints.maxHeight; + final hdHeight = (height - 60.h) / 5; + return Obx( + () => DataTable2( + columnSpacing: 12.w, + horizontalMargin: 12.w, + minWidth: 600.w, + showBottomBorder: true, + headingTextStyle: theme.typography.lg, + dataTextStyle: theme.typography.base, + dataRowHeight: hdHeight, + columns: [ + DataColumn2( + label: Text("购物车", textAlign: TextAlign.center), + headingRowAlignment: MainAxisAlignment.center, + fixedWidth: hdWidth * 1.7, + ), + DataColumn2( + label: Text("价格", textAlign: TextAlign.center), + headingRowAlignment: MainAxisAlignment.center, + fixedWidth: hdWidth * 0.8, + ), + DataColumn2( + label: Text("挂单时间", textAlign: TextAlign.center), + headingRowAlignment: MainAxisAlignment.center, + fixedWidth: hdWidth, + ), + ], + rows: holdBillC.holdBillList.map((e) { + final carList = (jsonDecode(e.carListJson ?? '[]') as List) + .map((v) => ShopCardEntity.fromJson(v as Map)) + .toList(); + return DataRow( + selected: holdBillC.selectedId.value == e.id, + onSelectChanged: (selected) { + if (selected == true) { + holdBillC.selectedId.value = e.id; + } + }, + color: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.selected)) { + return theme.colorScheme.secondary.withAlpha(24); + } + if (states.contains(WidgetState.hovered)) { + return theme.colorScheme.secondary.withAlpha(12); + } + return null; + }), + cells: [ + DataCell( + GridView.builder( + shrinkWrap: true, + gridDelegate: + const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 1, + mainAxisSpacing: 5, + crossAxisSpacing: 5, + childAspectRatio: 30, + ), + itemCount: carList.length, + itemBuilder: (context, i) { + final currentCar = carList[i]; + return [ + Text( + "${currentCar.productName}(单价¥${AppPriceTool.fenToYuan(currentCar.productPrice)}) x ${currentCar.productNumber}", + textAlign: TextAlign.left, + style: theme.typography.sm, + ), + Text("共", style: theme.typography.sm), + appPrice( + context, + currentCar.totalPrice, + textStyle: theme.typography.sm, + ), + ].toRow().marginOnly(left: 50.w); + }, + ), + ), + DataCell( + GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: + const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 1, + mainAxisSpacing: 5, + crossAxisSpacing: 5, + childAspectRatio: 10, + ), + itemCount: 3, + itemBuilder: (context, i) { + // 购物车总价 + if (i == 0) { + return [ + Text( + "购物车总价:", + textAlign: TextAlign.left, + style: theme.typography.sm, + ).flexible(flex: 1), + appPrice( + context, + e.price, + textStyle: theme.typography.sm, + ).flexible(flex: 1), + ].toRow(); + } + // 额外价格 + if (i == 1) { + return [ + Text( + "额外价格:", + textAlign: TextAlign.left, + style: theme.typography.sm, + ).flexible(flex: 1), + appPrice( + context, + e.extraPrice, + textStyle: theme.typography.sm, + ).flexible(flex: 1), + ].toRow(); + } + // 支付价格 + if (i == 2) { + return [ + Text( + "支付价格:", + textAlign: TextAlign.left, + style: theme.typography.sm, + ).flexible(flex: 1), + appPrice( + context, + e.payPrice, + textStyle: theme.typography.sm, + ).flexible(flex: 1), + ].toRow(); + } + return SizedBox.shrink(); + }, + ), + ), + DataCell( + Text( + DateUtil.formatDate( + e.createTime, + format: DateFormats.full, + ), + textAlign: TextAlign.center, + style: theme.typography.base, + ).center(), + ), + ], + ); + }).toList(), + ), + ); + }, + ); +} diff --git a/lib/common/widgets/order/select_pay_type_dialog.dart b/lib/common/widgets/order/select_pay_type_dialog.dart new file mode 100644 index 0000000..90de16d --- /dev/null +++ b/lib/common/widgets/order/select_pay_type_dialog.dart @@ -0,0 +1,1328 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:common_utils/common_utils.dart'; +import 'package:ducafe_ui_core/ducafe_ui_core.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:flutter_easyloading/flutter_easyloading.dart'; +import 'package:flutter_subscreen_plugin/flutter_subscreen_plugin.dart'; +import 'package:forui/forui.dart'; +import 'package:get/get.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +class SelectPayTypeDialogController extends GetxController { + SelectPayTypeDialogController(); + + /// **************** start 回调函数 start **************** + + // |开始识别|的回调 + VoidCallback? onRecognition; + + // |确定|的回调 + Future Function( + ServiceUser, + OrderType, + OrderPayType, + OrderPayDisplayType, + ServiceQRCode?, + )? onSubmit; + + // |"二维码扫一扫支付"点击"开始识别"| + Future Function( + Future Function( + ServiceHardCreateHardQrCodeScanOrder orderResult, + ) onOrderCreate, + )? onQrCodeScanRecognition; + + // |"二维码扫一扫支付" "支付成功"| + Future Function(ServiceUser? payUser)? onQrCodeScanPaySuccess; + + // |取消|的回调 + VoidCallback? onCancel; + + // 识别出用户的回调 + Function(ServiceUser?)? onDiscernUser; + + /// **************** end 回调函数 end **************** + + static const String _tag = "支付识别 Controller"; + + final app = Get.find(); + + late SysSetting appSetting; + + final payTypeRadioC = FRadioSelectGroupController(); + + Rxn payUser = Rxn(null); + + Rxn orderPayType = Rxn(OrderPayType.FACE); + + Rxn orderPayDisplayType = Rxn(OrderPayDisplayType.face); + + RxList usePayDisplayTypeList = + [].obs; + + OrderType orderType = OrderType.BELOW; + + RxBool isRecognition = RxBool(false); + + StreamSubscription? faceDiscernListen; + + // 钱包类标 + RxList userWalletList = RxList([]); + + // 当前选择的钱包 + Rx currentWallet = Rx(null); + + // 二维码识别结果 + ServiceQRCode? globalQrCode; + + // 人脸识别回调锁 + bool _faceCallbackLock = false; + + @override + void onInit() async { + super.onInit(); + appSetting = await AppSettingTool.getSysSetting(); + _listenAndroidFaceBack(); + // 监听支付方式变化 + payTypeRadioC.addValueListener((v) { + final payType = OrderPayDisplayType.values.fromCode( + v.first.code, + ); + if (payType == null || payType == orderPayDisplayType.value) { + return; + } + final isOffline = OrderPayDisplayType.isOffline(payType); + if (isOffline) { + payUser.value = AppUserTool.generateExternalUser(); + if (payType == OrderPayDisplayType.cash) { + orderPayType.value = OrderPayType.CASH; + } else if (payType == OrderPayDisplayType.zyy) { + orderPayType.value = OrderPayType.ZYY; + } else if (payType == OrderPayDisplayType.rmyy) { + orderPayType.value = OrderPayType.RMYY; + } else if (payType == OrderPayDisplayType.csxfk) { + orderPayType.value = OrderPayType.CSXFK; + } + } else if (payType == OrderPayDisplayType.qrCodeScan) { + orderPayType.value = OrderPayType.WALLET_QR_CODE_SCAN; + } else if (payUser.value != null) { + if (AppUserTool.isGenerateExternalUser(payUser.value!)) { + payUser.value = null; + } + } + + orderPayDisplayType.value = payType; + orderPayDisplayType.refresh(); + }); + } + + @override + void onClose() { + super.onClose(); + payTypeRadioC.dispose(); + } + + void initPayType() { + _closeStatus(); + if (appSetting.defaultPayDisplayType != null) { + payTypeRadioC.value = {appSetting.defaultPayDisplayType!}; + } + if (!ObjectUtil.isEmptyList(appSetting.usePayDisplayType)) { + final isNetwork = AppNetworkTool.isAutoNetwork(); + // 过滤一下不支持离线的支付方式 + if (isNetwork) { + usePayDisplayTypeList.value = appSetting.usePayDisplayType!.toList() + ..sort((a, b) => a.order.compareTo(b.order)); + } else { + final supportPayTypeList = appSetting.usePayDisplayType! + .where((i) => i.supportOffLine) + .toList(); + usePayDisplayTypeList.value = supportPayTypeList.toList() + ..sort((a, b) => a.order.compareTo(b.order)); + } + } + } + + _closeStatus() { + payUser.value = null; + userWalletList.value = []; + currentWallet.value = null; + isRecognition.value = false; + globalQrCode = null; + _faceCallbackLock = false; + // 设备 + if (appSetting.sysType == SysType.CANTEEN) { + BarcodeScanManage.instance.dispose(); + } + app.serialCardManage.close(); + + // 取消人脸监听 + faceDiscernListen?.cancel(); + + // 关闭加载提示 + EasyLoading.dismiss(); + } + + // |开始识别|点击 + Future doRecognition() async { + switch (orderPayDisplayType.value) { + case OrderPayDisplayType.qrCode: + await _dispatchQrCode(); + break; + case OrderPayDisplayType.walletCard: + await _dispatchWalletCard(); + break; + case OrderPayDisplayType.face: + await _dispatchFace(); + break; + case OrderPayDisplayType.qrCodeScan: + await _dispatchQrCodeScan(); + break; + default: + } + onRecognition?.call(); + } + + // |确定|点击 + doSubmit() async { + if (payUser.value == null) { + return; + } + // 判断是否为线下支付 + final isOffline = OrderPayDisplayType.isOffline(orderPayDisplayType.value!); + if (isOffline) { + orderType = OrderType.BELOW; + onDiscernUser?.call(payUser.value); + } + await onSubmit?.call( + payUser.value!, + orderType, + orderPayType.value!, + orderPayDisplayType.value!, + globalQrCode, + ); + _closeStatus(); + Navigator.of(Get.context!).pop(); + } + + // |取消|点击 + doCancel() async { + if (isRecognition.value) { + final flag = await appMessageDialog(Get.context!, "是否取消识别"); + if (!flag) return; + isRecognition.value = false; + Navigator.of(Get.context!).pop(); + if (orderPayDisplayType.value == OrderPayDisplayType.qrCodeScan) { + SubScreenPlugin.sendMsgToViceScreen( + SubScreenAction.qrCodeScanCancel.name, + ); + } + return; + } + _closeStatus(); + onCancel?.call(); + Navigator.of(Get.context!).pop(); + } + + // ************************************************************************** + // 二维码识别 start + // ************************************************************************** + Future _dispatchQrCode() async { + try { + if (isRecognition.value) { + Toast.error(msg: "正在识别中,请勿重复点击"); + return; + } + TtsManage.speak("请出示二维码"); + isRecognition.value = true; + onDiscernUser?.call(payUser.value); + BarcodeScanManage.instance.startListening(); + BarcodeScanManage.instance.qrcodeScanStream.listen((event) async { + final serviceCode = await BarcodeScanManage.instance.getCodeInfo(event); + if (serviceCode == null) return; + if (serviceCode.exceedTime != -1) { + int exceedTime = DateTime.fromMillisecondsSinceEpoch( + serviceCode.exceedTime!, + ).millisecondsSinceEpoch; + int nowTime = DateTime.now().millisecondsSinceEpoch; + if (exceedTime <= nowTime) { + TtsManage.speak("二维码已过期"); + return; + } + } + if (!_validateQrCodeType(serviceCode.payType!)) { + logger.e("|$_tag|二维码识别,无效的二维码json:|${jsonEncode(serviceCode)}|"); + TtsManage.speak("无效的二维码"); + return; + } + globalQrCode = serviceCode; + orderPayType.value = serviceCode.payType; + if (serviceCode.orderType != null) { + orderType = serviceCode.orderType!; + } + logger.i("|$_tag|二维码识别,识别到的二维码json:|${jsonEncode(serviceCode)}|"); + final isNetwork = AppNetworkTool.isAutoNetwork(); + if (isNetwork) { + final pa = { + "id": serviceCode.userId, + "type": appSetting.sysType.name, + }; + final data = await HardApi.getUserInfoById(pa); + if (data == null) throw Exception("用户不存在"); + payUser.value = data; + if (payUser.value != null) { + await SyncUserTool.storageUser(user: payUser.value!); + } + await _getWalletList(); + logger.i("|$_tag|二维码识别|有网络|,用户信息:|${payUser.toJson()}|"); + } else { + if (ObjectUtil.isNotEmpty(serviceCode.extra)) { + final extra = json.decode(serviceCode.extra!); + final jsonPayUser = ServiceUser.fromJson(extra); + payUser.value = jsonPayUser; + logger.i("|$_tag|二维码识别|无网络|,用户信息:|$extra|"); + } + } + isRecognition.value = false; + }); + } catch (e) { + TtsManage.speak("识别二维码错误"); + logger.e("|$_tag|二维码识别,失败error:|$e|"); + } + } + // ************************************************************************** + // 二维码识别 end + // ************************************************************************** + + // ************************************************************************** + // 饭卡识别 start + // ************************************************************************** + Future _dispatchWalletCard() async { + if (isRecognition.value) { + Toast.error(msg: "正在识别中,请勿重复点击"); + return; + } + TtsManage.speak("请在合适区域放置饭卡"); + try { + await app.serialCardManage.connect(); + if (!app.serialCardManage.isConnect) { + logger.e("|$_tag|饭卡识别,错误未打开设备"); + Toast.error(msg: "读卡器错误"); + return; + } + app.serialCardManage.callback = (type, status, card) async { + switch (status) { + case SerialCardResStatus.success: + if (ObjectUtil.isEmpty(card)) return; + final isNetwork = AppNetworkTool.isAutoNetwork(); + if (isNetwork) { + final pa = {"card": card}; + final data = await HardApi.getUserByCard(pa); + if (data == null) { + logger.e("|$_tag|饭卡识别,无效的饭卡cardId:|$card|"); + isRecognition.value = false; + app.serialCardManage.close(); + Toast.error(msg: "查询不到此用户"); + return; + } + payUser.value = data; + if (payUser.value != null) { + await SyncUserTool.storageUser(user: payUser.value!); + } + TtsManage.speak("识别饭卡成功"); + } else { + final user = await SyncUserTool.storageUserByCarNumber(card); + if (user == null || ObjectUtil.isEmpty(user.walletCardNumber)) { + logger.e("|$_tag|,饭卡识别,离线获取本地用户失败"); + Toast.error(msg: "离线模式下查询不到此用户"); + TtsManage.speak("离线模式下查询不到此用户"); + app.serialCardManage.close(); + isRecognition.value = false; + return; + } + payUser.value = SyncUserTool.parsePayUser(user: user); + logger.i("|$_tag|,饭卡识别,离线获取本地用户成功:|${payUser.value!.toJson()}|"); + } + app.serialCardManage.close(); + isRecognition.value = false; + orderPayType.value = OrderPayType.WALLET_CARD; + orderType = OrderType.BELOW; + await _getWalletList(); + onDiscernUser?.call(payUser.value); + break; + case SerialCardResStatus.warning: + break; + case SerialCardResStatus.error: + Toast.error(msg: "请在合适区域放置饭卡"); + isRecognition.value = false; + app.serialCardManage.close(); + logger.i("|$_tag|饭卡识别,连续一段时间未识别到饭卡"); + break; + } + }; + app.serialCardManage.sendRead(); + isRecognition.value = true; + } catch (e) { + isRecognition.value = false; + app.serialCardManage.close(); + logger.e("|$_tag|饭卡识别,失败error:|$e|"); + } + } + // ************************************************************************** + // 饭卡识别 end + // ************************************************************************** + + // ************************************************************************** + // 人脸支付 start + // ************************************************************************** + Future _dispatchFace() async { + try { + EasyLoading.show( + status: '人脸识别准备中...', + maskType: EasyLoadingMaskType.black, + ); + final faceDao = app.dataBase.getFaceDao; + final databaseFaceCount = await faceDao.findCount("-1"); + if (databaseFaceCount == null || databaseFaceCount == 0) { + String msg = "人脸数据库为空,请前往'设置'->'人脸管理'点击同步人脸"; + Toast.error(msg: msg); + throw Exception(msg); + } + final isActive = await AppFaceTool.isActiveSdk(); + if (!isActive) { + String msg = "请检查激人脸sdk激活码"; + Toast.error(msg: msg); + throw Exception(msg); + } + final faceCount = await FaceDiscernPlugs.getFaceCount(); + if (faceCount == 0) { + await SyncFaceTool.delEmptyFace(); + await AppFaceTool.faceRegister(); + } + final isJump = await AppFaceTool.jumpFacePage(); + if (!isJump) { + String msg = "跳转到人脸检测页面失败"; + Toast.error(msg: msg); + throw Exception(msg); + } + faceDiscernListen = FaceDiscernPlugs.faceStream.listen((event) { + if (event == null) return; + if (_faceCallbackLock) return; + _faceCallbackLock = true; + final userId = event.userId; + if (userId == null) { + Toast.error(msg: "人脸识别失败,请重试"); + return; + } + _faceDiscernAfter(event); + SubScreenPlugin.doubleScreenShow(); + }); + } catch (e) { + logger.e("|$_tag|跳转人脸识别页面,意外的错误error:|$e|"); + EasyLoading.dismiss(); + } + } + + // android人脸识别成功回调 + Future _faceDiscernAfter(FaceStream event) async { + try { + EasyLoading.show( + status: '获取用户信息中...', + maskType: EasyLoadingMaskType.black, + ); + final isNetwork = AppNetworkTool.isAutoNetwork(); + if (isNetwork) { + final pa = { + "id": event.userId, + // 现阶段都使用饭卡钱包 + "type": "CANTEEN", + }; + final data = await HardApi.getUserInfoById(pa); + if (data == null) throw Error(); + payUser.value = data; + logger.i("|_tag|人脸识别|有网络|,用户信息:|${payUser.toJson()}|"); + await SyncUserTool.storageUser(user: payUser.value!); + await _getWalletList(); + } else { + // 没有网络去本地数据库查询 + final user = await SyncUserTool.storageUserByUserId(event.userId!); + if (user == null) { + logger.e("|_tag|,人脸识别,离线获取本地用户失败"); + Toast.error(msg: "查询不到此用户"); + throw Error(); + } + payUser.value = SyncUserTool.parsePayUser(user: user); + payUser.refresh(); + logger.i("|_tag|人脸识别|无网络|,用户信息:|${payUser.toJson()}|"); + } + orderPayType.value = OrderPayType.FACE; + orderType = OrderType.BELOW; + onDiscernUser?.call(payUser.value); + } catch (e, task) { + logger.e("|$_tag|人脸识别,意外的错误error:|$e|task:|$task|"); + Toast.error(msg: "识别人脸错误,请重试"); + } finally { + EasyLoading.dismiss(); + _faceCallbackLock = false; + } + } + + // android 点击返回回调 + _listenAndroidFaceBack() { + final getChannel = FaceDiscernPlugs.getChannel; + getChannel.setMethodCallHandler((call) async { + if (call.method == "faceDiscernBack") { + SubScreenPlugin.doubleScreenShow(); + EasyLoading.dismiss(); + } + if (call.method == "faceDiscernPageInit") { + EasyLoading.dismiss(); + } + }); + } + + // ************************************************************************** + // 人脸支付 end + // ************************************************************************** + + // ************************************************************************** + // 二维码扫一扫 start + // ************************************************************************** + Future _dispatchQrCodeScan() async { + try { + // 先创建订单 + await onQrCodeScanRecognition?.call( + (orderRes) async { + logger.i( + "|$_tag|二维码扫一扫,创建本地id成功的回调,sqlOrderId|${orderRes.toJson()}|", + ); + // 发送消息给副屏展示二维码弹框 + SubScreenQrCodeScanEvent qrCodeScanEvent = SubScreenQrCodeScanEvent( + action: "hardQrCodeScan", + orderId: orderRes.orderId, + qrCodeData: orderRes.qrCodeData, + ); + SubScreenPlugin.sendMsgToViceScreen( + SubScreenAction.qrCodeScan.name, + params: qrCodeScanEvent.toJson(), + ); + isRecognition.value = true; + // 循环查询订单状态 + final order = await _queryQrCodeScanOrderStatus(orderRes.orderId); + if (!isRecognition.value) return; + if (order == null) { + isRecognition.value = false; + Toast.error(msg: "未支付,请检查用户是否扫码支付"); + return; + } + // 设置支付用户 + payUser.value?.walletId = order.walletId; + SubScreenPlugin.sendMsgToViceScreen( + SubScreenAction.qrCodeScanSuccess.name, + ); + // 支付成功 + await onQrCodeScanPaySuccess?.call(payUser.value); + isRecognition.value = false; + _closeStatus(); + Navigator.of(Get.context!).pop(); + }, + ); + } catch (error, stack) { + logger.e("|$_tag|二维码扫一扫,意外的错误error:|$error|task:|$stack|"); + isRecognition.value = false; + Toast.error(msg: "二维码扫一扫错误,请重试"); + } + } + + /// 轮训查询二维码订单状态 + Future _queryQrCodeScanOrderStatus( + String serviceOrderId, + ) async { + int errorCount = 0; + int maxErrorCount = 5; + while (true) { + if (!isRecognition.value) { + logger.e("|$_tag|轮训查询二维码订单状态,已取消订单,停止轮训,sqlOrderId|$serviceOrderId|"); + return null; + } + if (errorCount > maxErrorCount) { + logger.e("|$_tag|轮训查询二维码订单状态,订单不存在,sqlOrderId|$serviceOrderId|"); + Toast.error(msg: "订单不存在,请根据系统检查订单"); + return null; + } + await Future.delayed(const Duration(seconds: 3)); + final order = await HardApi.getOrder({"id": serviceOrderId}); + if (order == null) { + logger.e("|$_tag|轮训查询二维码订单状态,订单不存在,sqlOrderId|$serviceOrderId|"); + errorCount++; + continue; + } + if (order.orderStatus == OrderStatus.PAY.code) { + logger.i( + "|$_tag|轮训查询二维码订单状态,订单支付成功,sqlOrderId|$serviceOrderId|orderJson|${order.toJson()}|", + ); + return order; + } + errorCount++; + } + } + + // ************************************************************************** + // 二维码扫一扫 end + // ************************************************************************** + + /// 校验二维码类型是否符合 + _validateQrCodeType(OrderPayType type) { + List success = []; + // 超市扫码只能用二维码支付 + if (appSetting.sysType == SysType.SUPERMARKET) { + success = [OrderPayType.WALLET_QR_CODE]; + } else if (appSetting.sysType == SysType.CANTEEN) { + success = [ + OrderPayType.WALLET_QR_CODE, + OrderPayType.STRIDE_DEPT_QR_CODE, + OrderPayType.FOREIGN_TICKET_QR_CODE, + ]; + } + return success.contains(type); + } + + /// 选择钱包 + void appSelectorChange(ServiceWallet wallet) { + if (payUser.value == null) return; + + final currentPayUser = ServiceUser.fromJson(payUser.value!.toJson()); + currentPayUser.walletId = wallet.id; + currentPayUser.walletTypeId = wallet.walletTypeId; + currentPayUser.walletBalance = wallet.balance; + currentPayUser.walletCardNumber = wallet.cardNumber; + currentPayUser.walletIsFreeze = wallet.isFreeze; + + // 检查钱包ID是否真的发生了变化 + if (payUser.value!.walletId == wallet.id) { + return; + } + + currentWallet.value = wallet; + + // 使用SchedulerBinding延迟执行,避免在构建过程中触发setState() + SchedulerBinding.instance.addPostFrameCallback((_) { + payUser.value = currentPayUser; + }); + } + + /// 获取用户钱包列表 + Future _getWalletList() async { + // 如果选择了需要选择钱包的支付方式 + if (appSetting.sysType != SysType.SUPERMARKET) { + return; + } + final isSelectWallet = OrderPayDisplayType.isSelectWalletPayType( + orderPayDisplayType.value!, + ); + if (!isSelectWallet) return; + try { + if (payUser.value == null) return; + if (AppUserTool.isGenerateExternalUser(payUser.value!)) return; + + final userId = payUser.value?.userId; + if (userId == null) return; + + final data = await WalletApi.getWalletList({"userId": userId}); + if (data == null) throw Error(); + userWalletList.value = data; + } catch (error, stack) { + logger.e("|$_tag|获取用户钱包列表,意外的错误error:|$error|stack:|$stack|"); + } + } +} + +Future selectPayTypeDialog({ + bool paymentEnabled = true, + String? disabledMessage, +}) async { + final payTypeC = Get.find(); + final context = Get.context!; + payTypeC.initPayType(); + await showGeneralDialog( + context: context, + barrierColor: Colors.transparent, + barrierDismissible: false, + barrierLabel: '支付方式弹层已打开,请先关闭后再操作其他区域', + transitionDuration: const Duration(milliseconds: 180), + pageBuilder: (_, __, ___) { + return PopScope( + canPop: false, + child: SafeArea( + child: Stack( + children: [ + Positioned.fill( + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: () => HapticFeedback.mediumImpact(), + ), + ), + Positioned( + right: 486.w, + bottom: 28.h, + width: 520.w, + child: _CashierPaymentDialog( + controller: payTypeC, + paymentEnabled: paymentEnabled, + disabledMessage: disabledMessage, + ), + ), + ], + ), + ), + ); + }, + ); +} + +/// 与 `#/main` 一致的收银支付弹层,只负责重排现有支付 Controller 的操作入口。 +/// +/// 饭卡、二维码、人脸、现金等识别和提交仍由 `SelectPayTypeDialogController` +/// 执行,避免 UI 改版复制支付状态机或改变双屏、离线和订单处理语义。 +class _CashierPaymentDialog extends StatelessWidget { + const _CashierPaymentDialog({ + required this.controller, + required this.paymentEnabled, + this.disabledMessage, + }); + + final SelectPayTypeDialogController controller; + final bool paymentEnabled; + final String? disabledMessage; + + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + return Material( + color: Colors.transparent, + child: Container( + constraints: BoxConstraints(maxHeight: 790.h), + decoration: BoxDecoration( + color: XfjDesignTokens.surface, + border: Border.all(color: XfjDesignTokens.line), + borderRadius: BorderRadius.circular(XfjDesignTokens.cardRadius), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.18), + blurRadius: 32, + offset: const Offset(0, 12), + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + height: 58.h, + padding: EdgeInsets.symmetric(horizontal: 18.w), + decoration: const BoxDecoration( + border: Border( + bottom: BorderSide(color: XfjDesignTokens.line), + ), + ), + child: Row( + children: [ + Text( + '选择支付方式', + style: theme.typography.lg.copyWith( + color: XfjDesignTokens.ink, + fontWeight: FontWeight.w800, + ), + ), + const Spacer(), + InkWell( + onTap: controller.doCancel, + borderRadius: BorderRadius.circular(10), + child: SizedBox( + width: 46.w, + height: 46.h, + child: FIcon( + FAssets.icons.x, + size: 22.w, + color: XfjDesignTokens.muted, + ), + ), + ), + ], + ), + ), + Flexible( + child: SingleChildScrollView( + padding: EdgeInsets.all(16.w), + child: Obx( + () => AbsorbPointer( + absorbing: controller.isRecognition.value, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _PaymentMethodGrid(controller: controller), + SizedBox(height: 12.h), + _PaymentRecognitionCard(controller: controller), + if (controller.appSetting.sysType == + SysType.SUPERMARKET && + controller.userWalletList.isNotEmpty && + OrderPayDisplayType.isSelectWalletPayType( + controller.orderPayDisplayType.value!, + )) ...[ + SizedBox(height: 12.h), + _PaymentWalletList(controller: controller), + ], + if (!paymentEnabled) ...[ + SizedBox(height: 12.h), + Container( + padding: EdgeInsets.symmetric( + horizontal: 14.w, + vertical: 12.h, + ), + decoration: BoxDecoration( + color: const Color(0xFFFFF9E8), + border: Border.all( + color: XfjDesignTokens.warning.withValues( + alpha: 0.35, + ), + ), + borderRadius: BorderRadius.circular(10), + ), + child: Text( + disabledMessage ?? '请先添加商品后再继续支付', + style: theme.typography.sm.copyWith( + color: const Color(0xFF8C6A00), + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ], + ), + ), + ), + ), + ), + Container( + padding: EdgeInsets.all(16.w), + decoration: const BoxDecoration( + border: Border(top: BorderSide(color: XfjDesignTokens.line)), + ), + child: Row( + children: [ + Expanded( + child: _PaymentDialogButton( + label: '取消', + onTap: controller.doCancel, + soft: true, + ), + ), + SizedBox(width: 12.w), + Expanded( + child: Obx(() { + final hasUser = controller.payUser.value != null; + return _PaymentDialogButton( + label: hasUser ? '确认支付' : '开始识别', + onTap: !paymentEnabled || controller.isRecognition.value + ? null + : hasUser + ? controller.doSubmit + : controller.doRecognition, + ); + }), + ), + ], + ), + ), + ], + ), + ), + ); + } +} + +class _PaymentMethodGrid extends StatelessWidget { + const _PaymentMethodGrid({required this.controller}); + + final SelectPayTypeDialogController controller; + + @override + Widget build(BuildContext context) { + final methods = controller.usePayDisplayTypeList; + if (methods.isEmpty) { + return const _PaymentEmptyMessage(message: '暂无可用支付方式,请前往设置检查支付配置'); + } + return GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: methods.length, + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + mainAxisSpacing: XfjDesignTokens.cardGap.h, + crossAxisSpacing: XfjDesignTokens.cardGap.w, + childAspectRatio: 1.55, + ), + itemBuilder: (context, index) { + final method = methods[index]; + final selected = controller.orderPayDisplayType.value == method; + return _PaymentMethodButton( + type: method, + selected: selected, + enabled: !controller.isRecognition.value, + onTap: () => controller.payTypeRadioC.value = {method}, + ); + }, + ); + } +} + +class _PaymentMethodButton extends StatelessWidget { + const _PaymentMethodButton({ + required this.type, + required this.selected, + required this.enabled, + required this.onTap, + }); + + final OrderPayDisplayType type; + final bool selected; + final bool enabled; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + final foreground = selected + ? XfjDesignTokens.primary + : enabled + ? XfjDesignTokens.ink + : XfjDesignTokens.muted; + return Material( + color: selected ? XfjDesignTokens.primarySoft : XfjDesignTokens.surface, + borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), + child: InkWell( + onTap: enabled ? onTap : null, + borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), + child: Ink( + decoration: BoxDecoration( + border: Border.all( + color: selected ? XfjDesignTokens.primary : XfjDesignTokens.line, + ), + borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), + ), + child: Stack( + children: [ + Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + FIcon( + _paymentMethodIcon(type), + size: 28.w, + color: foreground, + ), + SizedBox(height: 6.h), + Text( + _paymentMethodLabel(type), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.typography.sm.copyWith( + color: foreground, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ), + if (selected) + Positioned( + right: 6.w, + bottom: 6.h, + child: Container( + width: 22.w, + height: 22.w, + decoration: const BoxDecoration( + color: XfjDesignTokens.primary, + shape: BoxShape.circle, + ), + child: FIcon( + FAssets.icons.check, + size: 13.w, + color: Colors.white, + ), + ), + ), + ], + ), + ), + ), + ); + } +} + +class _PaymentRecognitionCard extends StatelessWidget { + const _PaymentRecognitionCard({required this.controller}); + + final SelectPayTypeDialogController controller; + + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + final user = controller.payUser.value; + final type = controller.orderPayDisplayType.value; + return Container( + constraints: BoxConstraints(minHeight: 96.h), + padding: EdgeInsets.all(14.w), + decoration: BoxDecoration( + color: XfjDesignTokens.primarySoft, + border: Border.all( + color: XfjDesignTokens.primary.withValues(alpha: 0.2), + ), + borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), + ), + child: Row( + children: [ + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + user == null + ? _paymentPromptTitle(type) + : user.username ?? '已识别用户', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.typography.base.copyWith( + color: XfjDesignTokens.primary, + fontWeight: FontWeight.w800, + ), + ), + SizedBox(height: 6.h), + Text( + user == null + ? _paymentPromptDescription(type) + : '余额 ${_paymentMoney(user.walletBalance ?? 0)}', + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: theme.typography.sm.copyWith( + color: XfjDesignTokens.muted, + ), + ), + ], + ), + ), + SizedBox(width: 12.w), + controller.isRecognition.value + ? const SizedBox( + width: 38, + height: 38, + child: CircularProgressIndicator(strokeWidth: 3), + ) + : FIcon( + _paymentMethodIcon(type), + size: 44.w, + color: XfjDesignTokens.muted, + ), + ], + ), + ); + } +} + +class _PaymentWalletList extends StatelessWidget { + const _PaymentWalletList({required this.controller}); + + final SelectPayTypeDialogController controller; + + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '选择账户', + style: theme.typography.xs.copyWith(color: XfjDesignTokens.muted), + ), + SizedBox(height: 6.h), + ...controller.userWalletList.map((wallet) { + final selected = controller.currentWallet.value?.id == wallet.id; + return InkWell( + onTap: () => controller.appSelectorChange(wallet), + borderRadius: BorderRadius.circular(10), + child: Container( + constraints: BoxConstraints(minHeight: 48.h), + margin: EdgeInsets.only(bottom: XfjDesignTokens.cardGap.h), + padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 8.h), + decoration: BoxDecoration( + color: selected + ? XfjDesignTokens.primarySoft + : XfjDesignTokens.surface, + border: Border.all( + color: + selected ? XfjDesignTokens.primary : XfjDesignTokens.line, + ), + borderRadius: BorderRadius.circular(10), + ), + child: Row( + children: [ + FIcon( + FAssets.icons.walletMinimal, + size: 20.w, + color: selected + ? XfjDesignTokens.primary + : XfjDesignTokens.ink, + ), + SizedBox(width: 8.w), + Text('钱包账户', style: theme.typography.sm), + const Spacer(), + Text( + '余额 ${_paymentMoney(wallet.balance)}', + style: theme.typography.sm.copyWith( + color: XfjDesignTokens.muted, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + ], + ), + ), + ); + }), + ], + ); + } +} + +class _PaymentDialogButton extends StatelessWidget { + const _PaymentDialogButton({ + required this.label, + required this.onTap, + this.soft = false, + }); + + final String label; + final VoidCallback? onTap; + final bool soft; + + @override + Widget build(BuildContext context) { + final enabled = onTap != null; + final foreground = !enabled + ? XfjDesignTokens.muted + : soft + ? XfjDesignTokens.ink + : Colors.white; + return Material( + color: !enabled + ? XfjDesignTokens.control + : soft + ? XfjDesignTokens.control + : XfjDesignTokens.primary, + borderRadius: BorderRadius.circular(12), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + child: SizedBox( + height: 56.h, + child: Center( + child: Text( + label, + style: FTheme.of(context).typography.base.copyWith( + color: foreground, + fontWeight: FontWeight.w700, + ), + ), + ), + ), + ), + ); + } +} + +class _PaymentEmptyMessage extends StatelessWidget { + const _PaymentEmptyMessage({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Container( + padding: EdgeInsets.all(16.w), + decoration: BoxDecoration( + color: XfjDesignTokens.control, + borderRadius: BorderRadius.circular(12), + ), + child: Text( + message, + textAlign: TextAlign.center, + style: FTheme.of(context).typography.sm.copyWith( + color: XfjDesignTokens.muted, + ), + ), + ); + } +} + +SvgAsset _paymentMethodIcon(OrderPayDisplayType? type) { + return switch (type) { + OrderPayDisplayType.walletCard => FAssets.icons.idCard, + OrderPayDisplayType.qrCode => FAssets.icons.qrCode, + OrderPayDisplayType.qrCodeScan => FAssets.icons.scanLine, + OrderPayDisplayType.face => FAssets.icons.scanFace, + _ => FAssets.icons.circleDollarSign, + }; +} + +String _paymentMethodLabel(OrderPayDisplayType type) { + return switch (type) { + OrderPayDisplayType.walletCard => '饭卡', + OrderPayDisplayType.qrCode => '付款码', + OrderPayDisplayType.qrCodeScan => '扫一扫', + OrderPayDisplayType.face => '人脸', + OrderPayDisplayType.cash => '现金', + _ => type.title, + }; +} + +String _paymentPromptTitle(OrderPayDisplayType? type) { + return switch (type) { + OrderPayDisplayType.walletCard => '请刷饭卡', + OrderPayDisplayType.qrCode => '请出示付款码', + OrderPayDisplayType.qrCodeScan => '请扫描支付二维码', + OrderPayDisplayType.face => '请进行人脸识别', + OrderPayDisplayType.cash => '请收取现金', + _ => '请按提示完成支付', + }; +} + +String _paymentPromptDescription(OrderPayDisplayType? type) { + return switch (type) { + OrderPayDisplayType.walletCard => '请在读卡器上刷卡', + OrderPayDisplayType.qrCode => '请使用扫码枪读取用户付款码', + OrderPayDisplayType.qrCodeScan => '请让用户扫描副屏二维码', + OrderPayDisplayType.face => '请让用户正视摄像头', + OrderPayDisplayType.cash => '确认收款后提交支付', + _ => '选择支付方式后继续', + }; +} + +String _paymentMoney(int amount) => '¥ ${(amount / 100).toStringAsFixed(2)}'; + +Widget selectPayTypeUser(ServiceUser? user, OrderPayType? payType) { + final theme = FTheme.of(Get.context!); + if (user == null) return const SizedBox.shrink(); + final isFree = user.walletIsFreeze ?? true; + final textStyle = theme.typography.xl; + final titleTheme = theme.tileGroupStyle.tileStyle.copyWith( + border: Border.all( + color: Colors.transparent, + ), + ); + // 判断是否为`跨单位二维码支付`或者`外来人员二维码支付` + payType ??= OrderPayType.FACE; + final isStrideOrForeign = OrderPayType.isStrideOrForeign(payType); + final widgets = [ + FTile( + style: titleTheme, + prefixIcon: FIcon(FAssets.icons.user), + title: Text('姓名', style: textStyle), + details: Text("${user.username}", style: textStyle), + ), + FTile( + style: titleTheme, + prefixIcon: FIcon(FAssets.icons.smartphone), + title: Text('手机号', style: textStyle), + details: Text("${user.userMobile}", style: textStyle), + ), + ]; + if (isStrideOrForeign) { + widgets.addAll([ + FTile( + style: titleTheme, + prefixIcon: FIcon(FAssets.icons.stretchHorizontal), + title: Text('支付类型', style: textStyle), + details: Text(payType.title, style: textStyle), + ), + ]); + } else { + widgets.addAll([ + FTile( + style: titleTheme, + prefixIcon: FIcon(FAssets.icons.badgeDollarSign), + title: Text('余额', style: textStyle), + details: appPrice(Get.context!, user.walletBalance), + ), + FTile( + style: titleTheme, + prefixIcon: FIcon(FAssets.icons.idCard), + title: Text('卡号', style: textStyle), + details: Text("${user.walletCardNumber}", style: textStyle), + ).offstage(offstage: ObjectUtil.isEmpty(user.walletCardNumber)), + FTile( + style: titleTheme, + prefixIcon: FIcon(FAssets.icons.ban), + title: Text('是否冻结', style: textStyle), + details: Text(isFree ? "是" : "否", style: textStyle), + ).offstage(offstage: user.walletIsFreeze == null), + ]); + } + return widgets.toColumn(mainAxisAlignment: MainAxisAlignment.start); +} + +Widget selectPayTypeHint( + BuildContext context, { + OrderPayDisplayType? type, + bool isLoading = false, +}) { + if (type == null) return const SizedBox.shrink(); + const baseMsg = "请点击开始识别按钮"; + String msg = ""; + SvgAsset icon = FAssets.icons.bird; + switch (type) { + case OrderPayDisplayType.qrCode: + msg = "$baseMsg,并让用户出示二维码"; + icon = FAssets.icons.qrCode; + break; + case OrderPayDisplayType.walletCard: + msg = "$baseMsg,并让用户刷饭卡"; + icon = FAssets.icons.idCard; + break; + case OrderPayDisplayType.face: + msg = "$baseMsg,并让用户进行刷脸"; + icon = FAssets.icons.scanFace; + break; + case OrderPayDisplayType.cash: + msg = "请打开钱箱,放入现金"; + icon = FAssets.icons.circleDollarSign; + break; + case OrderPayDisplayType.zyy: + msg = "中医院支付,请点击支付按钮"; + icon = FAssets.icons.circleDollarSign; + break; + case OrderPayDisplayType.rmyy: + msg = "人民医院支付,请点击支付按钮"; + icon = FAssets.icons.circleDollarSign; + break; + case OrderPayDisplayType.csxfk: + msg = "超市消费卡支付,请点击支付按钮"; + icon = FAssets.icons.circleDollarSign; + break; + case OrderPayDisplayType.qrCodeScan: + msg = '二维码扫一扫支付,请让用户打开"诗画浦江"或"浙政钉"扫一扫'; + icon = FAssets.icons.scanLine; + break; + } + return appEmpty( + context, + loading: isLoading, + loadingText: "识别中", + emptyText: msg, + emptyIcon: icon, + ).height(double.infinity); +} diff --git a/lib/common/widgets/order/steelyard_container.dart b/lib/common/widgets/order/steelyard_container.dart new file mode 100644 index 0000000..a89d1d1 --- /dev/null +++ b/lib/common/widgets/order/steelyard_container.dart @@ -0,0 +1,267 @@ +import 'dart:async'; + +import 'package:common_utils/common_utils.dart'; +import 'package:ducafe_ui_core/ducafe_ui_core.dart'; +import 'package:flutter_easyloading/flutter_easyloading.dart'; +import 'package:forui/forui.dart'; +import 'package:get/get.dart'; +import 'package:flutter/material.dart'; +import 'package:pj_supermarket_cloud/common/index.dart'; + +class SteelyardController extends GetxController { + /// **************** start 回调函数 start **************** + + // |开始称重|的回调 + VoidCallback? onWeightStart; + + // |确定重量|的回调 + VoidCallback? onWeightSubmit; + + // |取消|的回调 + VoidCallback? onWeightCancel; + + /// **************** end 回调函数 end **************** + + static const _tag = "称重视图组件"; + + final appC = Get.find(); + + // 称重商品 + SupermarketSpuEntity? _product; + + // 重量 + RxDouble widget = RxDouble(0); + + // 回调的重量 + RxDouble callbackWidget = RxDouble(0); + + // 价格 + RxInt price = RxInt(0); + + // 添加计时器 + Timer? _noUpdateTimer; + static const _timeoutDuration = Duration(seconds: 3); + + // 点击|开始称重| + void weightStartCallback() { + EasyLoading.show( + status: '准备中...', + maskType: EasyLoadingMaskType.black, + ); + widget.value = 0; + callbackWidget.value = 0; + price.value = 0; + _listenSteelyard(); + onWeightStart?.call(); + TtsManage.speak("开始称重"); + EasyLoading.dismiss(); + } + + // 点击|确定重量| + void weightSubmitCallback() { + EasyLoading.show( + status: '确定中...', + maskType: EasyLoadingMaskType.black, + ); + _closeSerial(); + onWeightSubmit?.call(); + EasyLoading.dismiss(); + } + + // 点击|取消| + void weightCancelCallback() { + EasyLoading.show( + status: '取消中...', + maskType: EasyLoadingMaskType.black, + ); + _closeSerial(); + onWeightCancel?.call(); + EasyLoading.dismiss(); + } + + // 打开称重 + bool open(SupermarketSpuEntity current) { + final isOpen = _openSerial(); + if (_product != null) { + Toast.error(msg: "正在称重中,请勿重复点击"); + return false; + } + if (!isOpen) { + _closeStatus(); + return false; + } + _closeStatus(); + _product = current; + weightStartCallback(); + return true; + } + + void close() { + _closeStatus(); + _closeSerial(); + } + + // 清除状态 + _closeStatus() { + _product = null; + widget.value = 0; + callbackWidget.value = 0; + price.value = 0; + _noUpdateTimer?.cancel(); + _noUpdateTimer = null; + } + + // 打开称串口 + bool _openSerial() { + if (appC.srialSteelyardManage.isConnect) { + appC.srialSteelyardManage.close(); + } + appC.srialSteelyardManage.connect(); + if (appC.srialSteelyardManage.isConnect) { + logger.i("|$_tag|打开称串口成功"); + return true; + } + logger.e("|$_tag|打开称串口失败"); + return false; + } + + // 关闭串口 + void _closeSerial() { + appC.srialSteelyardManage.close(); + if (appC.srialSteelyardManage.isConnect) { + logger.e("|$_tag|关闭串口失败"); + return; + } + logger.i("|$_tag|关闭串口成功"); + } + + // 监听串口 + void _listenSteelyard() { + try { + if (_product == null) return; + appC.srialSteelyardManage.readerClose(); + appC.srialSteelyardManage.listen((event) { + final w = appC.srialSteelyardManage.parseWidget(event); + if (ObjectUtil.isEmpty(w)) return; + // 保留两位小数并向上取整 + final roundedWeight = (w! * 100).round() / 100; + callbackWidget.value = roundedWeight; + }); + interval( + callbackWidget, + (callback) { + if (_product == null) return; + widget.value = callbackWidget.value; + // 获取价格 + final productPrice = _getProudctPrice(_product!); + final p = (callbackWidget.value * productPrice).truncate().ceil(); + price.value = p; + logger.i( + "|$_tag|重量|${widget.value}|单价|$productPrice|总价|${price.value}|", + ); + // 重置超时计时器 + _noUpdateTimer?.cancel(); + _noUpdateTimer = Timer(_timeoutDuration, () { + if (widget.value > 0) { + weightSubmitCallback(); + } + }); + }, + time: const Duration(milliseconds: 100), + ); + } catch (e) { + _closeStatus(); + logger.e("|$_tag|打开并监听称串口失败error|$e|"); + } + } + + // 获取当前商品的价格,有促销价先用促销价 + int _getProudctPrice(SupermarketSpuEntity product) { + if (product.promotionPrice != 0) { + return product.promotionPrice; + } + return product.retailPrice; + } +} + +Widget steelyardEmpty(BuildContext context) { + final theme = FTheme.of(context); + return [ + [ + FIcon( + FAssets.icons.layers, + size: theme.typography.xl2.fontSize, + ), + SizedBox(width: 10.w), + Text( + "请将商品放置到电子称中,点击开始称重", + style: theme.typography.xl, + ), + ].toRow(mainAxisAlignment: MainAxisAlignment.center), + SizedBox(height: 10.w), + Text( + "注意:请将电子称上的物品全部清空再点击开始称重", + style: theme.typography.base, + ), + ].toColumn(mainAxisAlignment: MainAxisAlignment.center); +} + +Widget steelyardWidget(BuildContext context) { + final steelyardC = Get.find(); + final theme = FTheme.of(context); + return [ + [ + Text("重量:", style: theme.typography.xl2), + Obx( + () => Text( + "${steelyardC.widget.value}/kg", + style: theme.typography.xl2, + ), + ), + ].toRow().alignLeft().width(300.w), + [ + Text("价格:", style: theme.typography.xl2), + Obx( + () => appPrice(context, steelyardC.price.value), + ), + ].toRow().alignLeft().width(200.w), + ].toColumn(mainAxisAlignment: MainAxisAlignment.center); +} + +Widget steelyardContainer(BuildContext context) { + final theme = FTheme.of(context); + final steelyardC = Get.find(); + final textStyle = theme.typography.xl.copyWith( + color: theme.colorScheme.secondary, + ); + const textWidth = 220; + return [ + Visibility( + visible: steelyardC.widget.value >= 0.1, + replacement: steelyardEmpty(context), + child: steelyardWidget(context), + ).expanded(), + SizedBox(height: 100.h), + [ + // FButton( + // label: Text("开始称重", style: textStyle), + // onPress: () => controller.weightStartCallback(), + // ).width(textWidth.w), + // FButton( + // label: Text("确定重量", style: textStyle), + // onPress: () => controller.weightSubmitCallback(), + // ).width(textWidth.w), + FButton( + style: FButtonStyle.destructive, + label: Text("取消", style: textStyle), + onPress: () => steelyardC.weightCancelCallback(), + ).width(textWidth.w), + ] + .toRowSpace( + space: 50.w, + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.center, + ) + .marginOnly(top: 20.h), + ].toColumn(mainAxisAlignment: MainAxisAlignment.center); +} diff --git a/lib/common/widgets/setting/setting_password_dialog.dart b/lib/common/widgets/setting/setting_password_dialog.dart new file mode 100644 index 0000000..a413448 --- /dev/null +++ b/lib/common/widgets/setting/setting_password_dialog.dart @@ -0,0 +1,201 @@ +import 'package:ducafe_ui_core/ducafe_ui_core.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:forui/forui.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +const String _terminalAdminPassword = '250303'; + +/// 终端设置与退出共用的二次验证。 +/// +/// 当前按业务要求使用固定管理密码,后续若改为后台配置,只需替换此处校验来源。 +Future terminalAdminPasswordDialog( + BuildContext context, { + required String actionLabel, +}) async { + final result = await showAdaptiveDialog( + context: context, + barrierDismissible: false, + builder: (_) => _TerminalAdminPasswordDialog(actionLabel: actionLabel), + ); + return result ?? false; +} + +/// 保留旧方法名称,避免其他页面引用时破坏既有接口。 +Future settingPasswordDialog(BuildContext context) => + terminalAdminPasswordDialog(context, actionLabel: '进入设置'); + +class _TerminalAdminPasswordDialog extends StatefulWidget { + const _TerminalAdminPasswordDialog({required this.actionLabel}); + + final String actionLabel; + + @override + State<_TerminalAdminPasswordDialog> createState() => + _TerminalAdminPasswordDialogState(); +} + +class _TerminalAdminPasswordDialogState + extends State<_TerminalAdminPasswordDialog> { + late final TextEditingController _controller; + bool _obscureText = true; + bool _closing = false; + String? _errorText; + + @override + void initState() { + super.initState(); + _controller = TextEditingController(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void _submit() { + if (_closing) return; + final password = _controller.text.trim(); + if (password.isEmpty) { + setState(() => _errorText = '请输入管理密码'); + return; + } + if (password != _terminalAdminPassword) { + setState(() => _errorText = '管理密码不正确,请重新输入'); + HapticFeedback.mediumImpact(); + return; + } + _close(true); + } + + void _close(bool result) { + if (_closing) return; + _closing = true; + // 先释放输入法焦点,再由路由守卫等待弹窗退场后切换页面,避免 Overlay 与根路由同时拆除。 + FocusManager.instance.primaryFocus?.unfocus(); + Navigator.of(context).pop(result); + } + + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + return FDialog( + direction: Axis.horizontal, + title: Row( + children: [ + Container( + width: 48.w, + height: 48.w, + alignment: Alignment.center, + decoration: BoxDecoration( + color: XfjDesignTokens.primarySoft, + borderRadius: BorderRadius.circular( + XfjDesignTokens.controlRadius, + ), + ), + child: FIcon( + FAssets.icons.shieldCheck, + color: XfjDesignTokens.primary, + size: 24.sp, + ), + ), + SizedBox(width: 12.w), + Text( + '终端操作验证', + style: theme.typography.xl2.copyWith( + color: XfjDesignTokens.ink, + fontWeight: FontWeight.w800, + ), + ), + ], + ), + body: Padding( + padding: EdgeInsets.symmetric(vertical: 16.h), + child: SizedBox( + width: 620.w, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '${widget.actionLabel}前需要验证管理密码。', + style: theme.typography.base.copyWith( + color: XfjDesignTokens.muted, + ), + ), + SizedBox(height: 16.h), + FTextField.password( + controller: _controller, + autofocus: true, + label: const Text('管理密码'), + hint: '请输入管理密码', + maxLines: 1, + obscureText: _obscureText, + keyboardType: TextInputType.number, + textInputAction: TextInputAction.done, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + ], + onChange: (_) { + if (_errorText != null) setState(() => _errorText = null); + }, + onSubmit: (_) => _submit(), + suffixBuilder: (_, __, ___) => InkWell( + onTap: () => setState(() => _obscureText = !_obscureText), + borderRadius: BorderRadius.circular( + XfjDesignTokens.controlRadius, + ), + child: SizedBox( + width: 48.w, + height: 48.h, + child: FIcon( + _obscureText ? FAssets.icons.eye : FAssets.icons.eyeOff, + color: XfjDesignTokens.muted, + size: 22.sp, + ).center(), + ), + ), + ), + if (_errorText != null) ...[ + SizedBox(height: 8.h), + Row( + children: [ + FIcon( + FAssets.icons.circleAlert, + color: XfjDesignTokens.danger, + size: 18.sp, + ), + SizedBox(width: 8.w), + Text( + _errorText!, + style: theme.typography.sm.copyWith( + color: XfjDesignTokens.danger, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ], + ], + ), + ), + ), + actions: [ + FButton( + style: FButtonStyle.outline, + label: Text('取消', style: theme.typography.base), + onPress: () => _close(false), + ), + FButton( + label: Text( + '验证并继续', + style: theme.typography.base.copyWith(color: Colors.white), + ), + onPress: _submit, + ), + ], + ); + } +} diff --git a/lib/common/widgets/setting/terminal_setting_tile.dart b/lib/common/widgets/setting/terminal_setting_tile.dart new file mode 100644 index 0000000..d87995b --- /dev/null +++ b/lib/common/widgets/setting/terminal_setting_tile.dart @@ -0,0 +1,47 @@ +import 'package:common_utils/common_utils.dart'; +import 'package:ducafe_ui_core/ducafe_ui_core.dart'; +import 'package:flutter/material.dart'; +import 'package:forui/forui.dart'; + +/// 设置页的列表图标与文本需要保持统一留白,集中封装后各设置分区只描述业务内容。 +Widget appPrefixIcon( + BuildContext context, + SvgAsset icon, { + double padding = 20, +}) { + final theme = FTheme.of(context); + return FIcon( + icon, + size: theme.typography.xl.fontSize, + color: theme.colorScheme.primary, + ).paddingLeft(padding.w); +} + +Widget appSuffixIcon(BuildContext context, SvgAsset icon) { + final theme = FTheme.of(context); + return FIcon( + icon, + size: theme.typography.xl.fontSize, + color: theme.colorScheme.mutedForeground, + ).paddingRight(20.w); +} + +Widget appPrefixTitle( + BuildContext context, + String? text, { + double padding = 20, +}) { + final theme = FTheme.of(context); + final value = ObjectUtil.isEmpty(text) ? '--' : text!; + return Text(value, style: theme.typography.xl).paddingLeft(padding.w); +} + +Widget appSuffixTitle(BuildContext context, String? text) { + final theme = FTheme.of(context); + final value = ObjectUtil.isEmpty(text) ? '--' : text!; + return Text( + value, + style: + theme.typography.xl.copyWith(color: theme.colorScheme.mutedForeground), + ).paddingRight(20.w); +} diff --git a/lib/common/widgets/toast/toast_widget.dart b/lib/common/widgets/toast/toast_widget.dart new file mode 100644 index 0000000..db5bc9c --- /dev/null +++ b/lib/common/widgets/toast/toast_widget.dart @@ -0,0 +1,103 @@ +import 'package:ducafe_ui_core/ducafe_ui_core.dart'; +import 'package:flutter/material.dart'; +import 'package:fluttertoast/fluttertoast.dart'; +import 'package:forui/forui.dart'; +import 'package:get/get.dart'; + +class Toast { + static Widget _successTotal({ + required BuildContext context, + String msg = "操作成功", + }) { + final theme = FTheme.of(context); + List widgets = [ + const Icon( + Icons.error, + size: 35, + color: Colors.black, + ), + SizedBox(width: 10.w), + Text(msg, style: theme.typography.xl.copyWith(color: Colors.black)), + ]; + return Container( + padding: EdgeInsets.symmetric(horizontal: 30.w, vertical: 15.h), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(25.0), + color: Colors.greenAccent, + ), + child: widgets.toRow( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + ), + ); + } + + static Widget _errorTotal({ + required BuildContext context, + String msg = "操作失败", + }) { + final theme = FTheme.of(context); + List widgets = [ + const Icon( + Icons.error, + size: 35, + color: Colors.white, + ), + SizedBox(width: 10.w), + Text(msg, style: theme.typography.xl.copyWith(color: Colors.white)), + ]; + return Container( + padding: EdgeInsets.symmetric(horizontal: 30.w, vertical: 15.h), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(25.0), + color: Colors.red, + ), + child: widgets.toRow( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + ), + ); + } + + static Future base({ + required String msg, + Color backgroundColor = Colors.black, + Color textColor = Colors.white, + ToastGravity gravity = ToastGravity.TOP, + }) { + return Fluttertoast.showToast( + msg: msg, + gravity: gravity, + timeInSecForIosWeb: 1, + backgroundColor: backgroundColor, + textColor: textColor, + fontSize: 16.sp, + ); + } + + static success({String msg = "操作成功"}) { + final context = Get.context!; + final fToast = FToast(); + fToast.init(context); + fToast.showToast( + gravity: ToastGravity.TOP, + child: _successTotal( + context: context, + msg: msg, + ), + ); + } + + static error({String msg = "操作失败"}) { + final context = Get.context!; + final fToast = FToast(); + fToast.init(context); + fToast.showToast( + gravity: ToastGravity.TOP, + child: _errorTotal( + context: context, + msg: msg, + ), + ); + } +} diff --git a/lib/common/widgets/user/user_info.dart b/lib/common/widgets/user/user_info.dart new file mode 100644 index 0000000..055108f --- /dev/null +++ b/lib/common/widgets/user/user_info.dart @@ -0,0 +1,59 @@ +import 'package:common_utils/common_utils.dart'; +import 'package:ducafe_ui_core/ducafe_ui_core.dart'; +import 'package:flutter/widgets.dart'; +import 'package:forui/forui.dart'; +import 'package:pj_supermarket_cloud/common/index.dart'; + +Widget appUserInfo( + BuildContext context, { + dynamic username = "未知", + dynamic balance = "未知", + String? avatar = "", + bool visibleMoney = true, +}) { + if (ObjectUtil.isEmpty(username)) { + username = "未知"; + } + if (ObjectUtil.isEmpty(balance)) { + balance = "未知"; + } + if (ObjectUtil.isEmpty(avatar)) { + avatar = ""; + } + final theme = FTheme.of(context); + final style = theme.typography.lg; + return [ + ObjectUtil.isNotEmpty(avatar) + ? appImg(avatar).width(80.w).height(80.w) + : Image.asset( + AssetsImages.defaultAvatar, + fit: BoxFit.cover, + width: 80.w, + height: 80.w, + ), + SizedBox(width: 10.w), + [ + Text("姓名:$username", style: style), + [ + Text("余额:", style: style), + appPrice(context, balance), + ].toRow().offstage(offstage: !visibleMoney), + ].toColumn(crossAxisAlignment: CrossAxisAlignment.start), + [ + FButton( + label: Text( + "识别", + style: theme.typography.base.copyWith( + color: theme.colorScheme.primary, + ), + ), + style: FButtonStyle.outline, + onPress: () {}, + ).width(100.w).height(50.w).offstage(offstage: true), + ].toColumn(), + ] + .toRow( + mainAxisAlignment: MainAxisAlignment.center, + ) + .width(double.infinity); +} diff --git a/lib/common/widgets/user/user_tenant_dialog.dart b/lib/common/widgets/user/user_tenant_dialog.dart new file mode 100644 index 0000000..581a963 --- /dev/null +++ b/lib/common/widgets/user/user_tenant_dialog.dart @@ -0,0 +1,50 @@ +import 'package:common_utils/common_utils.dart'; +import 'package:flutter/material.dart'; +import 'package:forui/forui.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +Widget appTenantDialog( + BuildContext context, + FRadioSelectGroupController controller, { + required List list, +}) { + final theme = FTheme.of(context); + final selectList = list.where((v) => ObjectUtil.isNotEmpty(v.code)); + return FSelectMenuTile( + title: Text( + "单位", + style: theme.typography.base, + ), + groupController: controller, + validator: (value) => value == null ? '请选择' : null, + autoHide: true, + prefixIcon: FIcon( + FAssets.icons.building2, + size: theme.typography.xl.fontSize, + ), + menu: selectList.map((v) { + return FSelectTile( + title: Text( + v.name!, + style: theme.typography.base, + ), + value: v.code!, + ); + }).toList(), + details: ListenableBuilder( + listenable: controller, + builder: (context, _) { + final v = controller.value.firstOrNull; + if (v == null) return const SizedBox(); + final target = selectList.firstWhere((i) => i.id == v); + return Text( + target.name!, + style: theme.typography.base.copyWith( + color: theme.colorScheme.primary, + ), + ); + }, + ), + ); +} diff --git a/lib/main.dart b/lib/main.dart new file mode 100644 index 0000000..a43a3ac --- /dev/null +++ b/lib/main.dart @@ -0,0 +1,45 @@ +import 'dart:async'; +import 'dart:ui'; + +import 'package:flutter/material.dart'; +import 'package:flutter_subscreen_plugin/flutter_subscreen_plugin.dart'; +import 'package:get/get.dart'; +import 'package:pj_supermarket_cloud/app.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; +import 'package:pj_supermarket_cloud/sub_screen/page/index.dart'; + +void main() { + checkSubScreen(); +} + +Future checkSubScreen() async { + final defaultRouteName = PlatformDispatcher.instance.defaultRouteName; + logger.d("当前的屏幕$defaultRouteName"); + switch (defaultRouteName) { + case "/": + WidgetsFlutterBinding.ensureInitialized(); + DartPluginRegistrant.ensureInitialized(); + await AppRuntimeTool.initializePlatform(); + final appTheme = Get.put(AppThemeControl()); + Get.put(TerminalAdminSessionControl(), permanent: true); + final terminalMenu = Get.put(TerminalMenuControl()); + runApp(MainApp()); + await Future.microtask(() async { + final app = Get.put(AppControl()); + await app.initAsync(); + await appTheme.initAsync(); + await terminalMenu.initAsync(); + }); + break; + case "subMain": + WidgetsFlutterBinding.ensureInitialized(); + DartPluginRegistrant.ensureInitialized(); + bool isMultipleScreen = await SubScreenPlugin.isMultipleScreen; + bool isOverlay = await SubScreenPlugin.checkOverlayPermission; + if (isMultipleScreen && isOverlay) { + runApp(SubAppPage()); + } + break; + } +} diff --git a/lib/pages/canteen_order_total/controller.dart b/lib/pages/canteen_order_total/controller.dart new file mode 100644 index 0000000..9ca762d --- /dev/null +++ b/lib/pages/canteen_order_total/controller.dart @@ -0,0 +1,43 @@ +import 'package:get/get.dart'; +import 'package:pj_supermarket_cloud/common/index.dart'; + +class CanteenOrderTotalController extends GetxController { + CanteenOrderTotalController(); + + final appC = Get.find(); + + Rx sysSetting = Rx(null); + + Rx chartLatitude = ServiceChartLatitude( + dayTotal: [], + weekTotal: [], + monthTotal: [], + yearTotal: [], + allTotal: [], + ).obs; + + _initData() { + update(["canteen_order_total"]); + } + + @override + void onInit() async { + super.onInit(); + sysSetting.value = await AppSettingTool.getSysSetting(); + await getChartLatitude(); + } + + @override + void onReady() { + super.onReady(); + _initData(); + } + + /// 获取统计数据 + Future getChartLatitude() async { + final pa = {"merchantId": sysSetting.value?.serviceMerchantId}; + final data = await HardApi.chartLatitude(pa); + if (data == null) return; + chartLatitude.value = data; + } +} diff --git a/lib/pages/canteen_order_total/view.dart b/lib/pages/canteen_order_total/view.dart new file mode 100644 index 0000000..12b2b55 --- /dev/null +++ b/lib/pages/canteen_order_total/view.dart @@ -0,0 +1,175 @@ +import 'package:ducafe_ui_core/ducafe_ui_core.dart'; +import 'package:flutter/material.dart'; +import 'package:forui/forui.dart'; +import 'package:get/get.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +import 'controller.dart'; + +class CanteenOrderTotalPage extends GetView { + const CanteenOrderTotalPage({super.key}); + + Widget _buildPeriodCard( + BuildContext context, { + required String title, + required String description, + required List data, + bool emphasized = false, + }) { + final theme = FTheme.of(context); + return TerminalSurface( + color: emphasized ? XfjDesignTokens.primary : XfjDesignTokens.surface, + padding: EdgeInsets.all(24.w), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: theme.typography.xl.copyWith( + color: emphasized ? Colors.white : XfjDesignTokens.ink, + fontWeight: FontWeight.w700, + ), + ), + SizedBox(height: 6.h), + Text( + description, + style: theme.typography.sm.copyWith( + color: + emphasized ? const Color(0xFFCBD5E1) : XfjDesignTokens.muted, + ), + ), + SizedBox(height: 20.h), + Expanded( + child: Row( + children: ProductHourType.values + .where((type) => type.code != '-1') + .map((type) { + final target = data.firstWhereOrNull( + (item) => item.hourType == type.code, + ); + return Container( + margin: EdgeInsets.only(right: XfjDesignTokens.cardGap.w), + padding: EdgeInsets.all(16.w), + decoration: BoxDecoration( + color: emphasized + ? Colors.white.withValues(alpha: 0.08) + : XfjDesignTokens.canvas, + borderRadius: BorderRadius.circular(14), + border: Border.all( + color: emphasized + ? Colors.white.withValues(alpha: 0.12) + : XfjDesignTokens.line, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Row( + children: [ + FIcon( + type.icon, + color: emphasized + ? Colors.white + : XfjDesignTokens.primary, + ), + SizedBox(width: 8.w), + Text( + type.title, + style: theme.typography.base.copyWith( + color: emphasized + ? Colors.white + : XfjDesignTokens.ink, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + SizedBox(height: 14.h), + Text( + '${target?.count ?? '0'} 次', + style: theme.typography.xl2.copyWith( + color: + emphasized ? Colors.white : XfjDesignTokens.ink, + fontWeight: FontWeight.w800, + ), + ), + SizedBox(height: 8.h), + appPrice(context, target?.sum ?? '0'), + ], + ), + ).expanded(); + }).toList(), + ), + ), + ], + ), + ); + } + + Widget _buildView(BuildContext context) { + return Obx( + () => Column( + children: [ + _buildPeriodCard( + context, + title: '累计交易', + description: '终端启用以来的完整交易汇总', + data: controller.chartLatitude.value.allTotal, + emphasized: true, + ).expanded(flex: 11), + SizedBox(height: XfjDesignTokens.cardGap.h), + Row( + children: [ + _buildPeriodCard( + context, + title: '今日', + description: '今日实时数据', + data: controller.chartLatitude.value.dayTotal, + ).expanded(), + SizedBox(width: XfjDesignTokens.cardGap.w), + _buildPeriodCard( + context, + title: '本周', + description: '本自然周汇总', + data: controller.chartLatitude.value.weekTotal, + ).expanded(), + ], + ).expanded(flex: 10), + SizedBox(height: XfjDesignTokens.cardGap.h), + Row( + children: [ + _buildPeriodCard( + context, + title: '本月', + description: '本自然月汇总', + data: controller.chartLatitude.value.monthTotal, + ).expanded(), + SizedBox(width: XfjDesignTokens.cardGap.w), + _buildPeriodCard( + context, + title: '本年', + description: '本自然年汇总', + data: controller.chartLatitude.value.yearTotal, + ).expanded(), + ], + ).expanded(flex: 10), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + return GetBuilder( + init: CanteenOrderTotalController(), + id: 'canteen_order_total', + builder: (_) => TerminalShell( + title: '交易统计', + activeNav: TerminalNavType.statistics, + child: _buildView(context), + ), + ); + } +} diff --git a/lib/pages/canteen_product/controller.dart b/lib/pages/canteen_product/controller.dart new file mode 100644 index 0000000..c3ad9fd --- /dev/null +++ b/lib/pages/canteen_product/controller.dart @@ -0,0 +1,401 @@ +import 'dart:async'; + +import 'package:common_utils/common_utils.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_easyloading/flutter_easyloading.dart'; +import 'package:flutter_subscreen_plugin/flutter_subscreen_plugin.dart'; +import 'package:get/get.dart'; + +import 'package:forui/forui.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +class CanteenProductController extends GetxController with OrderMixin { + CanteenProductController(); + + static const _tag = "食堂购买页面"; + + StreamSubscription? mqUpdateListen; + + final searchC = TextEditingController(); + + /// 食堂接口沿用原有整批商品查询,搜索只在当前餐段和分类结果内过滤, + /// 避免为了 UI 输入框改动后端接口或数据库查询契约。 + final searchKeyword = ''.obs; + + final hourTypeC = FRadioSelectGroupController( + value: ProductHourType.MORNING.code, + ); + + late SysSetting appSetting; + + _initData() { + update(["canteen_product"]); + } + + @override + void onInit() async { + super.onInit(); + await _initState(); + _listrenMq(); + } + + @override + void onReady() { + super.onReady(); + _initData(); + } + + @override + void onClose() { + super.onClose(); + SubScreenPlugin.sendMsgToViceScreen(SubScreenAction.jumpSubBack.name); + // 读卡器 + appC.serialCardManage.readerClose(); + appC.serialCardManage.close(); + hourTypeC.dispose(); + searchC.dispose(); + // mq + mqUpdateListen?.cancel(); + } + + List get visibleCanteenProducts { + final keyword = searchKeyword.value.trim().toLowerCase(); + if (keyword.isEmpty) return canteenSpuList.toList(); + return canteenSpuList.where((product) { + return product.name.toLowerCase().contains(keyword) || + product.serviceUniqueCode.toLowerCase().contains(keyword); + }).toList(); + } + + void doSearch() { + searchKeyword.value = searchC.text.trim(); + } + + void doResetSearch() { + searchC.clear(); + searchKeyword.value = ''; + } + + Future _initState() async { + EasyLoading.show( + status: '初始化资源...', + maskType: EasyLoadingMaskType.black, + ); + appSetting = await AppSettingTool.getSysSetting(); + // 打开副屏 + await AppSubScreenTool.show(); + SubScreenPlugin.sendMsgToViceScreen(SubScreenAction.jumpSubPage.name); + // 组件初始化 + hourType.value = appSetting.hourType ?? ProductHourType.MORNING; + hourTypeC.value = {hourType.value.code}; + sysType = SysType.CANTEEN; + _listenPayTypeSelectCallback(); + // 更新分类和商品 + try { + await SyncProductTool.updateDatabase(); + await _getMchRule(); + } catch (e, stack) { + logger.e("|$_tag|初始化资源失败:error|$e|stack|$stack"); + Toast.error(msg: "初始化资源失败,请检查网络情况"); + EasyLoading.dismiss(); + Get.offAndToNamed(RouteNames.cashier); + } + // 获取分类和商品 + await getClassify(); + await getProduct(); + // 清空购物车 + await _clearShopCard(); + EasyLoading.dismiss(); + // 监听时间段选择 + hourTypeC.addValueListener((v) async { + final currentHourType = ProductHourType.values.fromCode( + v.first, + ); + if (currentHourType == null) { + return; + } + EasyLoading.show( + status: '切换中...', + maskType: EasyLoadingMaskType.black, + ); + hourType.value = currentHourType; + appSetting.hourType = currentHourType; + await getClassify(); + await getProduct(); + await _clearShopCard(); + await AppSettingTool.setSysSetting(appSetting); + EasyLoading.dismiss(); + }); + } + + _listrenMq() { + mqUpdateListen = + appC.mqUpdateEventBus.on().listen((event) async { + switch (event) { + // 食堂商品 + case MqtActionEnum.CANTEEN_PRODUCT: + await getProduct(); + break; + // 食堂分类 + case MqtActionEnum.CANTEEN_CLASSIFY: + await getClassify(); + break; + default: + } + }); + } + + // |下单|点击购买 + Future doPay() async { + final validShopCar = _validateShopCar(); + if (!validShopCar) { + await selectPayTypeDialog( + paymentEnabled: false, + disabledMessage: '请先添加菜品,再选择支付方式', + ); + return; + } + // 创建本地订单 + await createSqlOrder(); + await selectPayTypeDialog(); + } + + // |下单|点击取消下单 + Future doCancelPay({bool cancelShop = true}) async { + if (cancelShop) await _clearShopCard(); + sendSubScreen(); + } + + // |+|或者|-|点击更改商品数量 + void doComputedInputChange(int number, int index) async { + final current = shopCardList[index]; + current.productNumber = number; + current.totalPrice = _computedProductTotalPrice( + current.productPrice, + current.productNumber, + ); + if (current.productNumber == 0) { + shopCardList.removeWhere((e) { + return e.productServiceUniqueCode == current.productServiceUniqueCode; + }); + } + computedTotalPrice(); + await _computedAmountMealPrice(); + shopCardList.refresh(); + sendSubScreen(); + } + + // 点击商品 + void doProduct(CanteenSpuEntity current) async { + final target = shopCardList.firstWhereOrNull((e) { + return e.productServiceUniqueCode == current.serviceUniqueCode; + }); + if (ObjectUtil.isEmpty(target)) { + final data = ShopCardEntity( + orderId: -1, + productServiceUniqueCode: current.serviceUniqueCode, + productName: current.name, + productPic: current.image ?? "", + productPrice: current.price, + nature: ProductNature.DEFAULT, + productNumber: 1, + productWidget: 0, + totalPrice: current.price, + isPriceIncreased: current.isPriceIncreased, + ); + shopCardList.add(data); + } else { + // 旧商品 + target!.productNumber += 1; + target.totalPrice = _computedProductTotalPrice( + target.productPrice, + target.productNumber, + ); + } + computedTotalPrice(); + await _computedAmountMealPrice(); + shopCardList.refresh(); + sendSubScreen(); + } + + void _listenPayTypeSelectCallback() { + final payTypeSelect = Get.find(); + // |支付方式|点击|开始识别| + payTypeSelect.onRecognition = () async {}; + // 识别出用户的回调 + payTypeSelect.onDiscernUser = (payUser) { + if (payUser == null) return; + this.payUser.value = payUser; + // 计算额外的价格 + _computedAmountMealPrice(); + sendSubScreen(); + }; + // |支付方式|点击|确定| + payTypeSelect.onSubmit = ( + user, + orderType, + orderPayType, + orderPayDisplayType, + qrCode, + ) async { + final log = + "|$_tag|回调,识别到用户:|${user.toJson()}|orderType:|$orderType|;orderPayType:|$orderPayType|;orderPayDisplayType|$orderPayDisplayType|;"; + logger.i(log); + this.orderType = orderType; + this.orderPayType.value = orderPayType; + this.orderPayDisplayType.value = orderPayDisplayType; + globalQrCode = qrCode; + payUser.value = user; + final validPayUser = _validatePayUser(); + if (!validPayUser) return; + final validShopCar = _validateShopCar(); + if (!validShopCar) return; + EasyLoading.show( + status: '支付中...', + maskType: EasyLoadingMaskType.black, + ); + // 最后计算一次额外的价格 + await _computedAmountMealPrice(); + await updateSqlOrder(); + await createApiOrder(); + putOffSendSubScreen(); + _clearShopCard(); + EasyLoading.dismiss(); + }; + // |支付方式|点击|取消| + payTypeSelect.onCancel = () {}; + } + + bool _validatePayUser() { + bool flag = true; + if (ObjectUtil.isEmpty(payUser.value?.walletBalance)) { + TtsManage.speak("用户钱包不存在"); + Toast.error(msg: "校验购物车失败,用户钱包不存在"); + return false; + } + if (payUser.value!.walletBalance! < totalPrice.value) { + TtsManage.speak("用户余额不足"); + Toast.error(msg: "校验购物车失败,用户余额不足"); + return false; + } + return flag; + } + + bool _validateShopCar() { + bool flag = false; + for (final v in shopCardList) { + if (v.productNumber > 0) { + flag = true; + break; + } + } + if (!flag) { + Toast.error(msg: "校验购物车失败,未选择饭菜"); + return false; + } + return flag; + } + + // 计算额外的价格 + Future _computedAmountMealPrice() async { + var extraPrice = 0; + // final extraPrice = await _getMchRulePrice( + // totalPrice.value, + // userCanteenId: payUser.value?.canteenId, + // ); + if (extraPrice != 0) { + amountMealPrice.value = extraPrice; + sendSubScreen(); + } + } + + Future _clearShopCard() async { + shopCardList.clear(); + payUser.value = null; + totalPrice.value = 0; + amountMealPrice.value = 0; + await _getDefaultProduct(); + } + + // 获取默认商品 + Future _getDefaultProduct() async { + final sysType = appSetting.hourType?.code ?? ProductHourType.MORNING.code; + final productDao = appC.dataBase.getCanteenSpuDao; + List list = await productDao.getDefault(sysType); + if (ObjectUtil.isEmptyList(list)) return; + for (final CanteenSpuEntity v in list) { + final shopCard = shopCardList.firstWhereOrNull((i) { + return int.tryParse(i.productServiceUniqueCode) == v.id; + }); + if (shopCard != null) continue; + final data = ShopCardEntity( + orderId: -1, + productServiceUniqueCode: v.serviceUniqueCode, + productName: v.name, + productPic: v.image ?? "", + productPrice: v.price, + nature: ProductNature.DEFAULT, + productNumber: 1, + productWidget: 0, + totalPrice: v.price, + isPriceIncreased: v.isPriceIncreased, + ); + shopCardList.add(data); + } + computedTotalPrice(isSendSubScreen: false); + } + + // 计算单个的价格 + int _computedProductTotalPrice(int price, int number) { + return price * number; + } + + /// 获取商户规则 + Future _getMchRule() async { + final isNetwork = AppNetworkTool.isAutoNetwork(); + if (!isNetwork) return; + final pa = {"targetMchId": appSetting.serviceMerchantId}; + final mchRoleList = await WalletApi.getMchRoleList(pa); + if (mchRoleList == null) return; + var priceSetting = await AppSettingTool.getCanteenExtraPriceConfig(); + priceSetting ??= CanteenExtraPriceSetting( + payAmountEnable: false, + payAmountMealLimit: 0, + payAmountMealTimes: 0, + mchRoleList: mchRoleList, + ); + priceSetting.mchRoleList = mchRoleList; + await AppSettingTool.setCanteenExtraPriceConfig(priceSetting); + } + + Future _getMchRulePrice(int price, {String? userCanteenId}) async { + int extraPrice = 0; + final setting = await AppSettingTool.getCanteenExtraPriceConfig(); + if (setting == null || ObjectUtil.isEmptyList(setting.mchRoleList)) { + return extraPrice; + } + for (final v in setting.mchRoleList!) { + int? basePrice = v.basePrice; + int? multiple = v.multiple; + String? rulePriceType = v.rulePriceType; + if (basePrice == null || multiple == null || rulePriceType == null) { + continue; + } + if (price < basePrice) continue; + final surplus = price - basePrice; + // 10 跨单位额外支付 + if (rulePriceType == "10") { + if (userCanteenId == null || v.sourceMchId != userCanteenId) continue; + extraPrice += (surplus * multiple / 100).round(); + continue; + } + // 20 每餐额外支付 + if (rulePriceType == "20") { + extraPrice += (surplus * multiple / 100).round(); + continue; + } + } + return extraPrice; + } +} diff --git a/lib/pages/canteen_product/view.dart b/lib/pages/canteen_product/view.dart new file mode 100644 index 0000000..7d9838a --- /dev/null +++ b/lib/pages/canteen_product/view.dart @@ -0,0 +1,86 @@ +import 'package:ducafe_ui_core/ducafe_ui_core.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +import 'controller.dart'; + +class CanteenProductPage extends GetView { + const CanteenProductPage({super.key}); + + @override + Widget build(BuildContext context) { + return GetBuilder( + init: Get.find(), + id: 'canteen_product', + builder: (_) => TerminalShell( + title: '智慧收银', + activeNav: TerminalNavType.cashier, + child: Obx(() { + final productEntities = controller.visibleCanteenProducts; + final products = productEntities.map(_toProductData).toList(); + return CashierWorkspace( + categories: controller.classifyList.toList(), + selectedCategory: controller.currentTabIndex, + onCategoryChanged: controller.doTab, + products: products, + onProductTap: (product) { + final entity = productEntities.firstWhere( + (item) => item.serviceUniqueCode == product.id, + ); + controller.doProduct(entity); + }, + cartItems: controller.shopCardList.toList(), + user: controller.payUser.value, + totalPrice: controller.totalPrice.value, + extraPrice: controller.amountMealPrice.value, + productCount: controller.shopCardList.fold( + 0, + (total, item) => total + item.productNumber, + ), + onQuantityChanged: (item, quantity) { + final index = controller.shopCardList.indexWhere( + (current) => + current.productServiceUniqueCode == + item.productServiceUniqueCode, + ); + if (index >= 0) { + controller.doComputedInputChange(quantity, index); + } + }, + onRemoveCartItem: (item) { + final index = controller.shopCardList.indexWhere( + (current) => + current.productServiceUniqueCode == + item.productServiceUniqueCode, + ); + if (index >= 0) controller.doComputedInputChange(0, index); + }, + onClearCart: controller.doCancelPay, + onPay: controller.doPay, + onCancel: controller.doCancelPay, + searchController: controller.searchC, + onSearch: controller.doSearch, + onReset: controller.doResetSearch, + headerTrailing: appRadioHourTypeDialog( + context, + controller.hourTypeC, + ).width(280.w), + cartTitle: '已点菜品', + ); + }), + ), + ); + } + + CashierProductData _toProductData(CanteenSpuEntity product) { + return CashierProductData( + id: product.serviceUniqueCode, + name: product.name, + image: product.image, + price: product.price, + badge: product.isPriceIncreased ? '当前餐段加价' : null, + ); + } +} diff --git a/lib/pages/canteen_reserve/controller.dart b/lib/pages/canteen_reserve/controller.dart new file mode 100644 index 0000000..020f0dc --- /dev/null +++ b/lib/pages/canteen_reserve/controller.dart @@ -0,0 +1,204 @@ +import 'dart:async'; + +import 'package:flutter_easyloading/flutter_easyloading.dart'; +import 'package:forui/forui.dart'; +import 'package:get/get.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +class CanteenReserveController extends GetxController { + CanteenReserveController(); + + final appC = Get.find(); + + StreamSubscription? mqUpdateListen; + + RxList reserveList = [].obs; + + Rx hourType = Rx(ProductHourType.MORNING); + + var hourTypeC = FRadioSelectGroupController( + value: ProductHourType.MORNING.code, + ); + + // table + RxInt currentPage = 1.obs; + + RxInt totalPage = 0.obs; + + RxInt pageSize = 10.obs; + + _initData() { + update(["canteen_reserve"]); + } + + @override + void onInit() async { + super.onInit(); + await syncAllBroadcast(isMessageDialog: false); + _listrenMq(); + hourTypeC.addValueListener((v) async { + final currentHourType = ProductHourType.values.fromCode( + v.first, + ); + if (currentHourType == null) return; + EasyLoading.show( + status: '切换中...', + maskType: EasyLoadingMaskType.black, + ); + hourType.value = currentHourType; + currentPage.value = 1; + await _getReservePage(); + EasyLoading.dismiss(); + }); + } + + @override + void onReady() { + super.onReady(); + _initData(); + } + + @override + void onClose() { + super.onClose(); + hourTypeC.dispose(); + // mq + mqUpdateListen?.cancel(); + } + + Future doPage({int? page}) async { + if (page != null) { + currentPage.value = page; + } + await _getReservePage(); + } + + /// 获取预约列表 + Future _getReservePage() async { + final appSetting = await AppSettingTool.getSysSetting(); + final sysType = appSetting.sysType.name; + final broadcastDao = appC.dataBase.getBroadcastDao; + + /// 处理查询参数 + String hourTypeCode = hourType.value.code; + if (sysType == SysType.SUPERMARKET.name) { + hourTypeCode = "-1"; + } + final list = await broadcastDao.findAllPage( + hourTypeCode, + pageSize.value, + (currentPage.value - 1) * pageSize.value, + ); + final total = await broadcastDao.count(hourTypeCode); + totalPage.value = total ?? 0; + reserveList.value = list; + } + + // 切换播报状态 + Future toggle(BroadcastEntity v) async { + EasyLoading.show( + status: '切换中...', + maskType: EasyLoadingMaskType.black, + ); + final broadcastDao = appC.dataBase.getBroadcastDao; + v.enable = !v.enable; + await broadcastDao.edit(v); + await _getReservePage(); + EasyLoading.dismiss(); + } + + // 重置播报次数 + Future reset(BroadcastEntity v) async { + EasyLoading.show( + status: '重置中...', + maskType: EasyLoadingMaskType.black, + ); + final broadcastDao = appC.dataBase.getBroadcastDao; + v.broadcastCount = 0; + await broadcastDao.edit(v); + // 同步给后台 + final pa = { + "ids": [v.serviceCode], + "count": -1 + }; + await HardApi.broadcast(pa); + await _getReservePage(); + EasyLoading.dismiss(); + } + + /// 立即广播 + Future startBroadcast() async { + EasyLoading.show( + status: '广播中...', + maskType: EasyLoadingMaskType.black, + ); + // 立即执行一次广播 + SyncTakeFoodBroadcastTool.doCheckBroadcast(hourType.value); + EasyLoading.dismiss(); + } + + /// 关闭全部广播 + Future closeBroadcast() async { + final isSure = await appMessageDialog( + Get.context!, + "是否关闭所有${hourType.value.title}广播", + ); + if (!isSure) return; + EasyLoading.show( + status: '关闭中...', + maskType: EasyLoadingMaskType.black, + ); + final broadcastDao = appC.dataBase.getBroadcastDao; + await broadcastDao.toggleEnable(false, hourType.value); + await _getReservePage(); + EasyLoading.dismiss(); + } + + /// 打开全部广播 + Future openBroadcast() async { + final isSure = await appMessageDialog( + Get.context!, + "是否打卡所有${hourType.value.title}广播", + ); + if (!isSure) return; + EasyLoading.show( + status: '打开中...', + maskType: EasyLoadingMaskType.black, + ); + final broadcastDao = appC.dataBase.getBroadcastDao; + await broadcastDao.toggleEnable(true, hourType.value); + await _getReservePage(); + EasyLoading.dismiss(); + } + + /// 同步所有广播 + Future syncAllBroadcast({bool isMessageDialog = true}) async { + // 是否同步所有广播 + if (isMessageDialog) { + final isSure = await appMessageDialog(Get.context!, "是否同步所有广播"); + if (!isSure) return; + } + EasyLoading.show( + status: '同步中...', + maskType: EasyLoadingMaskType.black, + ); + try { + await SyncTakeFoodBroadcastTool.queryTodayBroadcast(); + await _getReservePage(); + EasyLoading.dismiss(); + } catch (e, stack) { + logger.e("同步广播失败error:|$e|stack:|$stack|"); + Toast.error(msg: "同步失败"); + EasyLoading.dismiss(); + } + } + + _listrenMq() { + mqUpdateListen = + appC.mqUpdateEventBus.on().listen((event) async { + if (event != MqtActionEnum.TAKE_FOOD_BROADCAST) return; + await syncAllBroadcast(isMessageDialog: false); + }); + } +} diff --git a/lib/pages/canteen_reserve/view.dart b/lib/pages/canteen_reserve/view.dart new file mode 100644 index 0000000..cb1a7b2 --- /dev/null +++ b/lib/pages/canteen_reserve/view.dart @@ -0,0 +1,274 @@ +import 'package:common_utils/common_utils.dart'; +import 'package:data_table_2/data_table_2.dart'; +import 'package:ducafe_ui_core/ducafe_ui_core.dart'; +import 'package:flutter/material.dart'; +import 'package:forui/forui.dart'; +import 'package:get/get.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +import 'controller.dart'; + +class CanteenReservePage extends GetView { + const CanteenReservePage({super.key}); + + // 主视图 + Widget _buildView(BuildContext context) { + final theme = FTheme.of(context); + return TerminalSurface( + padding: EdgeInsets.all(20.w), + child: [ + [ + [ + Text( + '预订播报列表', + style: theme.typography.xl2.copyWith( + color: XfjDesignTokens.ink, + fontWeight: FontWeight.w800, + ), + ), + SizedBox(height: 4.h), + Text( + '管理待取餐订单的播报状态与次数', + style: theme.typography.sm.copyWith( + color: XfjDesignTokens.muted, + ), + ), + ].toColumn(crossAxisAlignment: CrossAxisAlignment.start), + const Spacer(), + Container( + padding: EdgeInsets.symmetric(horizontal: 14.w, vertical: 8.h), + decoration: BoxDecoration( + color: XfjDesignTokens.primarySoft, + borderRadius: BorderRadius.circular(999), + ), + child: Text( + '播报服务已启用', + style: theme.typography.sm.copyWith( + color: XfjDesignTokens.primary, + fontWeight: FontWeight.w600, + ), + ), + ), + ].toRow(), + SizedBox(height: 18.h), + _buildCondition(context, 0), + SizedBox(height: 18.h), + _buildTable(context).center().expanded(), + Obx( + () => appPage( + context, + pageNo: controller.currentPage.value, + pageSize: controller.pageSize.value, + total: controller.totalPage.value, + onPageChanged: (page) => controller.doPage(page: page), + ), + ), + ].toColumn(), + ); + } + + /// 搜索条件 + Widget _buildCondition(BuildContext context, double headerHeight) { + final theme = FTheme.of(context); + return [ + appRadioHourTypeDialog(context, controller.hourTypeC).width(300.w), + const Spacer(), + FButton( + style: FButtonStyle.outline, + prefix: FIcon( + FAssets.icons.rotateCcw, + size: theme.typography.base.fontSize, + ), + label: Text( + '刷新', + style: theme.typography.base.copyWith( + color: theme.colorScheme.primary, + ), + ), + onPress: () => controller.syncAllBroadcast(), + ), + FButton( + prefix: FIcon( + FAssets.icons.rocket, + size: theme.typography.base.fontSize, + ), + label: Text( + '立即广播', + style: theme.typography.base.copyWith( + color: theme.colorScheme.secondary, + ), + ), + onPress: () => controller.startBroadcast(), + ), + FButton( + prefix: FIcon( + FAssets.icons.laptopMinimalCheck, + size: theme.typography.base.fontSize, + ), + label: Text( + '开启全部广播', + style: theme.typography.base.copyWith( + color: theme.colorScheme.secondary, + ), + ), + onPress: () => controller.openBroadcast(), + ), + FButton( + style: FButtonStyle.destructive, + prefix: FIcon( + FAssets.icons.ban, + size: theme.typography.base.fontSize, + ), + label: Text( + '关闭全部广播', + style: theme.typography.base.copyWith( + color: theme.colorScheme.secondary, + ), + ), + onPress: () => controller.closeBroadcast(), + ), + ].toRowSpace(space: 15.w, mainAxisAlignment: MainAxisAlignment.center); + } + + /// 表格 + Widget _buildTable(BuildContext context) { + final theme = FTheme.of(context); + return LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + final height = constraints.maxHeight; + final hdHeight = (height - 50.h) / 10.5; + return Obx( + () => DataTable2( + columnSpacing: 12.w, + horizontalMargin: 12.w, + minWidth: 600.w, + showBottomBorder: true, + headingTextStyle: theme.typography.lg, + dataTextStyle: theme.typography.base, + dataRowHeight: hdHeight, + columns: [ + DataColumn2( + label: Text("用户", textAlign: TextAlign.center), + headingRowAlignment: MainAxisAlignment.center, + fixedWidth: 300.w, + ), + DataColumn2( + label: Text("商品", textAlign: TextAlign.center), + headingRowAlignment: MainAxisAlignment.center, + fixedWidth: 230.w, + ), + DataColumn2( + label: Text("价格", textAlign: TextAlign.center), + headingRowAlignment: MainAxisAlignment.center, + fixedWidth: 230.w, + ), + DataColumn2( + label: Text("广播次数", textAlign: TextAlign.center), + headingRowAlignment: MainAxisAlignment.center, + fixedWidth: 230.w, + ), + DataColumn2( + label: Text("是否开启广播", textAlign: TextAlign.center), + headingRowAlignment: MainAxisAlignment.center, + fixedWidth: 200.w, + ), + DataColumn2( + label: Text("下单时间", textAlign: TextAlign.center), + headingRowAlignment: MainAxisAlignment.center, + fixedWidth: 300.w, + ), + DataColumn2( + label: Text("操作", textAlign: TextAlign.center), + headingRowAlignment: MainAxisAlignment.center, + fixedWidth: 300.w, + ), + ], + rows: controller.reserveList + .map( + (v) => DataRow( + cells: [ + DataCell( + Text( + "${v.username}-${v.userMobile}", + textAlign: TextAlign.center, + ).center(), + ), + DataCell( + Text( + v.productDesc, + textAlign: TextAlign.center, + ).center(), + ), + DataCell( + appPrice(context, v.payPrice).center(), + ), + DataCell( + Text( + "已播报${v.broadcastCount}次,最多播报${v.broadcastMaxCount}次", + textAlign: TextAlign.start, + ).center(), + ), + DataCell( + Text( + v.enable ? "是" : "否", + textAlign: TextAlign.center, + ).center(), + ), + DataCell( + Text( + DateUtil.formatDate(v.createTime), + textAlign: TextAlign.center, + ).center(), + ), + DataCell( + [ + FButton( + label: Text( + v.enable ? "关闭播报" : '开启播报', + style: theme.typography.sm.copyWith( + color: theme.colorScheme.primary, + ), + ), + style: FButtonStyle.outline, + onPress: () => controller.toggle(v), + ), + FButton( + label: Text( + '重置播报次数', + style: theme.typography.sm.copyWith( + color: theme.colorScheme.primary, + ), + ), + style: FButtonStyle.outline, + onPress: () => controller.reset(v), + ), + ].toRow( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + ), + ), + ], + ), + ) + .toList(), + ), + ); + }, + ); + } + + @override + Widget build(BuildContext context) { + return GetBuilder( + init: CanteenReserveController(), + id: "canteen_reserve", + builder: (_) { + return TerminalShell( + title: '预订管理', + activeNav: TerminalNavType.reservation, + child: _buildView(context), + ); + }, + ); + } +} diff --git a/lib/pages/card_active/controller.dart b/lib/pages/card_active/controller.dart new file mode 100644 index 0000000..ab2a422 --- /dev/null +++ b/lib/pages/card_active/controller.dart @@ -0,0 +1,266 @@ +import 'package:common_utils/common_utils.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_easyloading/flutter_easyloading.dart'; +import 'package:get/get.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +class CardActiveController extends GetxController { + final appC = Get.find(); + + SerialCardManage get serialCardManage => appC.serialCardManage; + + // table + RxInt currentPage = 1.obs; + + RxInt totalPage = 0.obs; + + RxInt pageSize = 10.obs; + + RxList tableList = [].obs; + + RxBool isLoading = false.obs; + + RxString errorMessage = ''.obs; + + Rx detail = Rx(null); + + RxBool readLoading = false.obs; + + // 写卡 + RxBool writeLoading = false.obs; + + // 卡号 + final carNumberC = TextEditingController(); + + _initData() { + update(["card_active"]); + } + + @override + void onInit() { + super.onInit(); + getTableList(); + } + + @override + void onReady() { + super.onReady(); + _initData(); + } + + @override + void onClose() { + super.onClose(); + carNumberC.dispose(); + } + + Future doPage({int? page}) async { + if (page != null) currentPage.value = page; + // 收起键盘 + FocusManager.instance.primaryFocus?.unfocus(); + getTableList(); + } + + Future doReset() async { + currentPage.value = 1; + carNumberC.clear(); + // 收起键盘 + FocusManager.instance.primaryFocus?.unfocus(); + await getTableList(); + } + + Future getTableList() async { + isLoading.value = true; + errorMessage.value = ''; + EasyLoading.show( + status: '查询中...', + maskType: EasyLoadingMaskType.black, + ); + try { + final pa = { + "current": currentPage.value, + "size": pageSize.value, + "deptId": "", + "walletTypeId": "", + "cardNumber": carNumberC.text, + }; + final data = await WalletApi.openCardList(pa); + if (data == null) { + tableList.clear(); + totalPage.value = 0; + return; + } + totalPage.value = data.total; + final records = data.records; + if (records != null) { + tableList.value = records; + } else { + tableList.clear(); + } + } catch (e, stack) { + errorMessage.value = '饭卡数据加载失败,请检查网络后重试'; + logger.e('饭卡列表查询失败error:|$e|stack:|$stack|'); + } finally { + isLoading.value = false; + EasyLoading.dismiss(); + } + } + + // 写卡 + void doWriteCard(ServiceOpenCard v) async { + if (v.isActiveHardware == true) { + Toast.error(msg: "该卡已经激活,无需重复激活"); + return; + } + if (ObjectUtil.isEmpty(v.cardNumber)) { + Toast.error(msg: "没有设置卡号"); + return; + } + try { + await serialCardManage.connect(); + if (!serialCardManage.isConnect) { + Toast.error(msg: "连接读卡器失败"); + return; + } + writeLoading.value = true; + detail.value = null; + final context = Get.context!; + cardDialog( + title: "写卡", + loading: readLoading, + detail: detail, + doSubmit: () { + Navigator.of(context).pop(); + _closeStatus(); + }, + ); + serialCardManage.callback = (type, status, res) async { + switch (status) { + case SerialCardResStatus.success: + if (ObjectUtil.isNotEmpty(v.id)) { + await _getOpenCardSuccess(v.id!); + getTableList(); + } + detail.value = v; + _closeStatus(); + break; + case SerialCardResStatus.warning: + break; + case SerialCardResStatus.error: + TtsManage.speak("请在合适区域放置饭卡"); + Toast.error(msg: "请在合适区域放置饭卡"); + break; + } + }; + TtsManage.speak("请在合适区域放置饭卡"); + serialCardManage.sendWrite(v.cardNumber!); + } catch (e, stack) { + _closeStatus(); + logger.d("写卡失败error:|$e|stack:|$stack|"); + Toast.error(msg: "写入失败,请重试"); + } + } + + // 读卡 + void doReadCard() async { + final context = Get.context!; + try { + await serialCardManage.connect(); + if (!serialCardManage.isConnect) { + Toast.error(msg: "连接读卡器失败"); + return; + } + readLoading.value = true; + detail.value = null; + cardDialog( + title: "读卡", + loading: readLoading, + detail: detail, + doSubmit: () { + Navigator.of(context).pop(); + _closeStatus(); + }, + ); + serialCardManage.callback = (type, status, res) async { + switch (status) { + case SerialCardResStatus.success: + if (ObjectUtil.isEmpty(res)) { + _closeStatus(); + return; + } + res = res.replaceAll(RegExp(r'[^a-zA-Z0-9]'), ''); + if (ObjectUtil.isEmpty(res)) { + TtsManage.speak("此饭卡未绑定用户"); + _closeStatus(); + return; + } + TtsManage.speak("读取成功"); + final pa = {"cardNumber": res}; + final data = await WalletApi.openCardList(pa); + if (data != null && data.records != null) { + detail.value = data.records![0]; + } + _closeStatus(); + break; + case SerialCardResStatus.warning: + break; + case SerialCardResStatus.error: + TtsManage.speak("请在合适区域放置饭卡"); + break; + } + }; + TtsManage.speak("请在合适区域放置饭卡"); + serialCardManage.sendRead(); + } catch (e, stack) { + _closeStatus(); + logger.d("读卡失败error:|$e|stack:|$stack|"); + Toast.error(msg: "读取失败,请重试"); + } + } + + Future _getOpenCardSuccess(String id) async { + try { + EasyLoading.show( + status: '激活饭卡中...', + maskType: EasyLoadingMaskType.black, + ); + final pa = {"id": id}; + final data = await WalletApi.openCardSuccess(pa); + if (data == false) throw Error(); + EasyLoading.dismiss(); + TtsManage.speak("激活成功"); + Toast.success(msg: "激活成功"); + } catch (e, stack) { + logger.e("激活饭卡请求失败error:|$e|stack:|$stack|"); + EasyLoading.dismiss(); + Toast.error(msg: "激活失败"); + } + } + + // 重新激活 + Future doRefreshAvatar(ServiceOpenCard v) async { + if (v.isActiveHardware == false) { + Toast.error(msg: "该卡为激活状态,不能重新激活"); + return; + } + try { + final isSure = await appMessageDialog(Get.context!, "是否重新激活"); + if (!isSure) return; + final pa = {"id": v.id}; + final data = await WalletApi.refreshAvatar(pa); + if (data == false) return; + TtsManage.speak("重新激活成功"); + Toast.success(msg: "重新激活成功"); + getTableList(); + } catch (e, stack) { + logger.d("重新激活失败error:|$e|stack:|$stack|"); + } + } + + void _closeStatus() { + serialCardManage.readerClose(); + serialCardManage.close(); + readLoading.value = false; + } +} diff --git a/lib/pages/card_active/view.dart b/lib/pages/card_active/view.dart new file mode 100644 index 0000000..fe77a93 --- /dev/null +++ b/lib/pages/card_active/view.dart @@ -0,0 +1,301 @@ +import 'package:data_table_2/data_table_2.dart'; +import 'package:ducafe_ui_core/ducafe_ui_core.dart'; +import 'package:flutter/material.dart'; +import 'package:forui/forui.dart'; +import 'package:get/get.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +import 'controller.dart'; + +class CardActivePage extends GetView { + const CardActivePage({super.key}); + + Widget _buildToolbar() => Row( + children: [ + TerminalToolbarControl( + width: 360.w, + child: FTextField( + controller: controller.carNumberC, + hint: '请输入饭卡卡号', + maxLines: 1, + onSubmit: (_) => controller.doPage(page: 1), + ), + ), + SizedBox(width: 12.w), + TerminalToolbarButton( + label: '重置', + icon: FAssets.icons.ban, + style: TerminalToolbarButtonStyle.secondary, + onTap: controller.doReset, + ), + SizedBox(width: 12.w), + TerminalToolbarButton( + label: '搜索', + icon: FAssets.icons.search, + onTap: () => controller.doPage(page: 1), + ), + const Spacer(), + TerminalToolbarButton( + label: '读卡', + icon: FAssets.icons.idCard, + style: TerminalToolbarButtonStyle.secondary, + minWidth: 112.w, + onTap: controller.doReadCard, + ), + ], + ); + + // 主视图 + Widget _buildView() { + final context = Get.context!; + final theme = FTheme.of(context); + return TerminalSurface( + padding: EdgeInsets.all(20.w), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '饭卡列表', + style: theme.typography.xl2.copyWith( + color: XfjDesignTokens.ink, + fontWeight: FontWeight.w800, + ), + ), + SizedBox(height: 4.h), + Text( + '查询饭卡并执行激活或重新激活', + style: theme.typography.sm.copyWith( + color: XfjDesignTokens.muted, + ), + ), + SizedBox(height: 18.h), + _buildToolbar(), + SizedBox(height: 18.h), + Expanded(child: _buildTable(context)), + SizedBox(height: XfjDesignTokens.cardGap.h), + Obx( + () => appPage( + context, + pageNo: controller.currentPage.value, + pageSize: controller.pageSize.value, + total: controller.totalPage.value, + itemLabel: '张饭卡', + onPageChanged: (page) => controller.doPage(page: page), + ), + ), + ], + ), + ); + } + + // 主视图 + Widget _buildTable(BuildContext context) { + final theme = FTheme.of(context); + return Obx(() { + if (controller.isLoading.value && controller.tableList.isEmpty) { + return const Center(child: CircularProgressIndicator()); + } + if (controller.errorMessage.isNotEmpty) { + return _CardEmptyState(message: controller.errorMessage.value); + } + if (controller.tableList.isEmpty) { + return const _CardEmptyState(message: '暂无饭卡数据,请调整卡号后重新搜索'); + } + return ClipRRect( + borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), + child: DataTable2( + columnSpacing: 12.w, + horizontalMargin: 16.w, + minWidth: 1480.w, + showBottomBorder: true, + headingRowColor: WidgetStateProperty.all(XfjDesignTokens.control), + headingRowHeight: 58.h, + headingTextStyle: theme.typography.base.copyWith( + color: XfjDesignTokens.ink, + fontWeight: FontWeight.w700, + ), + dataTextStyle: theme.typography.base, + dataRowHeight: (XfjDesignTokens.controlHeight + 14).h, + columns: [ + DataColumn2( + label: Text("编号", textAlign: TextAlign.center), + headingRowAlignment: MainAxisAlignment.center, + fixedWidth: 250.w, + ), + DataColumn2( + label: Text("开卡部门", textAlign: TextAlign.center), + headingRowAlignment: MainAxisAlignment.center, + ), + DataColumn2( + label: Text("绑定用户昵称", textAlign: TextAlign.center), + headingRowAlignment: MainAxisAlignment.center, + ), + DataColumn2( + label: Text("绑定用户手机号", textAlign: TextAlign.center), + headingRowAlignment: MainAxisAlignment.center, + ), + DataColumn2( + label: Text("饭卡类型", textAlign: TextAlign.center), + headingRowAlignment: MainAxisAlignment.center, + ), + DataColumn2( + label: Text("饭卡卡号", textAlign: TextAlign.center), + headingRowAlignment: MainAxisAlignment.center, + ), + DataColumn2( + label: Text("饭卡状态", textAlign: TextAlign.center), + headingRowAlignment: MainAxisAlignment.center, + ), + const DataColumn2( + label: Text("操作", textAlign: TextAlign.center), + headingRowAlignment: MainAxisAlignment.center, + fixedWidth: 170, + ), + ], + rows: controller.tableList + .map( + (v) => DataRow( + cells: [ + DataCell( + _TableText(v.id ?? "未知"), + ), + DataCell( + _TableText(v.deptName ?? "未知"), + ), + DataCell( + _TableText(v.username ?? ""), + ), + DataCell( + _TableText(v.mobile ?? ""), + ), + DataCell( + _TableText( + WalletType.values + .fromCode(v.walletTypeUniqueCode) + ?.title ?? + "未知", + ), + ), + DataCell( + _TableText(v.cardNumber ?? ""), + ), + DataCell( + _CardStatusBadge(active: v.isActiveHardware == true), + ), + DataCell( + Center( + child: SizedBox( + width: 150.w, + child: TerminalToolbarButton( + label: v.isActiveHardware == true ? '重新激活' : '激活', + icon: v.isActiveHardware == true + ? FAssets.icons.refreshCcw + : FAssets.icons.folderOpenDot, + style: v.isActiveHardware == true + ? TerminalToolbarButtonStyle.danger + : TerminalToolbarButtonStyle.secondary, + onTap: () => v.isActiveHardware == true + ? controller.doRefreshAvatar(v) + : controller.doWriteCard(v), + ), + ), + ), + ), + ], + ), + ) + .toList(), + ), + ); + }); + } + + @override + Widget build(BuildContext context) { + return GetBuilder( + id: "card_active", + builder: (_) { + return TerminalShell( + title: '开卡管理', + activeNav: TerminalNavType.card, + child: _buildView(), + ); + }, + ); + } +} + +class _TableText extends StatelessWidget { + const _TableText(this.value); + + final String value; + + @override + Widget build(BuildContext context) => Center( + child: Tooltip( + message: value, + child: Text( + value, + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + ), + ), + ); +} + +class _CardStatusBadge extends StatelessWidget { + const _CardStatusBadge({required this.active}); + + final bool active; + + @override + Widget build(BuildContext context) => Center( + child: Container( + padding: EdgeInsets.symmetric(horizontal: 14.w, vertical: 6.h), + decoration: BoxDecoration( + color: active + ? XfjDesignTokens.success.withValues(alpha: 0.1) + : XfjDesignTokens.warning.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), + ), + child: Text( + active ? '已激活' : '未激活', + style: FTheme.of(context).typography.sm.copyWith( + color: active + ? XfjDesignTokens.success + : XfjDesignTokens.warning, + fontWeight: FontWeight.w700, + ), + ), + ), + ); +} + +class _CardEmptyState extends StatelessWidget { + const _CardEmptyState({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) => Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + FIcon( + FAssets.icons.creditCard, + size: 48.sp, + color: XfjDesignTokens.muted, + ), + SizedBox(height: 12.h), + Text( + message, + style: FTheme.of(context).typography.base.copyWith( + color: XfjDesignTokens.muted, + ), + ), + ], + ), + ); +} diff --git a/lib/pages/cashier/view.dart b/lib/pages/cashier/view.dart new file mode 100644 index 0000000..a87c4cb --- /dev/null +++ b/lib/pages/cashier/view.dart @@ -0,0 +1,51 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; +import 'package:pj_supermarket_cloud/pages/canteen_product/controller.dart'; +import 'package:pj_supermarket_cloud/pages/canteen_product/view.dart'; +import 'package:pj_supermarket_cloud/pages/supermarket_order/controller.dart'; +import 'package:pj_supermarket_cloud/pages/supermarket_order/view.dart'; + +/// 收银一级路由只负责选择业务实现,不复制两套 Controller 的订单逻辑。 +/// +/// 设备类型在进入页面时已经由设置确定,因此页面生命周期内不动态切换,避免同时初始化 +/// 两套扫码、串口、购物车和副屏监听。 +class CashierPage extends StatefulWidget { + const CashierPage({super.key}); + + @override + State createState() => _CashierPageState(); +} + +class _CashierPageState extends State { + SysType? _sysType; + + @override + void initState() { + super.initState(); + _loadSysType(); + } + + Future _loadSysType() async { + final setting = await AppSettingTool.getSysSetting(); + if (mounted) setState(() => _sysType = setting.sysType); + } + + @override + Widget build(BuildContext context) { + final sysType = _sysType; + if (sysType == null) { + return const ColoredBox( + color: XfjDesignTokens.canvas, + child: Center(child: CircularProgressIndicator()), + ); + } + if (sysType == SysType.SUPERMARKET) { + Get.lazyPut(() => SupermarketOrderController()); + return const SupermarketOrderPage(); + } + Get.lazyPut(() => CanteenProductController()); + return const CanteenProductPage(); + } +} diff --git a/lib/pages/index.dart b/lib/pages/index.dart new file mode 100644 index 0000000..1f22bbf --- /dev/null +++ b/lib/pages/index.dart @@ -0,0 +1,19 @@ +library; + +export './canteen_order_total/controller.dart'; +export './canteen_order_total/view.dart'; +export './canteen_reserve/controller.dart'; +export './canteen_reserve/view.dart'; +export './card_active/controller.dart'; +export './card_active/view.dart'; +export './cashier/view.dart'; +export './load/controller.dart'; +export './load/view.dart'; +export './login/controller.dart'; +export './login/view.dart'; +export './order_history/controller.dart'; +export './order_history/view.dart'; +export './setting/controller.dart'; +export './setting/view.dart'; +export './take_food/controller.dart'; +export './take_food/view.dart'; diff --git a/lib/pages/load/controller.dart b/lib/pages/load/controller.dart new file mode 100644 index 0000000..3be4b82 --- /dev/null +++ b/lib/pages/load/controller.dart @@ -0,0 +1,15 @@ +import 'package:get/get.dart'; + +class LoadController extends GetxController { + LoadController(); + + _initData() { + update(["load"]); + } + + @override + void onReady() { + super.onReady(); + _initData(); + } +} diff --git a/lib/pages/load/view.dart b/lib/pages/load/view.dart new file mode 100644 index 0000000..c83244f --- /dev/null +++ b/lib/pages/load/view.dart @@ -0,0 +1,286 @@ +import 'package:ducafe_ui_core/ducafe_ui_core.dart'; +import 'package:flutter/material.dart'; +import 'package:forui/forui.dart'; +import 'package:get/get.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +import 'controller.dart'; + +class LoadPage extends GetView { + const LoadPage({super.key}); + + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + return GetBuilder( + init: LoadController(), + id: 'load', + builder: (_) => ColoredBox( + color: XfjDesignTokens.canvas, + child: SafeArea( + child: Padding( + padding: EdgeInsets.all(28.w), + child: Column( + children: [ + Row( + children: [ + Text( + 'ZAX', + style: theme.typography.xl4.copyWith( + color: const Color(0xFF0865E8), + fontWeight: FontWeight.w900, + fontStyle: FontStyle.italic, + ), + ), + SizedBox(width: 20.w), + Container( + width: 1, height: 32.h, color: XfjDesignTokens.line), + SizedBox(width: 20.w), + Text( + '智慧消费终端', + style: theme.typography.xl.copyWith( + color: XfjDesignTokens.ink, + fontWeight: FontWeight.w700, + ), + ), + const Spacer(), + const TerminalStatusPill(label: '安全启动'), + ], + ), + Center( + child: TerminalSurface( + width: 1000.w, + height: 620.h, + clipBehavior: Clip.hardEdge, + child: Row( + children: [ + Container( + width: 390.w, + color: XfjDesignTokens.primarySoft, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + width: 230.w, + height: 230.w, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Colors.white.withValues(alpha: 0.72), + border: Border.all( + color: XfjDesignTokens.primary + .withValues(alpha: 0.18), + ), + ), + child: Center( + child: Container( + width: 142.w, + height: 142.w, + decoration: BoxDecoration( + color: Colors.white, + shape: BoxShape.circle, + boxShadow: XfjDesignTokens.surfaceShadow, + ), + child: FIcon( + FAssets.icons.monitor, + size: 70.w, + color: XfjDesignTokens.primary, + ), + ), + ), + ), + SizedBox(height: 30.h), + Text( + '环境自适应检测', + style: theme.typography.xl.copyWith( + color: XfjDesignTokens.primary, + fontWeight: FontWeight.w700, + ), + ), + SizedBox(height: 8.h), + Text( + '正在检查双屏与本地设备能力', + style: theme.typography.sm.copyWith( + color: XfjDesignTokens.muted, + ), + ), + ], + ), + ), + Padding( + padding: EdgeInsets.all(42.w), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '正在启动收银终端', + style: theme.typography.xl4.copyWith( + color: XfjDesignTokens.ink, + fontWeight: FontWeight.w800, + ), + ), + SizedBox(height: 12.h), + Text( + '系统正在准备本地数据与设备服务,完成后将自动进入登录页面。', + style: theme.typography.base.copyWith( + color: XfjDesignTokens.muted, + ), + ), + SizedBox(height: 30.h), + const LinearProgressIndicator( + minHeight: 10, + color: XfjDesignTokens.primary, + backgroundColor: Color(0xFFEDEFF3), + borderRadius: + BorderRadius.all(Radius.circular(999)), + ), + SizedBox(height: 26.h), + _StartupStep( + icon: FAssets.icons.hardDrive, + title: '加载终端配置', + detail: '读取设备模式与本地设置', + complete: true, + ), + SizedBox(height: 12.h), + _StartupStep( + icon: FAssets.icons.database, + title: '打开业务数据库', + detail: '准备订单、商品与用户数据', + complete: true, + ), + SizedBox(height: 12.h), + _StartupStep( + icon: FAssets.icons.printer, + title: '初始化设备服务', + detail: '检查打印、读卡与电子秤', + active: true, + ), + SizedBox(height: 12.h), + _StartupStep( + icon: FAssets.icons.wifi, + title: '连接网络与同步', + detail: '启动消息服务与后台同步', + ), + ], + ), + ).expanded(), + ], + ), + ), + ).expanded(), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + width: 8.w, + height: 8.w, + decoration: const BoxDecoration( + color: XfjDesignTokens.success, + shape: BoxShape.circle, + ), + ), + SizedBox(width: 8.w), + Text( + 'ZAX 智慧消费终端 · 本地服务运行中', + style: theme.typography.sm.copyWith( + color: XfjDesignTokens.muted, + ), + ), + ], + ), + ], + ), + ), + ), + ), + ); + } +} + +/// 启动步骤统一展示完成、进行中和等待三种状态,避免只用进度动画表达结果。 +class _StartupStep extends StatelessWidget { + const _StartupStep({ + required this.icon, + required this.title, + required this.detail, + this.complete = false, + this.active = false, + }); + + final SvgAsset icon; + final String title; + final String detail; + final bool complete; + final bool active; + + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + final color = complete + ? XfjDesignTokens.success + : active + ? XfjDesignTokens.primary + : XfjDesignTokens.muted; + return Container( + height: 78.h, + padding: EdgeInsets.symmetric(horizontal: 18.w), + decoration: BoxDecoration( + color: active ? XfjDesignTokens.primarySoft : XfjDesignTokens.surface, + border: Border.all( + color: active + ? XfjDesignTokens.primary.withValues(alpha: 0.36) + : XfjDesignTokens.line, + ), + borderRadius: BorderRadius.circular(16), + ), + child: Row( + children: [ + Container( + width: 48.w, + height: 48.w, + decoration: BoxDecoration( + color: complete || active ? color : XfjDesignTokens.control, + borderRadius: BorderRadius.circular(12), + ), + child: FIcon( + complete ? FAssets.icons.check : icon, + color: complete || active ? Colors.white : color, + ), + ), + SizedBox(width: 14.w), + Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: theme.typography.base.copyWith( + color: color, + fontWeight: FontWeight.w700, + ), + ), + SizedBox(height: 4.h), + Text( + detail, + style: + theme.typography.sm.copyWith(color: XfjDesignTokens.muted), + ), + ], + ), + const Spacer(), + Text( + complete + ? '已完成' + : active + ? '进行中' + : '等待中', + style: theme.typography.sm.copyWith( + color: color, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ); + } +} diff --git a/lib/pages/login/controller.dart b/lib/pages/login/controller.dart new file mode 100644 index 0000000..12a9fd3 --- /dev/null +++ b/lib/pages/login/controller.dart @@ -0,0 +1,178 @@ +import 'package:common_utils/common_utils.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_easyloading/flutter_easyloading.dart'; +import 'package:forui/forui.dart'; + +import 'package:get/get.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +class LoginController extends GetxController { + static const String _tag = "登录页面 Controller"; + + late SysSetting sysSetting; + + late UserSetting userSetting; + + Rx sysType = SysType.CANTEEN.obs; + + RxList tenantList = [].obs; + + final tenantC = FRadioSelectGroupController(); + + ServiceTenant currentTenant = ServiceTenant(); + + // 用户名 + final usernameC = TextEditingController(); + // 密码 + final passwordC = TextEditingController(); + + _initData() { + update(["login"]); + } + + @override + void onInit() async { + super.onInit(); + sysSetting = await AppSettingTool.getSysSetting(); + userSetting = await AppSettingTool.getUserSetting(); + sysType.value = sysSetting.sysType; + await getTenantList(); + await autoLogin(); + tenantC.addValueListener((v) async { + final target = tenantList.firstWhereOrNull((i) => i.code == v.first); + if (target == null) return; + EasyLoading.show( + status: '切换中...', + maskType: EasyLoadingMaskType.black, + ); + currentTenant = target; + userSetting.tenantId = target.id; + await AppSettingTool.setUserSetting(userSetting); + EasyLoading.dismiss(); + }); + } + + @override + void onReady() { + super.onReady(); + _initData(); + } + + Future doLogin(bool visibleMsg) async { + if (!_validateForm()) return; + SysSetting sysSetting = await AppSettingTool.getSysSetting(); + final pa = { + 'username': usernameC.value.text, + 'password': passwordC.value.text, + 'sysType': sysSetting.sysType.name, + "grant_type": 'password', + "scope": 'server', + }; + if (visibleMsg) { + EasyLoading.show( + status: '登录中...', + maskType: EasyLoadingMaskType.black, + ); + } + try { + await AppSettingTool.setUserSetting(UserSetting()); + final data = await UserApi.login(pa); + if (data == null) { + if (visibleMsg) EasyLoading.dismiss(); + return; + } + userSetting.userId = data['user_id']; + userSetting.accessToken = data['access_token']; + userSetting.refreshToken = data['refresh_token']; + userSetting.expiresTime = int.tryParse("${data['exp']}"); + userSetting.sysType = sysSetting.sysType; + userSetting.username = usernameC.value.text; + userSetting.password = passwordC.value.text; + userSetting.tenantId = currentTenant.code; + await AppSettingTool.setUserSetting(userSetting); + await TtsManage.speak("登录成功"); + if (visibleMsg) { + Toast.success(msg: "登录成功"); + EasyLoading.dismiss(); + } + Get.offAndToNamed(RouteNames.cashier); + _clearForm(); + } catch (e) { + logger.e("|$_tag|登录错误error:|$e|"); + EasyLoading.dismiss(); + } + } + + Future autoLogin() async { + if (ObjectUtil.isEmpty(userSetting.username) || + ObjectUtil.isEmpty(userSetting.password) || + ObjectUtil.isEmpty(userSetting.sysType?.name)) { + return; + } + final sysSetting = await AppSettingTool.getSysSetting(); + usernameC.text = userSetting.username!; + passwordC.text = userSetting.password!; + // 无网络并且绑定过直接使用 + final isNetwork = AppNetworkTool.isAutoNetwork(); + final serviceId = sysSetting.serviceId; + if (!isNetwork && serviceId != null) { + Toast.success(msg: "登录成功"); + EasyLoading.dismiss(); + Get.offAndToNamed(RouteNames.cashier); + _clearForm(); + return; + } + if (!isNetwork && serviceId == null) { + Toast.error(msg: "未绑定机器,不能开启离线模式"); + return; + } + await doLogin(true); + } + + bool _validateForm() { + final username = usernameC.value.text; + final password = passwordC.value.text; + if (ObjectUtil.isEmpty(username)) { + Toast.error(msg: "请输入账号"); + return false; + } + if (ObjectUtil.isEmpty(password)) { + Toast.error(msg: "请输入密码"); + return false; + } + return true; + } + + Future getTenantList() async { + try { + final isNetwork = AppNetworkTool.isAutoNetwork(); + List? list = []; + if (isNetwork) { + list = await UserApi.getTenantList(); + } else { + sysSetting = await AppSettingTool.getSysSetting(); + list = sysSetting.tenantList; + } + if (list == null) return; + tenantList.value = list; + if (ObjectUtil.isNotEmpty(userSetting.tenantId)) { + tenantC.value = {userSetting.tenantId!}; + } else { + if (!ObjectUtil.isEmptyList(list)) { + tenantC.value = {list.first.id!}; + } + } + // 存储租户列表 + sysSetting.tenantList = list; + await AppSettingTool.setSysSetting(sysSetting); + } catch (e) { + logger.e("|$_tag|获取租户列表错误error:|$e|"); + } + } + + void _clearForm() { + usernameC.clear(); + passwordC.clear(); + } +} diff --git a/lib/pages/login/view.dart b/lib/pages/login/view.dart new file mode 100644 index 0000000..9318016 --- /dev/null +++ b/lib/pages/login/view.dart @@ -0,0 +1,215 @@ +import 'package:ducafe_ui_core/ducafe_ui_core.dart'; +import 'package:flutter/material.dart'; +import 'package:forui/forui.dart'; +import 'package:get/get.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +import 'controller.dart'; + +class LoginPage extends GetView { + const LoginPage({super.key}); + + Widget _buildBrandArea(BuildContext context) { + final theme = FTheme.of(context); + return Container( + padding: EdgeInsets.all(48.w), + decoration: BoxDecoration( + color: const Color(0xFF0F294A), + borderRadius: BorderRadius.circular(20), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'ZAX', + style: theme.typography.xl4.copyWith( + color: Colors.white, + fontWeight: FontWeight.w900, + fontStyle: FontStyle.italic, + ), + ), + const Spacer(), + Text( + '让每一次收银\n都更清晰、更可靠', + style: theme.typography.xl4.copyWith( + color: Colors.white, + fontWeight: FontWeight.w800, + height: 1.35, + ), + ), + SizedBox(height: 20.h), + Text( + '统一承载超市收银、食堂消费、饭卡、人脸、打印与双屏协作。', + style: theme.typography.lg.copyWith( + color: Colors.white.withValues(alpha: 0.72), + height: 1.6, + ), + ), + SizedBox(height: 36.h), + Row( + children: [ + _Capability(icon: FAssets.icons.monitor, title: '双屏协作'), + SizedBox(width: 12), + _Capability(icon: FAssets.icons.creditCard, title: '多种支付'), + SizedBox(width: 12), + _Capability(icon: FAssets.icons.printer, title: '设备协同'), + ], + ), + ], + ), + ); + } + + Widget _buildLoginForm(BuildContext context) { + final theme = FTheme.of(context); + return TerminalSurface( + padding: EdgeInsets.symmetric(horizontal: 54.w, vertical: 42.h), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + width: 58.w, + height: 58.w, + decoration: BoxDecoration( + color: XfjDesignTokens.primarySoft, + borderRadius: BorderRadius.circular(16), + ), + child: FIcon(FAssets.icons.lockKeyhole, + color: XfjDesignTokens.primary), + ), + SizedBox(height: 22.h), + Text( + '登录收银终端', + style: theme.typography.xl4.copyWith( + color: XfjDesignTokens.ink, + fontWeight: FontWeight.w800, + ), + ), + SizedBox(height: 10.h), + Text( + '选择所属单位,并使用终端账号继续', + style: theme.typography.base.copyWith(color: XfjDesignTokens.muted), + ), + SizedBox(height: 30.h), + Obx( + () => appTenantDialog( + context, + controller.tenantC, + list: controller.tenantList, + ), + ), + SizedBox(height: 20.h), + Obx( + () => FTextField( + textInputAction: controller.sysType.value == SysType.SUPERMARKET + ? TextInputAction.none + : TextInputAction.newline, + keyboardType: controller.sysType.value == SysType.SUPERMARKET + ? TextInputType.none + : TextInputType.multiline, + label: const Text('用户名'), + controller: controller.usernameC, + hint: '请输入用户名', + onSubmit: (_) => controller.doLogin(true), + ), + ), + SizedBox(height: 20.h), + Obx( + () => FTextField.password( + textInputAction: controller.sysType.value == SysType.SUPERMARKET + ? TextInputAction.none + : TextInputAction.newline, + keyboardType: controller.sysType.value == SysType.SUPERMARKET + ? TextInputType.none + : TextInputType.multiline, + label: const Text('密码'), + controller: controller.passwordC, + hint: '请输入密码', + onSubmit: (_) => controller.doLogin(true), + ), + ), + SizedBox(height: 30.h), + FButton( + label: Text( + '进入终端', + style: theme.typography.lg.copyWith(color: Colors.white), + ), + onPress: () => controller.doLogin(true), + ).height(60.h).width(double.infinity), + SizedBox(height: 18.h), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + FIcon(FAssets.icons.shieldCheck, color: XfjDesignTokens.success), + SizedBox(width: 8.w), + Text( + '登录信息受本地安全策略保护', + style: + theme.typography.sm.copyWith(color: XfjDesignTokens.muted), + ), + ], + ), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + return GetBuilder( + init: LoginController(), + id: 'login', + builder: (_) => ColoredBox( + color: XfjDesignTokens.canvas, + child: SafeArea( + child: Padding( + padding: EdgeInsets.all(36.w), + child: Row( + children: [ + _buildBrandArea(context).expanded(flex: 11), + SizedBox(width: XfjDesignTokens.cardGap.w), + _buildLoginForm(context).expanded(flex: 9), + ], + ), + ), + ), + ), + ); + } +} + +/// 登录品牌区的能力标签仅表达终端能力,避免在页面内重复堆叠相同布局。 +class _Capability extends StatelessWidget { + const _Capability({required this.icon, required this.title}); + + final SvgAsset icon; + final String title; + + @override + Widget build(BuildContext context) { + return Container( + padding: EdgeInsets.symmetric(horizontal: 14.w, vertical: 12.h), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.1), + border: Border.all(color: Colors.white.withValues(alpha: 0.16)), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + FIcon(icon, size: 22.w, color: Colors.white), + SizedBox(width: 8.w), + Text( + title, + style: FTheme.of(context).typography.base.copyWith( + color: Colors.white, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ); + } +} diff --git a/lib/pages/order_history/controller.dart b/lib/pages/order_history/controller.dart new file mode 100644 index 0000000..a7cabc7 --- /dev/null +++ b/lib/pages/order_history/controller.dart @@ -0,0 +1,337 @@ +import 'package:common_utils/common_utils.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_easyloading/flutter_easyloading.dart'; +import 'package:forui/forui.dart'; +import 'package:get/get.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +import 'widgets/refund_dialog.dart'; + +class OrderHistoryController extends GetxController { + final appC = Get.find(); + + late SysSetting appSetting; + + final sysType = SysType.CANTEEN.obs; + + PrintManage get printManage => appC.printManage; + + Rx hourType = Rx(ProductHourType.MORNING); + + Rx currentStatus = OrderStatus.TOTAL.obs; + + RxList tabList = [].obs; + + RxList saleReasons = [].obs; + + // 选择的时间 + RxList selectDateTime = [ + DateTime.now(), + DateTime.now().add(const Duration(days: 1)), + ].obs; + + // table + RxInt currentPage = 1.obs; + + RxInt totalPage = 0.obs; + + RxInt pageSize = 10.obs; + + RxList orderList = RxList([]); + + /// 交易详情在当前页面以右侧抽屉展示,不再占用独立路由。 + Rx selectedOrder = Rx(null); + + RxList selectedShopCardList = [].obs; + + var hourTypeC = FRadioSelectGroupController( + value: ProductHourType.MORNING.code, + ); + + _initData() { + update(["order_history"]); + } + + @override + void onInit() async { + super.onInit(); + appSetting = await AppSettingTool.getSysSetting(); + sysType.value = appSetting.sysType; + hourType.value = appSetting.hourType ?? ProductHourType.MORNING; + hourTypeC.value = {hourType.value.code}; + _updateSetting(); + // tabs + await getTotalList(); + await getOrder(); + + hourTypeC.addValueListener((v) async { + final currentHourType = ProductHourType.values.fromCode( + v.first, + ); + if (currentHourType == null) return; + EasyLoading.show( + status: '切换中...', + maskType: EasyLoadingMaskType.black, + ); + hourType.value = currentHourType; + currentPage.value = 1; + await getTotalList(); + await getOrder(); + EasyLoading.dismiss(); + }); + } + + @override + void onReady() { + super.onReady(); + _initData(); + } + + @override + void onClose() { + super.onClose(); + hourTypeC.dispose(); + } + + Future doPage({int? page}) async { + if (page != null) { + currentPage.value = page; + } + await getOrder(); + } + + // 订单列表 + Future getOrder() async { + final appSetting = await AppSettingTool.getSysSetting(); + final sysType = appSetting.sysType.name; + final orderDao = appC.dataBase.getOrderDao; + + String orderStatus = currentStatus.value.name; + if (currentStatus.value == OrderStatus.TOTAL) { + orderStatus = "-1"; + } + + String hourTypeCode = hourType.value.code; + if (sysType == SysType.SUPERMARKET.name) { + hourTypeCode = "-1"; + } + + final list = await orderDao.findAll( + orderStatus, + selectDateTime[0], + selectDateTime[1], + pageSize.value, + (currentPage.value - 1) * pageSize.value, + sysType, + hourTypeCode, + ); + final total = await orderDao.count( + orderStatus, + selectDateTime[0], + selectDateTime[1], + sysType, + hourTypeCode, + ); + totalPage.value = total ?? 0; + orderList.value = list; + } + + Future doTab(OrderStatus i) async { + if (i.code == currentStatus.value.code) return; + currentStatus.value = i; + currentPage.value = 1; + await getOrder(); + } + + Future doDateRangeDate(List? dateTime) async { + if (ObjectUtil.isEmptyList(dateTime)) { + return; + } + selectDateTime.value = dateTime!; + currentPage.value = 1; + await getTotalList(); + await getOrder(); + } + + Future getTotalList() async { + final total = await _getTotal(); + List tabs = OrderStatus.visibleList() + .map((v) => OrderHistoryTableItem(status: v)) + .toList(); + for (int i = 0; i < tabs.length; i++) { + final v = tabs[i]; + final currentTotal = total["UNIQUE_${v.status.name}"]; + if (currentTotal == null) continue; + v.total = currentTotal; + } + tabList.value = tabs; + } + +// 根据日期查询统计 + Future> _getTotal() async { + final appSetting = await AppSettingTool.getSysSetting(); + Map map = { + "UNIQUE_TOTAL": 0, + "UNIQUE_CREATE": 0, + "UNIQUE_OFFLINE_CREATE": 0, + "UNIQUE_PAY": 0, + "UNIQUE_OFFLINE_PAY": 0, + "UNIQUE_REFUND": 0, + }; + String totalSql = """ + SELECT + SUM(CASE WHEN status = 'CREATE' THEN 1 ELSE 0 END) AS UNIQUE_CREATE, + SUM(CASE WHEN status = 'OFFLINE_CREATE' THEN 1 ELSE 0 END) AS UNIQUE_OFFLINE_CREATE, + SUM(CASE WHEN status = 'PAY' THEN 1 ELSE 0 END) AS UNIQUE_PAY, + SUM(CASE WHEN status = 'OFFLINE_PAY' THEN 1 ELSE 0 END) AS UNIQUE_OFFLINE_PAY, + SUM(CASE WHEN status = 'REFUND' THEN 1 ELSE 0 END) AS UNIQUE_REFUND, + SUM(CASE WHEN 1 = 1 THEN 1 ELSE 0 END) AS UNIQUE_TOTAL + FROM order_record + WHERE + sysType = ? + AND strftime('%Y-%m-%d', createTime) BETWEEN ? AND ? + """; + final sysType = appSetting.sysType; + List? args = [ + sysType.name, + DateUtil.formatDate(selectDateTime[0], format: DateFormats.y_mo_d), + DateUtil.formatDate(selectDateTime[1], format: DateFormats.y_mo_d), + ]; + // 组装where条件 + // 非食堂不查询 sysType + if (sysType != SysType.SUPERMARKET) { + if (hourType.value.code != "-1") { + totalSql += " AND hourType = ?"; + args.add(hourType.value.code); + } + } + final list = await appC.dataBase.database.rawQuery(totalSql, args); + if (!ObjectUtil.isEmptyList(list)) { + final map = list[0]; + if (ObjectUtil.isNotEmpty(map)) { + return map; + } + } + return map; + } + + /// 退款 + Future doRefund(OrderEntity v) async { + // 超过4个小时不能退款 + final now = DateTime.now(); + final diff = now.difference(v.createTime); + if (diff.inHours > 4) { + Toast.error(msg: "超过4个小时不能退款"); + return; + } + final price = AppPriceTool.fenToYuan(v.payPrice); + refundDialog( + Get.context!, + title: "是否退还${v.username}用户的$price元", + saleReasons: saleReasons, + doCancel: () { + Navigator.of(Get.context!).pop(); + }, + doSubmit: (String value) async { + try { + EasyLoading.show( + status: '退款中...', + maskType: EasyLoadingMaskType.black, + ); + final pa = { + "orderId": v.serviceUniqueCode, + "reason": value, + }; + final data = await HardApi.timeLimitRefund(pa); + if (!data) throw Error(); + // 修改本地订单为退款 + v.status = OrderStatus.REFUND; + final orderDao = appC.dataBase.getOrderDao; + await orderDao.edit(v); + await getTotalList(); + await getOrder(); + TtsManage.speak("退款成功总金额$price元"); + } catch (error, stack) { + TtsManage.speak("退款失败"); + logger.e("退款失败error:|$error|stack:|$stack|"); + } finally { + Navigator.of(Get.context!).pop(); + EasyLoading.dismiss(); + } + }, + ); + } + + /// 打开订单详情抽屉,并从本地数据库加载商品明细。 + Future doDetail(OrderEntity v) async { + selectedOrder.value = v; + final id = v.id; + selectedShopCardList.value = id == null + ? [] + : await appC.dataBase.getShopCardDao.findByOrderId(id); + } + + void closeDetail() { + selectedOrder.value = null; + selectedShopCardList.clear(); + } + + /// 打印订单发票 + void doPrint(OrderEntity v) async { + final isOpen = await printManage.open(); + if (!isOpen) { + Toast.error(msg: "打开打印机失败"); + return; + } + EasyLoading.show( + status: '打印中...', + maskType: EasyLoadingMaskType.black, + ); + try { + final orderId = int.tryParse(v.serviceUniqueCode); + final pa = {"orderId": orderId}; + final data = await HardApi.generateInvoice(pa); + if (data == null) throw Error(); + if (ObjectUtil.isEmpty(data.historyId) || ObjectUtil.isEmpty(data.pic)) { + throw Exception("服务端生成发票失败"); + } + await SyncPrintTool.addTask( + orderId: v.serviceUniqueCode, + androidOrderId: v.id.toString(), + historyId: data.historyId!, + pic: data.pic!, + ); + EasyLoading.dismiss(); + Toast.success(msg: "打印成功"); + } catch (e, stack) { + logger.e("打印订单发票失败error:|$e|stack:|$stack|"); + EasyLoading.dismiss(); + } finally { + await printManage.close(); + } + } + + Future _updateSetting() async { + final isNetwork = AppNetworkTool.isAutoNetwork(); + if (!isNetwork) return; + var setting = await AppSettingTool.getCanteenExtraPriceConfig(); + try { + final data = await WalletApi.getPaySetting(); + if (data == null) throw Error(); + final storageData = CanteenExtraPriceSetting( + payAmountEnable: data.payAmountEnable!, + payAmountMealLimit: data.payAmountMealLimit!, + payAmountMealTimes: data.payAmountMealTimes!, + afterSaleReturnReasons: data.afterSaleReturnReasons, + ); + if (setting == null || setting != storageData) { + await AppSettingTool.setCanteenExtraPriceConfig(storageData); + setting = storageData; + } + saleReasons.value = setting.afterSaleReturnReasons?.split(",") ?? []; + } catch (e, stack) { + logger.e("更新支付设置失败error:|$e|stack:|$stack|"); + } + } +} diff --git a/lib/pages/order_history/view.dart b/lib/pages/order_history/view.dart new file mode 100644 index 0000000..83dd1f1 --- /dev/null +++ b/lib/pages/order_history/view.dart @@ -0,0 +1,780 @@ +import 'package:common_utils/common_utils.dart'; +import 'package:ducafe_ui_core/ducafe_ui_core.dart'; +import 'package:flutter/material.dart'; +import 'package:forui/forui.dart'; +import 'package:get/get.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +import 'controller.dart'; + +class OrderHistoryPage extends GetView { + const OrderHistoryPage({super.key}); + + Widget _buildFilterCard(BuildContext context) { + final theme = FTheme.of(context); + return TerminalSurface( + padding: EdgeInsets.all(16.w), + child: Column( + children: [ + Row( + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '交易记录', + style: theme.typography.xl2.copyWith( + color: XfjDesignTokens.ink, + fontWeight: FontWeight.w800, + ), + ), + SizedBox(height: 4.h), + Text( + '查询本机订单,支持退款、打印发票和订单详情', + style: theme.typography.sm.copyWith( + color: XfjDesignTokens.muted, + ), + ), + ], + ), + const Spacer(), + TerminalSoftButton( + label: '刷新', + icon: FAssets.icons.refreshCw, + onTap: () async { + await controller.getTotalList(); + await controller.getOrder(); + }, + ), + ], + ), + SizedBox(height: 16.h), + Row( + children: [ + Obx( + () => Row( + children: controller.tabList + .map( + (tab) => _StatusFilterButton( + label: tab.status.title, + count: tab.total, + selected: + controller.currentStatus.value == tab.status, + onTap: () => controller.doTab(tab.status), + ).marginOnly(right: 8.w), + ) + .toList(), + ), + ), + const Spacer(), + Obx( + () => Visibility( + visible: controller.sysType.value == SysType.CANTEEN, + child: Row( + children: [ + Text( + '用餐时段', + style: theme.typography.sm.copyWith( + color: XfjDesignTokens.muted, + ), + ), + SizedBox(width: 8.w), + ...ProductHourType.values.map( + (type) => _MealFilterButton( + label: type.title, + selected: controller.hourType.value == type, + onTap: () { + controller.hourTypeC.value = {type.code}; + }, + ), + ), + ], + ), + ), + ), + ], + ), + ], + ), + ); + } + + Widget _buildTable(BuildContext context) { + final theme = FTheme.of(context); + return TerminalSurface( + clipBehavior: Clip.hardEdge, + child: Column( + children: [ + Container( + height: 52.h, + padding: EdgeInsets.symmetric(horizontal: 16.w), + color: const Color(0xFFF7F8FA), + child: _OrderGridRow( + children: const [ + '下单时间', + '订餐类型', + '支付类型', + '订单人', + '商品名称', + '总价', + '额外支付', + '实际扣款', + '操作', + ] + .map( + (text) => Text( + text, + style: theme.typography.sm.copyWith( + color: XfjDesignTokens.muted, + fontWeight: FontWeight.w700, + ), + ), + ) + .toList(), + ), + ), + Obx(() { + if (controller.orderList.isEmpty) { + return _buildEmpty(context).expanded(); + } + return ListView.builder( + padding: EdgeInsets.zero, + itemCount: controller.orderList.length, + itemBuilder: (context, index) { + return _buildOrderRow(context, controller.orderList[index]); + }, + ).expanded(); + }), + Container( + height: 60.h, + padding: EdgeInsets.symmetric(horizontal: 20.w), + decoration: const BoxDecoration( + color: Color(0xFFFAFBFC), + border: Border(top: BorderSide(color: XfjDesignTokens.line)), + ), + child: Row( + children: [ + Obx( + () => Text( + '共 ${controller.totalPage.value} 条记录,本地订单数据', + style: theme.typography.base.copyWith( + color: XfjDesignTokens.muted, + ), + ), + ), + const Spacer(), + Obx( + () => SizedBox( + // appPage 内部会主动占满父宽度,放在 Row 的非弹性子项中会收到 + // 无限宽约束并导致整页白屏,因此在交易页明确限定分页区域宽度。 + width: 760.w, + child: appPage( + context, + pageNo: controller.currentPage.value, + pageSize: controller.pageSize.value, + total: controller.totalPage.value, + onPageChanged: (page) => controller.doPage(page: page), + ), + ), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildOrderRow(BuildContext context, OrderEntity order) { + final theme = FTheme.of(context); + final hourType = ProductHourType.values.fromCode(order.hourType ?? '-1'); + return Container( + constraints: BoxConstraints(minHeight: 88.h), + padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 10.h), + decoration: const BoxDecoration( + border: Border(bottom: BorderSide(color: XfjDesignTokens.line)), + ), + child: _OrderGridRow( + children: [ + Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + DateUtil.formatDate( + order.createTime, + format: DateFormats.y_mo_d, + ), + style: theme.typography.sm.copyWith( + color: XfjDesignTokens.ink, + fontWeight: FontWeight.w600, + ), + ), + SizedBox(height: 4.h), + Text( + DateUtil.formatDate(order.createTime, + format: DateFormats.h_m_s), + style: theme.typography.sm.copyWith( + color: XfjDesignTokens.muted, + ), + ), + ], + ), + Text(order.type.title), + Text(order.payType.title, + maxLines: 1, overflow: TextOverflow.ellipsis), + Text(order.username ?? '未知用户'), + Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + order.productDesc ?? '未知商品', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.typography.sm.copyWith( + color: XfjDesignTokens.ink, + fontWeight: FontWeight.w600, + ), + ), + SizedBox(height: 6.h), + Row( + children: [ + _OrderStatusBadge(status: order.status), + if (hourType != null) ...[ + SizedBox(width: 8.w), + Text( + hourType.title, + style: theme.typography.xs.copyWith( + color: XfjDesignTokens.muted, + ), + ), + ], + ], + ), + ], + ), + appPrice(context, order.price), + appPrice(context, order.extraPrice), + appPrice(context, order.payPrice), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _TableActionButton( + label: '退款', + danger: true, + enabled: OrderStatus.successPayCodes().contains( + order.status.code, + ) && + DateTime.now().difference(order.createTime).inHours <= 4 && + controller.sysType.value != SysType.SUPERMARKET, + onTap: () => controller.doRefund(order), + ), + SizedBox(width: 8.w), + _TableActionButton( + label: '打印发票', + onTap: () => controller.doPrint(order), + ), + SizedBox(width: 8.w), + _TableActionButton( + label: '详情', + onTap: () => controller.doDetail(order), + ), + ], + ), + ], + ), + ); + } + + Widget _buildEmpty(BuildContext context) { + final theme = FTheme.of(context); + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 80.w, + height: 80.w, + decoration: const BoxDecoration( + shape: BoxShape.circle, + color: XfjDesignTokens.control, + ), + child: FIcon( + FAssets.icons.receipt, + color: XfjDesignTokens.muted, + size: 40.w, + ).center(), + ), + SizedBox(height: 20.h), + Text( + '没有符合条件的交易记录', + style: theme.typography.xl.copyWith( + color: XfjDesignTokens.ink, + fontWeight: FontWeight.w700, + ), + ), + SizedBox(height: 8.h), + Text( + '请调整订单状态或用餐时段后重试', + style: theme.typography.base.copyWith( + color: XfjDesignTokens.muted, + ), + ), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + return GetBuilder( + init: OrderHistoryController(), + id: 'order_history', + builder: (_) => TerminalShell( + title: '交易查询', + activeNav: TerminalNavType.transactions, + child: Stack( + children: [ + Column( + children: [ + _buildFilterCard(context), + SizedBox(height: XfjDesignTokens.cardGap.h), + _buildTable(context).expanded(), + ], + ), + Obx( + () => controller.selectedOrder.value == null + ? const SizedBox.shrink() + : _OrderDetailDrawer(controller: controller), + ), + ], + ), + ), + ); + } +} + +class _OrderGridRow extends StatelessWidget { + const _OrderGridRow({required this.children}); + + final List children; + + @override + Widget build(BuildContext context) { + const flexes = [17, 11, 15, 9, 23, 10, 10, 11, 24]; + return Row( + children: List.generate( + children.length, + (index) => Expanded( + flex: flexes[index], + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 4.w), + child: children[index], + ), + ), + ), + ); + } +} + +class _StatusFilterButton extends StatelessWidget { + const _StatusFilterButton({ + required this.label, + required this.count, + required this.selected, + required this.onTap, + }); + + final String label; + final int count; + final bool selected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), + child: Container( + constraints: BoxConstraints(minHeight: 44.h), + padding: EdgeInsets.symmetric(horizontal: 16.w), + decoration: BoxDecoration( + color: selected ? XfjDesignTokens.primary : XfjDesignTokens.control, + borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), + ), + child: Row( + children: [ + Text( + label, + style: theme.typography.base.copyWith( + color: selected ? Colors.white : const Color(0xFF344054), + fontWeight: FontWeight.w700, + ), + ), + SizedBox(width: 8.w), + Container( + padding: EdgeInsets.symmetric(horizontal: 8.w, vertical: 2.h), + decoration: BoxDecoration( + color: selected + ? Colors.white.withValues(alpha: 0.2) + : Colors.white, + borderRadius: BorderRadius.circular(999), + ), + child: Text( + '$count', + style: theme.typography.sm.copyWith( + color: selected ? Colors.white : XfjDesignTokens.muted, + ), + ), + ), + ], + ), + ), + ); + } +} + +class _MealFilterButton extends StatelessWidget { + const _MealFilterButton({ + required this.label, + required this.selected, + required this.onTap, + }); + + final String label; + final bool selected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(10), + child: Container( + constraints: BoxConstraints(minHeight: 40.h), + padding: EdgeInsets.symmetric(horizontal: 16.w), + decoration: BoxDecoration( + color: selected ? XfjDesignTokens.primarySoft : Colors.transparent, + borderRadius: BorderRadius.circular(10), + ), + child: Text( + label, + style: FTheme.of(context).typography.base.copyWith( + color: + selected ? XfjDesignTokens.primary : XfjDesignTokens.muted, + fontWeight: FontWeight.w600, + ), + ).center(), + ), + ); + } +} + +class _TableActionButton extends StatelessWidget { + const _TableActionButton({ + required this.label, + required this.onTap, + this.danger = false, + this.enabled = true, + }); + + final String label; + final VoidCallback onTap; + final bool danger; + final bool enabled; + + @override + Widget build(BuildContext context) { + final color = !enabled + ? const Color(0xFF98A2B3) + : danger + ? XfjDesignTokens.danger + : XfjDesignTokens.primary; + return InkWell( + onTap: enabled ? onTap : null, + borderRadius: BorderRadius.circular(10), + child: Container( + constraints: BoxConstraints(minHeight: 40.h), + padding: EdgeInsets.symmetric(horizontal: 12.w), + decoration: BoxDecoration( + color: Colors.white, + border: Border.all( + color: enabled && danger + ? XfjDesignTokens.danger + : XfjDesignTokens.line, + ), + borderRadius: BorderRadius.circular(10), + ), + child: Text( + label, + style: FTheme.of(context).typography.sm.copyWith( + color: color, + fontWeight: FontWeight.w600, + ), + ).center(), + ), + ); + } +} + +class _OrderStatusBadge extends StatelessWidget { + const _OrderStatusBadge({required this.status}); + + final OrderStatus status; + + @override + Widget build(BuildContext context) { + final (background, foreground, icon) = switch (status) { + OrderStatus.PAY => ( + const Color(0xFFECFBF3), + XfjDesignTokens.success, + FAssets.icons.circleCheck, + ), + OrderStatus.REFUND => ( + const Color(0xFFFFF3F2), + XfjDesignTokens.danger, + FAssets.icons.circleAlert, + ), + OrderStatus.OFFLINE_PAY => ( + const Color(0xFFFFF9E8), + const Color(0xFF8C6A00), + FAssets.icons.clock, + ), + _ => ( + XfjDesignTokens.control, + XfjDesignTokens.muted, + FAssets.icons.clock, + ), + }; + return Container( + padding: EdgeInsets.symmetric(horizontal: 10.w, vertical: 4.h), + decoration: BoxDecoration( + color: background, + borderRadius: BorderRadius.circular(999), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + FIcon(icon, size: 14.w, color: foreground), + SizedBox(width: 4.w), + Text( + status.title, + style: FTheme.of(context).typography.xs.copyWith( + color: foreground, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ); + } +} + +class _OrderDetailDrawer extends StatelessWidget { + const _OrderDetailDrawer({required this.controller}); + + final OrderHistoryController controller; + + @override + Widget build(BuildContext context) { + final order = controller.selectedOrder.value!; + final theme = FTheme.of(context); + return Positioned.fill( + child: ColoredBox( + color: Colors.black.withValues(alpha: 0.35), + child: Align( + alignment: Alignment.centerRight, + child: Container( + width: 1040.w, + color: XfjDesignTokens.canvas, + child: Column( + children: [ + Container( + height: 80.h, + padding: EdgeInsets.symmetric(horizontal: 24.w), + decoration: const BoxDecoration( + color: Colors.white, + border: Border( + bottom: BorderSide(color: XfjDesignTokens.line), + ), + ), + child: Row( + children: [ + Container( + width: 48.w, + height: 48.w, + decoration: BoxDecoration( + color: XfjDesignTokens.primarySoft, + borderRadius: BorderRadius.circular(12), + ), + child: FIcon( + FAssets.icons.fileText, + color: XfjDesignTokens.primary, + ).center(), + ), + SizedBox(width: 16.w), + Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '订单详情', + style: theme.typography.xl2.copyWith( + color: XfjDesignTokens.ink, + fontWeight: FontWeight.w800, + ), + ), + Text( + '订单号:${order.serviceUniqueCode}', + style: theme.typography.sm.copyWith( + color: XfjDesignTokens.muted, + ), + ), + ], + ), + const Spacer(), + InkWell( + onTap: controller.closeDetail, + child: FIcon( + FAssets.icons.x, + color: XfjDesignTokens.muted, + size: 26.w, + ).center().width(48.w).height(48.w), + ), + ], + ), + ), + Padding( + padding: EdgeInsets.all(16.w), + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TerminalSurface( + width: 350.w, + padding: EdgeInsets.all(16.w), + child: ListView( + children: [ + _DetailLine( + label: '机器订单 ID', value: '${order.id ?? ''}'), + _DetailLine( + label: '服务端唯一标识', + value: order.serviceUniqueCode), + _DetailLine(label: '用户唯一标识', value: order.userCode), + _DetailLine( + label: '用户名', value: order.username ?? '未知用户'), + _DetailLine(label: '订单类型', value: order.type.title), + _DetailLine( + label: '订单状态', value: order.status.title), + _DetailLine( + label: '支付方式', value: order.payType.title), + _DetailLine( + label: '商品总数量', value: '${order.count}'), + _DetailLine( + label: '创建时间', + value: DateUtil.formatDate(order.createTime), + ), + ], + ), + ), + SizedBox(width: XfjDesignTokens.cardGap.w), + TerminalSurface( + padding: EdgeInsets.all(16.w), + child: Column( + children: [ + Text( + '商品明细', + style: theme.typography.xl.copyWith( + color: XfjDesignTokens.ink, + fontWeight: FontWeight.w800, + ), + ).alignLeft(), + SizedBox(height: 12.h), + Obx( + () => ListView.separated( + itemCount: + controller.selectedShopCardList.length, + separatorBuilder: (context, index) => + const Divider( + color: XfjDesignTokens.line, + height: 1, + ), + itemBuilder: (context, index) { + final item = + controller.selectedShopCardList[index]; + return SizedBox( + height: 70.h, + child: Row( + children: [ + Text(item.productName).expanded(), + Text( + item.nature == ProductNature.DEFAULT + ? '${item.productNumber}' + : '${item.productWidget}/kg', + ).width(100.w), + appPrice(context, item.productPrice) + .width(120.w), + appPrice(context, item.totalPrice) + .width(120.w), + ], + ), + ); + }, + ), + ).expanded(), + orderAllPrice( + context, + totalPrice: order.price, + amountMealPrice: order.extraPrice, + payPrice: order.payPrice, + productPrice: '商品总价', + ), + ], + ), + ).expanded(), + ], + ), + ).expanded(), + ], + ), + ), + ), + ), + ); + } +} + +class _DetailLine extends StatelessWidget { + const _DetailLine({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Padding( + padding: EdgeInsets.symmetric(vertical: 12.h), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: FTheme.of(context).typography.sm.copyWith( + color: XfjDesignTokens.muted, + ), + ), + SizedBox(height: 6.h), + Text( + value, + style: FTheme.of(context).typography.base.copyWith( + color: XfjDesignTokens.ink, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ); + } +} diff --git a/lib/pages/order_history/widgets/refund_dialog.dart b/lib/pages/order_history/widgets/refund_dialog.dart new file mode 100644 index 0000000..c15d2b3 --- /dev/null +++ b/lib/pages/order_history/widgets/refund_dialog.dart @@ -0,0 +1,92 @@ +import 'package:flutter/material.dart'; +import 'package:forui/forui.dart'; +import 'package:get/get.dart'; +import 'package:ducafe_ui_core/ducafe_ui_core.dart'; + +Future refundDialog( + BuildContext context, { + required String title, + required RxList saleReasons, + required VoidCallback doCancel, + required Function(String) doSubmit, +}) async { + String value = saleReasons.first; + final typeC = FRadioSelectGroupController(value: value); + typeC.addValueListener((v) async { + final current = v.first; + value = current; + }); + + final theme = FTheme.of(context); + final width = context.width; + final height = context.height; + showDialog( + context: context, + builder: (BuildContext _) { + return FDialog( + direction: Axis.vertical, + title: Text(title, style: theme.typography.lg), + body: [ + Obx( + () => FSelectGroup( + enabled: false, + controller: typeC, + style: theme.selectGroupStyle.copyWith( + labelLayoutStyle: + theme.selectGroupStyle.labelLayoutStyle.copyWith( + childPadding: EdgeInsets.symmetric( + vertical: 10.h, + ), + ), + radioStyle: theme.selectGroupStyle.radioStyle.copyWith( + padding: EdgeInsets.symmetric( + vertical: 10.h, + ), + ), + ), + items: saleReasons + .map>( + (v) => FSelectGroupItem.radio( + value: v, + label: Text( + v, + style: theme.typography.xl, + ), + ), + ) + .toList(), + ), + ), + ].toColumn().width(width * 0.5).height(height * 0.6), + actions: [ + [ + FButton( + label: Text( + "确认退款", + style: theme.typography.xl.copyWith( + color: theme.colorScheme.secondary, + ), + ), + onPress: () { + doSubmit.call(value); + }, + ).flexible(flex: 1), + SizedBox(width: 20.w), + FButton( + style: FButtonStyle.destructive, + label: Text( + "取消", + style: theme.typography.xl.copyWith( + color: theme.colorScheme.secondary, + ), + ), + onPress: () { + doCancel.call(); + }, + ).flexible(flex: 1), + ].toRow(), + ], + ); + }, + ); +} diff --git a/lib/pages/setting/controller.dart b/lib/pages/setting/controller.dart new file mode 100644 index 0000000..39031d9 --- /dev/null +++ b/lib/pages/setting/controller.dart @@ -0,0 +1,258 @@ +import 'dart:async'; + +import 'package:common_utils/common_utils.dart'; +import 'package:flutter_easyloading/flutter_easyloading.dart'; +import 'package:get/get.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +class SettingController extends GetxController { + static const String _tag = '设置 Controller'; + + final appC = Get.find(); + final sysSetting = Rxn(); + final userSetting = Rxn(); + final manualHardwareInput = false.obs; + final cuurentMenuType = SettingMenuType.baseInfo.obs; + final isLoading = true.obs; + final isBound = false.obs; + final syncingKey = ''.obs; + final syncFailedKeys = {}.obs; + final sqlBackupTime = Rxn(); + final logBackupTime = Rxn(); + final lastBackupMessage = ''.obs; + + StreamSubscription? _mqUpdateListen; + + @override + void onInit() { + super.onInit(); + _initialize(); + } + + Future _initialize() async { + try { + await getSetting(); + await refreshBackupTimes(); + _mqUpdateListen = appC.mqUpdateEventBus.on().listen( + (event) async { + if (event == MqtActionEnum.CONFIG) await getSetting(); + }, + ); + } catch (e, stack) { + logger.e('|$_tag|初始化失败error:|$e|stack:|$stack|'); + Toast.error(msg: '设置加载失败,请稍后重试'); + } finally { + isLoading.value = false; + } + } + + @override + void onClose() { + _mqUpdateListen?.cancel(); + super.onClose(); + } + + void doMenu(SettingMenuType type) { + cuurentMenuType.value = type; + } + + Future getSetting() async { + final setting = await AppSettingTool.getSysSetting(); + sysSetting.value = setting; + userSetting.value = await AppSettingTool.getUserSetting(); + isBound.value = ObjectUtil.isNotEmpty(setting.serviceMerchantId); + manualHardwareInput.value = + AppRuntimeTool.isEmulator || setting.manualHardwareInput; + AppRuntimeTool.applyManualHardwareInput(setting.manualHardwareInput); + } + + Future setGlobalFontSize(SettingGlobalFontSize fontSize) async { + final setting = sysSetting.value; + if (setting == null || setting.globalFontSize == fontSize) return; + setting.globalFontSize = fontSize; + await AppSettingTool.setSysSetting(setting); + Get.find().setSizeScalar(fontSize); + sysSetting.refresh(); + } + + Future setButtonRadius(SettingButtonRadius value) async { + final setting = sysSetting.value; + if (setting == null || setting.buttonRadius == value) return; + setting.buttonRadius = value; + await AppSettingTool.setSysSetting(setting); + Get.find().setButtonRadius(value); + sysSetting.refresh(); + } + + Future setCardRadius(SettingCardRadius value) async { + final setting = sysSetting.value; + if (setting == null || setting.cardRadius == value) return; + setting.cardRadius = value; + await AppSettingTool.setSysSetting(setting); + Get.find().setCardRadius(value); + sysSetting.refresh(); + } + + Future setButtonSize(SettingButtonSize value) async { + final setting = sysSetting.value; + if (setting == null || setting.buttonSize == value) return; + setting.buttonSize = value; + await AppSettingTool.setSysSetting(setting); + Get.find().setButtonSize(value); + sysSetting.refresh(); + } + + Future togglePayType(OrderPayDisplayType type) async { + final setting = sysSetting.value; + if (setting == null) return; + final enabled = List.from( + setting.usePayDisplayType ?? OrderPayDisplayType.values, + ); + if (enabled.contains(type)) { + if (enabled.length == 1) { + Toast.error(msg: '至少保留一种支付方式'); + return; + } + enabled.remove(type); + } else { + enabled.add(type); + } + enabled.sort((a, b) => a.order.compareTo(b.order)); + + // 默认支付方式必须属于启用集合,否则收银页会出现默认值不可选择的矛盾状态。 + if (!enabled.contains(setting.defaultPayDisplayType)) { + setting.defaultPayDisplayType = enabled.first; + } + setting.usePayDisplayType = enabled; + await AppSettingTool.setSysSetting(setting); + sysSetting.refresh(); + } + + Future setDefaultPayType(OrderPayDisplayType type) async { + final setting = sysSetting.value; + if (setting == null) return; + final enabled = setting.usePayDisplayType ?? OrderPayDisplayType.values; + if (!enabled.contains(type)) { + Toast.error(msg: '请先启用该支付方式'); + return; + } + setting.defaultPayDisplayType = type; + await AppSettingTool.setSysSetting(setting); + sysSetting.refresh(); + } + + Future syncConfig() async { + await _runSync('config', '设备信息', () async { + final setting = await SyncConfigTool.autoSyncSysInfo(); + if (setting != null) sysSetting.value = setting; + }); + } + + Future syncMq() async { + await _runSync('mq', '长链接', () async { + await MqManager.closeMqtt(); + await AppMqTool.mqRefresh(); + }); + } + + Future syncClassify() async { + await _runSync('classify', '分类', SyncProductTool.syncClassify); + } + + Future syncProduct() async { + await _runSync('product', '商品', SyncProductTool.syncProduct); + } + + Future syncOfflineOrder() async { + await _runSync('offlineOrder', '离线订单', SyncOrderTool.syncOfflineOrder); + } + + Future _runSync( + String key, + String label, + Future Function() action, + ) async { + if (syncingKey.isNotEmpty) return; + if (!AppNetworkTool.isAutoNetworkMsg()) return; + syncingKey.value = key; + syncFailedKeys.remove(key); + EasyLoading.show(status: '同步中...', maskType: EasyLoadingMaskType.black); + try { + await action(); + await getSetting(); + Toast.success(msg: '同步$label成功'); + } catch (e, stack) { + syncFailedKeys.add(key); + Toast.error(msg: '同步$label失败,请稍后重试'); + logger.e('|$_tag|同步$label失败error:|$e|stack:|$stack|'); + } finally { + syncingKey.value = ''; + EasyLoading.dismiss(); + } + } + + Future backupsSql() async { + await _runBackup('SQL', AppFileTool.backupSqlFilePath); + } + + Future backupsLog() async { + await _runBackup('日志', AppFileTool.backupLogFile); + } + + Future _runBackup( + String label, + Future Function() action, + ) async { + EasyLoading.show(status: '备份中...', maskType: EasyLoadingMaskType.black); + try { + final path = await action(); + if (ObjectUtil.isEmpty(path)) throw Exception('未生成备份文件'); + lastBackupMessage.value = '$label 备份已生成:$path'; + await refreshBackupTimes(); + Toast.success(msg: '$label 备份成功'); + } catch (e, stack) { + Toast.error(msg: '$label 备份失败,请检查存储权限和文件状态'); + logger.e('|$_tag|$label备份失败error:|$e|stack:|$stack|'); + } finally { + EasyLoading.dismiss(); + } + } + + Future refreshBackupTimes() async { + final setting = sysSetting.value; + if (setting == null) return; + final basePath = await AppFileTool.getExternalPath( + backupExternalPath: false, + ); + sqlBackupTime.value = await AppFileTool.getLatestModifiedTime( + '$basePath/backup/database', + ); + logBackupTime.value = await AppFileTool.getLatestModifiedTime( + '$basePath/backup/logger/manual', + ); + } + + Future setManualHardwareInput(bool enabled) async { + if (AppRuntimeTool.isEmulator && !enabled) { + Toast.error(msg: '虚拟机必须使用手动硬件输入模式'); + manualHardwareInput.value = true; + return; + } + if (enabled && !manualHardwareInput.value) { + final confirmed = await appMessageDialog( + Get.context!, + '设置为主控后,扫码、饭卡读写和人脸识别将改为人工输入,是否继续?', + ); + if (!confirmed) return; + } + final setting = sysSetting.value; + if (setting == null) return; + setting.manualHardwareInput = enabled; + await AppSettingTool.setSysSetting(setting); + AppRuntimeTool.applyManualHardwareInput(enabled); + manualHardwareInput.value = enabled; + sysSetting.refresh(); + Toast.success(msg: enabled ? '已设置为主控手动输入模式' : '已恢复真实硬件模式'); + } +} diff --git a/lib/pages/setting/view.dart b/lib/pages/setting/view.dart new file mode 100644 index 0000000..d44a3cf --- /dev/null +++ b/lib/pages/setting/view.dart @@ -0,0 +1,285 @@ +import 'package:ducafe_ui_core/ducafe_ui_core.dart'; +import 'package:flutter/material.dart'; +import 'package:forui/forui.dart'; +import 'package:get/get.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; +import 'package:pj_supermarket_cloud/pages/setting/widgets/index.dart'; + +import 'controller.dart'; + +class SettingPage extends GetView { + const SettingPage({super.key}); + + @override + Widget build(BuildContext context) { + return TerminalShell( + title: '设置', + activeNav: TerminalNavType.settings, + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SizedBox(width: 240.w, child: _SettingMenu(controller: controller)), + SizedBox(width: XfjDesignTokens.cardGap.w), + Expanded(child: _SettingContent(controller: controller)), + ], + ), + ); + } +} + +class _SettingMenu extends StatelessWidget { + const _SettingMenu({required this.controller}); + + final SettingController controller; + + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + return TerminalSurface( + padding: EdgeInsets.all(12.w), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: EdgeInsets.fromLTRB(12.w, 4.h, 12.w, 12.h), + child: Text( + '终端设置', + style: theme.typography.sm.copyWith( + color: XfjDesignTokens.muted, + fontWeight: FontWeight.w700, + ), + ), + ), + Obx( + () => Column( + children: SettingMenuType.values + .map( + (type) => _SettingMenuButton( + type: type, + selected: controller.cuurentMenuType.value == type, + onTap: () => controller.doMenu(type), + ).marginOnly(bottom: 8.h), + ) + .toList(), + ), + ), + const Spacer(), + Obx(() { + final bound = controller.isBound.value; + return Container( + padding: EdgeInsets.all(16.w), + decoration: BoxDecoration( + color: const Color(0xFFF7F8FA), + border: Border.all(color: XfjDesignTokens.line), + borderRadius: BorderRadius.circular( + XfjDesignTokens.controlRadius, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 8.w, + height: 8.w, + decoration: BoxDecoration( + color: bound + ? XfjDesignTokens.success + : XfjDesignTokens.warning, + shape: BoxShape.circle, + ), + ), + SizedBox(width: 8.w), + Text( + bound ? '设备已绑定' : '设备未绑定', + style: theme.typography.sm.copyWith( + color: bound + ? XfjDesignTokens.success + : XfjDesignTokens.warning, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + SizedBox(height: 8.h), + Text( + bound ? '部分设置修改后会自动同步到本机服务。' : '请先在基本信息中绑定设备后使用业务设置。', + style: theme.typography.sm.copyWith( + color: XfjDesignTokens.muted, + ), + ), + ], + ), + ); + }), + ], + ), + ); + } +} + +class _SettingMenuButton extends StatelessWidget { + const _SettingMenuButton({ + required this.type, + required this.selected, + required this.onTap, + }); + + final SettingMenuType type; + final bool selected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), + child: Container( + constraints: BoxConstraints(minHeight: 56.h), + padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 10.h), + decoration: BoxDecoration( + color: selected ? XfjDesignTokens.primary : Colors.transparent, + borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), + ), + child: Row( + children: [ + FIcon( + _menuIcon(type), + color: selected ? Colors.white : const Color(0xFF344054), + size: 23.sp, + ), + SizedBox(width: 12.w), + Expanded( + child: Text( + type.title, + style: theme.typography.base.copyWith( + color: selected ? Colors.white : const Color(0xFF344054), + fontWeight: FontWeight.w600, + ), + ), + ), + if (selected) + FIcon(FAssets.icons.check, color: Colors.white, size: 18.sp), + ], + ), + ), + ); + } +} + +class _SettingContent extends StatelessWidget { + const _SettingContent({required this.controller}); + + final SettingController controller; + + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + return TerminalSurface( + clipBehavior: Clip.antiAlias, + child: Column( + children: [ + Container( + constraints: BoxConstraints(minHeight: 92.h), + padding: EdgeInsets.symmetric(horizontal: 24.w, vertical: 14.h), + decoration: const BoxDecoration( + border: Border(bottom: BorderSide(color: XfjDesignTokens.line)), + ), + child: Obx(() { + final type = controller.cuurentMenuType.value; + return Row( + children: [ + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + type.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.typography.xl2.copyWith( + color: XfjDesignTokens.ink, + fontWeight: FontWeight.w800, + ), + ), + SizedBox(height: 4.h), + Text( + _menuDescription(type), + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: theme.typography.sm.copyWith( + color: XfjDesignTokens.muted, + ), + ), + ], + ), + ), + ], + ); + }), + ), + Expanded( + child: Padding( + padding: EdgeInsets.all(20.w), + child: Obx(() { + if (controller.isLoading.value) { + return const Center(child: CircularProgressIndicator()); + } + final type = controller.cuurentMenuType.value; + if (!controller.isBound.value && + type != SettingMenuType.baseInfo && + type != SettingMenuType.backup) { + return const SettingEmpty( + message: '设备尚未绑定,请先在“基本信息”中完成绑定', + ); + } + return _buildDetail(context, type); + }), + ), + ), + ], + ), + ); + } + + Widget _buildDetail(BuildContext context, SettingMenuType type) { + return switch (type) { + SettingMenuType.baseInfo => settingBaseInfo(context), + SettingMenuType.backup => settingBackup(context), + SettingMenuType.syncSetting => settingSync(context), + SettingMenuType.paySetting => settingPay(context), + SettingMenuType.faceSetting => settingFace(context), + SettingMenuType.productSetting => settingProduct(context), + SettingMenuType.versionSetting => settingVersionSetting(context), + }; + } +} + +SvgAsset _menuIcon(SettingMenuType type) { + return switch (type) { + SettingMenuType.baseInfo => FAssets.icons.settings, + SettingMenuType.backup => FAssets.icons.databaseBackup, + SettingMenuType.syncSetting => FAssets.icons.refreshCw, + SettingMenuType.paySetting => FAssets.icons.creditCard, + SettingMenuType.faceSetting => FAssets.icons.scanFace, + SettingMenuType.productSetting => FAssets.icons.package, + SettingMenuType.versionSetting => FAssets.icons.rocket, + }; +} + +String _menuDescription(SettingMenuType type) { + return switch (type) { + SettingMenuType.baseInfo => '查看终端身份、绑定状态和字体显示设置', + SettingMenuType.backup => '查看备份路径并导出本地数据库和日志', + SettingMenuType.syncSetting => '手动刷新设备、长连接、分类、商品和离线订单', + SettingMenuType.paySetting => '配置允许使用的支付方式和默认支付方式', + SettingMenuType.faceSetting => '查询、同步和维护本地人脸样本', + SettingMenuType.productSetting => '查询本地商品、同步分类商品和维护默认菜品', + SettingMenuType.versionSetting => '查看可用终端版本、更新内容和下载状态', + }; +} diff --git a/lib/pages/setting/widgets/index.dart b/lib/pages/setting/widgets/index.dart new file mode 100644 index 0000000..44ef472 --- /dev/null +++ b/lib/pages/setting/widgets/index.dart @@ -0,0 +1,10 @@ +library; + +export './setting_backup.dart'; +export './setting_baseInfo.dart'; +export './setting_face.dart'; +export './setting_pay.dart'; +export './setting_product.dart'; +export './setting_sync.dart'; +export './setting-version-setting.dart'; +export './setting_components.dart'; diff --git a/lib/pages/setting/widgets/setting-version-setting.dart b/lib/pages/setting/widgets/setting-version-setting.dart new file mode 100644 index 0000000..2876a3d --- /dev/null +++ b/lib/pages/setting/widgets/setting-version-setting.dart @@ -0,0 +1,342 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:background_downloader/background_downloader.dart'; +import 'package:common_utils/common_utils.dart'; +import 'package:ducafe_ui_core/ducafe_ui_core.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_easyloading/flutter_easyloading.dart'; +import 'package:forui/forui.dart'; +import 'package:get/get.dart'; +import 'package:permission_handler/permission_handler.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +import 'setting_components.dart'; + +class VersionManageController extends GetxController { + static const String _tag = '版本管理 Controller'; + + final currentPage = 1.obs; + final totalPage = 0.obs; + final pageSize = 3.obs; + final versionList = [].obs; + final isLoading = false.obs; + final errorMessage = ''.obs; + + @override + void onInit() { + super.onInit(); + getVersionList(); + } + + Future doPage({int? page}) async { + if (page != null) currentPage.value = page; + await getVersionList(showLoading: true); + } + + Future getVersionList({bool showLoading = false}) async { + isLoading.value = true; + errorMessage.value = ''; + if (showLoading) { + EasyLoading.show(status: '查询中...', maskType: EasyLoadingMaskType.black); + } + try { + final data = await AppApi.openCardList({ + 'appUniqueCode': Constants.deviceUniqueCode, + 'current': currentPage.value, + 'size': pageSize.value, + }); + logger.d('|$_tag|获取版本列表成功:${json.encode(data)}'); + versionList.assignAll(data?.records ?? const []); + totalPage.value = data?.total ?? 0; + } catch (e, stack) { + versionList.clear(); + errorMessage.value = '版本列表加载失败,请检查网络后重试'; + logger.e('|$_tag|获取版本列表失败error:|$e|stack:|$stack|'); + } finally { + isLoading.value = false; + if (showLoading) EasyLoading.dismiss(); + } + } + + Future doDownload(String url) async { + if (url.isEmpty) { + Toast.error(msg: '当前版本没有可用下载地址'); + return; + } + var downUrl = url; + if (!url.startsWith('http://') && !url.startsWith('https://')) { + downUrl = '${Constants.ossUrl}/api/admin/sys-file/oss/file?fileName=$url'; + } + if (!Platform.isAndroid) { + EasyLoading.showInfo('当前平台不支持系统下载'); + return; + } + EasyLoading.show(status: '下载中...', maskType: EasyLoadingMaskType.black); + try { + var granted = await Permission.storage.request().isGranted; + if (!granted && await Permission.manageExternalStorage.isDenied) { + granted = await Permission.manageExternalStorage.request().isGranted; + } + if (!granted) { + EasyLoading.showError('存储权限未授权,无法下载'); + return; + } + final ext = AppFileTool.getFileExtension(downUrl); + final useExt = ObjectUtil.isEmpty(ext) ? '.apk' : ext; + final now = DateTime.now(); + final fileName = 'pj_xfj_${now.millisecondsSinceEpoch}$useExt'; + final task = DownloadTask( + url: downUrl, + filename: fileName, + updates: Updates.statusAndProgress, + baseDirectory: BaseDirectory.applicationDocuments, + ); + final result = await FileDownloader().download( + task, + onProgress: (progress) => EasyLoading.showProgress( + progress, + status: '(${(progress * 100).toStringAsFixed(0)}%) 下载中...', + ), + onStatus: (status) => logger.d('|$_tag|下载状态:$status'), + ); + if (result.status != TaskStatus.complete) { + EasyLoading.showError('下载失败'); + return; + } + final movedPath = await FileDownloader().moveToSharedStorage( + task, + SharedStorage.downloads, + mimeType: useExt == '.apk' + ? 'application/vnd.android.package-archive' + : 'application/octet-stream', + ); + if (ObjectUtil.isEmpty(movedPath)) { + EasyLoading.showError('移动到下载目录失败'); + return; + } + logger.i('|$_tag|下载完成:$movedPath'); + EasyLoading.showSuccess('下载完成'); + } catch (e, stack) { + logger.e('|$_tag|下载失败error:|$e|stack:|$stack|'); + EasyLoading.showError('下载失败'); + } finally { + EasyLoading.dismiss(); + } + } +} + +Widget settingVersionSetting(BuildContext context) { + final controller = Get.isRegistered() + ? Get.find() + : Get.put(VersionManageController()); + return Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + SettingActionButton( + label: '刷新版本列表', + icon: FAssets.icons.refreshCw, + primary: true, + onTap: controller.doPage, + ), + ], + ), + SizedBox(height: 20.h), + Expanded( + child: Obx(() { + if (controller.isLoading.value) { + return const Center(child: CircularProgressIndicator()); + } + if (controller.errorMessage.isNotEmpty) { + return SettingEmpty(message: controller.errorMessage.value); + } + if (controller.versionList.isEmpty) { + return const SettingEmpty(message: '暂无版本信息'); + } + return GridView.builder( + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + crossAxisSpacing: XfjDesignTokens.cardGap.w, + mainAxisSpacing: XfjDesignTokens.cardGap.h, + childAspectRatio: 1.25, + ), + itemCount: controller.versionList.length, + itemBuilder: (context, index) => _VersionCard( + item: controller.versionList[index], + recommended: index == 0, + onDownload: () => controller.doDownload( + controller.versionList[index].downloadUrl ?? '', + ), + ), + ); + }), + ), + SizedBox(height: XfjDesignTokens.cardGap.h), + Obx( + () => appPage( + context, + pageNo: controller.currentPage.value, + pageSize: controller.pageSize.value, + total: controller.totalPage.value, + onPageChanged: (pageNo) => controller.doPage(page: pageNo), + ), + ), + SizedBox(height: XfjDesignTokens.cardGap.h), + SettingNotice( + text: '下载需要 Android 存储权限。下载完成后仍需按照终端发布流程确认版本来源并执行安装。', + background: Color(0xFFFFF9E8), + borderColor: Color(0x4DF2A817), + icon: FAssets.icons.triangleAlert, + ), + ], + ); +} + +class _VersionCard extends StatelessWidget { + const _VersionCard({ + required this.item, + required this.recommended, + required this.onDownload, + }); + + final ServiceAppVersion item; + final bool recommended; + final VoidCallback onDownload; + + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + final platform = + AppPlatformEnum.values.fromCode(item.platform ?? '')?.title ?? '未知平台'; + final status = + AppVersionStatusEnum.values.fromCode(item.status ?? '')?.title ?? + '未知状态'; + return Container( + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + color: XfjDesignTokens.surface, + border: Border.all(color: XfjDesignTokens.line), + borderRadius: BorderRadius.circular(XfjDesignTokens.cardRadius), + boxShadow: XfjDesignTokens.surfaceShadow, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Container( + padding: EdgeInsets.all(20.w), + decoration: const BoxDecoration( + border: Border(bottom: BorderSide(color: XfjDesignTokens.line)), + ), + child: Row( + children: [ + Container( + width: 52.w, + height: 52.w, + alignment: Alignment.center, + decoration: BoxDecoration( + color: XfjDesignTokens.primarySoft, + borderRadius: BorderRadius.circular( + XfjDesignTokens.controlRadius, + ), + ), + child: FIcon( + FAssets.icons.rocket, + color: XfjDesignTokens.primary, + size: 28.sp, + ), + ), + SizedBox(width: 14.w), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '版本 ${item.versionName ?? '--'}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.typography.xl.copyWith( + color: XfjDesignTokens.ink, + fontWeight: FontWeight.w800, + ), + ), + SizedBox(height: 4.h), + Text( + '$platform · Build ${item.versionCode ?? '--'}', + style: theme.typography.sm.copyWith( + color: XfjDesignTokens.muted, + ), + ), + ], + ), + ), + Container( + padding: EdgeInsets.symmetric( + horizontal: 10.w, + vertical: 5.h, + ), + decoration: BoxDecoration( + color: recommended + ? XfjDesignTokens.primarySoft + : XfjDesignTokens.control, + borderRadius: BorderRadius.circular(999), + ), + child: Text( + recommended ? '推荐更新' : status, + style: theme.typography.sm.copyWith( + color: recommended + ? XfjDesignTokens.primary + : XfjDesignTokens.muted, + ), + ), + ), + ], + ), + ), + Expanded( + child: Padding( + padding: EdgeInsets.all(20.w), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '更新内容', + style: theme.typography.base.copyWith( + color: XfjDesignTokens.ink, + fontWeight: FontWeight.w700, + ), + ), + SizedBox(height: 10.h), + Expanded( + child: Text( + item.updateContent ?? '暂无更新内容', + maxLines: 5, + overflow: TextOverflow.ellipsis, + style: theme.typography.sm.copyWith( + color: XfjDesignTokens.muted, + height: 1.5, + ), + ), + ), + SizedBox(height: 12.h), + SizedBox( + width: double.infinity, + child: SettingActionButton( + label: '下载 APK', + icon: FAssets.icons.arrowDownToLine, + primary: recommended, + enabled: ObjectUtil.isNotEmpty(item.downloadUrl), + onTap: onDownload, + ), + ), + ], + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/pages/setting/widgets/setting_backup.dart b/lib/pages/setting/widgets/setting_backup.dart new file mode 100644 index 0000000..8b9b36b --- /dev/null +++ b/lib/pages/setting/widgets/setting_backup.dart @@ -0,0 +1,78 @@ +import 'package:common_utils/common_utils.dart'; +import 'package:ducafe_ui_core/ducafe_ui_core.dart'; +import 'package:flutter/material.dart'; +import 'package:forui/forui.dart'; +import 'package:get/get.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +import '../controller.dart'; +import 'setting_components.dart'; + +Widget settingBackup(BuildContext context) { + final controller = Get.find(); + return Obx(() { + final sys = controller.sysSetting.value; + if (sys == null) return const SettingEmpty(message: '暂无备份信息'); + final path = ObjectUtil.isEmpty(sys.externalPath) + ? '--' + : '${sys.externalPath}/pj_cashier/backup'; + return SingleChildScrollView( + child: Column( + children: [ + SettingGroup( + title: '备份', + child: Column( + children: [ + SettingInfoRow( + icon: FAssets.icons.folderOpen, + label: '备份路径', + value: path, + actionLabel: '复制', + onTap: path == '--' ? null : () => KeywordTool.copy(path), + ), + SettingInfoRow( + icon: FAssets.icons.database, + label: 'SQL 备份', + value: _backupTime(controller.sqlBackupTime.value), + actionLabel: '立即备份', + onTap: controller.backupsSql, + ), + SettingInfoRow( + icon: FAssets.icons.fileArchive, + label: '日志备份', + value: _backupTime(controller.logBackupTime.value), + actionLabel: '立即备份', + onTap: controller.backupsLog, + showDivider: false, + ), + ], + ), + ), + if (controller.lastBackupMessage.isNotEmpty) ...[ + SizedBox(height: XfjDesignTokens.cardGap.h), + SettingNotice( + text: controller.lastBackupMessage.value, + color: XfjDesignTokens.success, + background: const Color(0xFFECFBF3), + borderColor: XfjDesignTokens.success.withValues(alpha: 0.3), + icon: FAssets.icons.circleCheck, + ), + ], + SizedBox(height: XfjDesignTokens.cardGap.h), + SettingNotice( + text: '备份文件可能包含订单和设备诊断信息,导出后请妥善保管,不要通过非安全渠道传输。', + background: const Color(0xFFFFF9E8), + borderColor: XfjDesignTokens.warning.withValues(alpha: 0.3), + icon: FAssets.icons.triangleAlert, + ), + ], + ), + ); + }); +} + +String _backupTime(DateTime? time) { + if (time == null) return '尚未备份'; + return '上次备份:${DateUtil.formatDate(time, format: DateFormats.full)}'; +} diff --git a/lib/pages/setting/widgets/setting_baseInfo.dart b/lib/pages/setting/widgets/setting_baseInfo.dart new file mode 100644 index 0000000..35f00d4 --- /dev/null +++ b/lib/pages/setting/widgets/setting_baseInfo.dart @@ -0,0 +1,597 @@ +import 'package:common_utils/common_utils.dart'; +import 'package:ducafe_ui_core/ducafe_ui_core.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_easyloading/flutter_easyloading.dart'; +import 'package:forui/forui.dart'; +import 'package:get/get.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +import '../controller.dart'; +import 'setting_components.dart'; + +Future _bindServiceDialog(BuildContext context, SysSetting sys) async { + final theme = FTheme.of(context); + final uniqueCodeController = TextEditingController(text: sys.serviceId ?? ''); + await showAdaptiveDialog( + context: context, + builder: (dialogContext) => FDialog( + direction: Axis.horizontal, + title: Text('绑定唯一编号', style: theme.typography.xl2), + body: FTextField( + textInputAction: TextInputAction.none, + controller: uniqueCodeController, + hint: '请输入设备唯一编号', + maxLines: 1, + ).width(context.width * 0.5).marginSymmetric(vertical: 20.h), + actions: [ + FButton( + style: FButtonStyle.outline, + label: Text('取消', style: theme.typography.base), + onPress: () => Navigator.of(dialogContext).pop(), + ), + FButton( + label: Text( + '绑定', + style: theme.typography.base.copyWith(color: Colors.white), + ), + onPress: () async { + final serverId = int.tryParse(uniqueCodeController.text.trim()); + if (serverId == null) { + Toast.error(msg: '请输入正确的设备唯一编号'); + return; + } + if (!AppNetworkTool.isAutoNetworkMsg()) return; + EasyLoading.show( + status: '绑定中...', + maskType: EasyLoadingMaskType.black, + ); + try { + final success = await AppSettingTool.bindSys(serverId); + if (!success) { + Toast.error(msg: '绑定失败,请核对唯一编号'); + return; + } + final controller = Get.find(); + await controller.getSetting(); + await AppMqTool.mqRefresh(); + Toast.success(msg: '绑定成功'); + if (dialogContext.mounted) Navigator.of(dialogContext).pop(); + } catch (e, stack) { + logger.e('|绑定服务端设备信息error:|$e|stack:|$stack|'); + Toast.error(msg: '绑定失败,请稍后重试'); + } finally { + EasyLoading.dismiss(); + } + }, + ), + ], + ), + ); + uniqueCodeController.dispose(); +} + +Widget settingBaseInfo(BuildContext context) { + final controller = Get.find(); + return Obx(() { + final sys = controller.sysSetting.value; + if (sys == null) return const SettingEmpty(message: '暂无终端信息'); + return SingleChildScrollView( + child: Column( + children: [ + SettingGroup( + title: '基本信息', + child: Column( + children: [ + _copyRow( + icon: FAssets.icons.store, + label: '设备类型', + value: sys.sysType.cnStr, + ), + _copyRow( + icon: FAssets.icons.hardDrive, + label: '设备名称', + value: sys.serviceName, + ), + _copyRow( + icon: FAssets.icons.idCard, + label: 'Android ID', + value: sys.androidId, + masked: true, + ), + SettingInfoRow( + icon: FAssets.icons.idCard, + label: '唯一编号', + value: _maskIdentifier(sys.serviceId), + actionLabel: + ObjectUtil.isEmpty(sys.serviceId) ? '立即绑定' : '重新绑定', + onTap: () => _bindServiceDialog(context, sys), + ), + _copyRow( + icon: FAssets.icons.store, + label: '商户编号', + value: sys.serviceMerchantId, + masked: true, + ), + _copyRow( + icon: FAssets.icons.building, + label: '商户部门编号', + value: sys.merchantDeptId, + masked: true, + showDivider: false, + ), + ], + ), + ), + SizedBox(height: XfjDesignTokens.cardGap.h), + SettingGroup( + title: '运行环境', + child: Column( + children: [ + SettingInfoRow( + icon: FAssets.icons.monitor, + label: '当前设备', + subtitle: '检测到 ${AppRuntimeTool.displayCount} 个显示屏', + value: AppRuntimeTool.isEmulator + ? 'Android 虚拟机' + : 'Android 真实设备', + ), + SettingInfoRow( + icon: FAssets.icons.keyboard, + label: '设置为主控', + subtitle: AppRuntimeTool.isEmulator + ? '虚拟机强制使用人工输入,不连接扫码、饭卡和人脸硬件' + : '开启后扫码、饭卡读写和人脸识别由操作员输入结果', + showDivider: false, + trailing: FSwitch( + semanticLabel: '设置为主控手动硬件输入模式', + value: controller.manualHardwareInput.value, + enabled: !AppRuntimeTool.isEmulator, + onChange: controller.setManualHardwareInput, + ), + ), + ], + ), + ), + SizedBox(height: XfjDesignTokens.cardGap.h), + SettingGroup( + title: '主题样式', + child: Column( + children: [ + SettingSegmentedSelector( + label: '字体大小', + value: sys.globalFontSize ?? SettingGlobalFontSize.xl3, + options: const { + SettingGlobalFontSize.xl2: '紧凑', + SettingGlobalFontSize.xl3: '标准', + SettingGlobalFontSize.xl4: '大号', + }, + onChanged: controller.setGlobalFontSize, + ), + SettingSegmentedSelector( + label: '按钮圆角', + value: sys.buttonRadius, + options: const { + SettingButtonRadius.compact: '紧凑', + SettingButtonRadius.standard: '标准', + SettingButtonRadius.large: '大号', + }, + onChanged: controller.setButtonRadius, + ), + SettingSegmentedSelector( + label: '卡片圆角', + value: sys.cardRadius, + options: const { + SettingCardRadius.compact: '紧凑', + SettingCardRadius.standard: '标准', + SettingCardRadius.large: '大号', + }, + onChanged: controller.setCardRadius, + ), + SettingSegmentedSelector( + label: '按钮大小', + value: sys.buttonSize, + options: const { + SettingButtonSize.compact: '紧凑', + SettingButtonSize.standard: '标准', + SettingButtonSize.large: '大号', + }, + onChanged: controller.setButtonSize, + ), + const _TerminalMenuEditor(), + ], + ), + ), + ], + ), + ); + }); +} + +/// 左侧菜单只允许从手柄直接拖动,并提供上下移动按钮作为精确操作和无障碍替代。 +class _TerminalMenuEditor extends StatelessWidget { + const _TerminalMenuEditor(); + + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + final menuController = Get.find(); + return Container( + padding: EdgeInsets.fromLTRB(20.w, 14.h, 20.w, 20.h), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '左侧菜单', + style: theme.typography.base.copyWith( + color: XfjDesignTokens.ink, + fontWeight: FontWeight.w700, + ), + ), + SizedBox(height: 4.h), + Text( + '拖动手柄调整顺序,退出固定在底部;关闭后仅隐藏左侧入口。', + style: theme.typography.sm.copyWith( + color: XfjDesignTokens.muted, + ), + ), + ], + ), + ), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: EdgeInsets.symmetric( + horizontal: 12.w * XfjDesignTokens.buttonSizeScale, + vertical: 8.h * XfjDesignTokens.buttonSizeScale, + ), + decoration: BoxDecoration( + color: XfjDesignTokens.primarySoft, + borderRadius: BorderRadius.circular( + XfjDesignTokens.controlRadius, + ), + ), + child: Row( + children: [ + FIcon( + FAssets.icons.gripVertical, + color: XfjDesignTokens.primary, + size: 18.sp, + ), + SizedBox(width: 8.w), + Text( + '拖动手柄', + style: theme.typography.sm.copyWith( + color: XfjDesignTokens.primary, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ), + SizedBox(width: 8.w), + FButton( + style: FButtonStyle.outline, + prefix: FIcon( + FAssets.icons.rotateCcw, + color: XfjDesignTokens.ink, + size: 18.sp, + ), + label: Text( + '还原', + style: theme.typography.sm.copyWith( + color: XfjDesignTokens.ink, + fontWeight: FontWeight.w700, + ), + ), + onPress: () { + menuController.reset(); + Toast.success(msg: '左侧菜单已还原'); + }, + ), + ], + ), + ], + ), + SizedBox(height: 12.h), + Obx( + () => ReorderableListView.builder( + shrinkWrap: true, + primary: false, + physics: const NeverScrollableScrollPhysics(), + buildDefaultDragHandles: false, + itemCount: menuController.menuOrder.length, + onReorder: menuController.reorder, + proxyDecorator: (child, _, animation) => AnimatedBuilder( + animation: animation, + builder: (context, child) => Material( + color: Colors.transparent, + elevation: 6 * animation.value, + borderRadius: BorderRadius.circular( + XfjDesignTokens.controlRadius, + ), + child: child, + ), + child: child, + ), + itemBuilder: (context, index) { + final item = menuController.menuOrder[index]; + return Padding( + key: ValueKey(item.code), + padding: EdgeInsets.only( + bottom: index == menuController.menuOrder.length - 1 + ? 0 + : XfjDesignTokens.cardGap.h, + ), + child: Obx( + () => _TerminalMenuRow( + item: item, + index: index, + visible: menuController.isVisible(item), + reorderable: true, + visibilityEnabled: true, + onVisibleChanged: (visible) => + menuController.setVisible(item, visible), + onMoveUp: index == 0 + ? null + : () => menuController.reorder(index, index - 1), + onMoveDown: index == menuController.menuOrder.length - 1 + ? null + : () => menuController.reorder(index, index + 2), + ), + ), + ); + }, + ), + ), + SizedBox(height: XfjDesignTokens.cardGap.h), + Obx( + () => _TerminalMenuRow( + item: TerminalMenuItemType.logout, + index: menuController.menuOrder.length, + visible: menuController.isVisible(TerminalMenuItemType.logout), + reorderable: false, + visibilityEnabled: true, + onVisibleChanged: (visible) => menuController.setVisible( + TerminalMenuItemType.logout, + visible, + ), + ), + ), + ], + ), + ); + } +} + +class _TerminalMenuRow extends StatelessWidget { + const _TerminalMenuRow({ + required this.item, + required this.index, + required this.visible, + required this.reorderable, + required this.visibilityEnabled, + required this.onVisibleChanged, + this.onMoveUp, + this.onMoveDown, + }); + + final TerminalMenuItemType item; + final int index; + final bool visible; + final bool reorderable; + final bool visibilityEnabled; + final ValueChanged onVisibleChanged; + final VoidCallback? onMoveUp; + final VoidCallback? onMoveDown; + + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + final leadingHandle = SizedBox( + width: 48.w, + height: 48.h, + child: FIcon( + reorderable ? FAssets.icons.gripVertical : FAssets.icons.lock, + color: reorderable ? XfjDesignTokens.muted : XfjDesignTokens.primary, + size: 22.sp, + ).center(), + ); + return Container( + constraints: BoxConstraints(minHeight: XfjDesignTokens.controlHeight.h), + padding: EdgeInsets.symmetric(horizontal: 12.w), + decoration: BoxDecoration( + color: visible ? XfjDesignTokens.surface : XfjDesignTokens.control, + border: Border.all(color: XfjDesignTokens.line), + borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), + ), + child: Row( + children: [ + if (reorderable) + ReorderableDragStartListener(index: index, child: leadingHandle) + else + leadingHandle, + SizedBox(width: 8.w), + if (reorderable) ...[ + Container( + width: 32.w, + height: 32.w, + alignment: Alignment.center, + decoration: BoxDecoration( + color: visible + ? XfjDesignTokens.primarySoft + : XfjDesignTokens.surface, + borderRadius: BorderRadius.circular( + XfjDesignTokens.controlRadius, + ), + ), + child: Text( + '${index + 1}', + style: theme.typography.sm.copyWith( + color: + visible ? XfjDesignTokens.primary : XfjDesignTokens.muted, + fontWeight: FontWeight.w700, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + ), + ], + SizedBox(width: 12.w), + FIcon( + item.icon, + color: visible ? XfjDesignTokens.ink : XfjDesignTokens.muted, + size: 22.sp, + ), + SizedBox(width: 10.w), + Expanded( + child: Text( + item.title, + style: theme.typography.base.copyWith( + color: visible ? XfjDesignTokens.ink : XfjDesignTokens.muted, + fontWeight: FontWeight.w700, + ), + ), + ), + if (reorderable) ...[ + _MenuMoveButton( + semanticLabel: '${item.title}上移', + icon: FAssets.icons.chevronUp, + onTap: onMoveUp, + ), + SizedBox(width: 8.w), + _MenuMoveButton( + semanticLabel: '${item.title}下移', + icon: FAssets.icons.chevronDown, + onTap: onMoveDown, + ), + SizedBox(width: 16.w), + ] else ...[ + Container( + padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 8.h), + decoration: BoxDecoration( + color: XfjDesignTokens.primarySoft, + borderRadius: BorderRadius.circular( + XfjDesignTokens.controlRadius, + ), + ), + child: Text( + '固定底部', + style: theme.typography.sm.copyWith( + color: XfjDesignTokens.primary, + fontWeight: FontWeight.w700, + ), + ), + ), + SizedBox(width: 16.w), + ], + SizedBox( + width: 64.w, + child: Text( + visible ? '显示' : '隐藏', + textAlign: TextAlign.right, + style: theme.typography.sm.copyWith( + color: + visible ? XfjDesignTokens.success : XfjDesignTokens.muted, + fontWeight: FontWeight.w700, + ), + ), + ), + SizedBox(width: 12.w), + FSwitch( + semanticLabel: '${item.title}${visible ? '已显示' : '已隐藏'}', + value: visible, + enabled: visibilityEnabled, + onChange: onVisibleChanged, + ), + ], + ), + ); + } +} + +class _MenuMoveButton extends StatelessWidget { + const _MenuMoveButton({ + required this.semanticLabel, + required this.icon, + this.onTap, + }); + + final String semanticLabel; + final SvgAsset icon; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + final enabled = onTap != null; + final buttonSize = + (48 * XfjDesignTokens.buttonSizeScale).clamp(48, 60).toDouble(); + return Semantics( + button: true, + enabled: enabled, + label: semanticLabel, + child: Opacity( + opacity: enabled ? 1 : 0.35, + child: Material( + color: XfjDesignTokens.surface, + borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), + child: Container( + width: buttonSize.w, + height: buttonSize.h, + decoration: BoxDecoration( + border: Border.all(color: XfjDesignTokens.line), + borderRadius: BorderRadius.circular( + XfjDesignTokens.controlRadius, + ), + ), + child: FIcon( + icon, + color: XfjDesignTokens.ink, + size: 20.sp, + ).center(), + ), + ), + ), + ), + ); + } +} + +Widget _copyRow({ + required SvgAsset icon, + required String label, + String? value, + bool masked = false, + bool showDivider = true, +}) { + return SettingInfoRow( + icon: icon, + label: label, + value: masked ? _maskIdentifier(value) : _safeValue(value), + actionLabel: '复制', + showDivider: showDivider, + onTap: ObjectUtil.isEmpty(value) ? null : () => KeywordTool.copy(value), + ); +} + +String _safeValue(String? value) { + return ObjectUtil.isEmpty(value) ? '--' : value!; +} + +String _maskIdentifier(String? value) { + if (ObjectUtil.isEmpty(value)) return '--'; + final text = value!; + if (text.length <= 8) return '${text.substring(0, 2)}••••'; + final prefixLength = text.length >= 16 ? 4 : 3; + final suffixLength = text.length >= 16 ? 4 : 3; + return '${text.substring(0, prefixLength)}••••••••${text.substring(text.length - suffixLength)}'; +} diff --git a/lib/pages/setting/widgets/setting_components.dart b/lib/pages/setting/widgets/setting_components.dart new file mode 100644 index 0000000..ded7e47 --- /dev/null +++ b/lib/pages/setting/widgets/setting_components.dart @@ -0,0 +1,434 @@ +import 'package:ducafe_ui_core/ducafe_ui_core.dart'; +import 'package:flutter/material.dart'; +import 'package:forui/forui.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +class SettingGroup extends StatelessWidget { + const SettingGroup({super.key, required this.title, required this.child}); + + final String title; + final Widget child; + + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: theme.typography.lg.copyWith( + color: XfjDesignTokens.ink, + fontWeight: FontWeight.w800, + ), + ), + SizedBox(height: 12.h), + Container( + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + color: XfjDesignTokens.surface, + border: Border.all(color: XfjDesignTokens.line), + borderRadius: BorderRadius.circular(XfjDesignTokens.cardRadius), + ), + child: child, + ), + ], + ); + } +} + +class SettingSegmentedSelector extends StatelessWidget { + const SettingSegmentedSelector({ + super.key, + required this.label, + required this.value, + required this.options, + required this.onChanged, + this.showDivider = true, + }); + + final String label; + final T value; + final Map options; + final ValueChanged onChanged; + final bool showDivider; + + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + return Container( + constraints: BoxConstraints(minHeight: 72.h), + padding: EdgeInsets.symmetric(horizontal: 20.w, vertical: 12.h), + decoration: BoxDecoration( + border: showDivider + ? const Border(bottom: BorderSide(color: XfjDesignTokens.line)) + : null, + ), + child: Row( + children: [ + SizedBox( + width: 230.w, + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.typography.base.copyWith( + color: XfjDesignTokens.ink, + fontWeight: FontWeight.w600, + ), + ), + ), + SizedBox(width: 16.w), + Expanded( + child: Align( + alignment: Alignment.centerRight, + child: Container( + padding: EdgeInsets.all(4.w), + decoration: BoxDecoration( + color: const Color(0xFFF0F2F5), + borderRadius: BorderRadius.circular( + XfjDesignTokens.controlRadius, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: options.entries.map((entry) { + final active = value == entry.key; + return Padding( + padding: EdgeInsets.only( + left: entry.key == options.keys.first ? 0 : 4.w, + ), + child: InkWell( + onTap: () => onChanged(entry.key), + borderRadius: BorderRadius.circular( + XfjDesignTokens.controlRadius, + ), + child: Container( + constraints: BoxConstraints( + minWidth: 112.w, + minHeight: 48.h, + ), + padding: EdgeInsets.symmetric( + horizontal: 20.w * XfjDesignTokens.buttonSizeScale, + vertical: 6.h * XfjDesignTokens.buttonSizeScale, + ), + alignment: Alignment.center, + decoration: BoxDecoration( + color: active ? Colors.white : Colors.transparent, + borderRadius: BorderRadius.circular( + XfjDesignTokens.controlRadius, + ), + boxShadow: + active ? XfjDesignTokens.surfaceShadow : null, + ), + child: Text( + entry.value, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.typography.base.copyWith( + color: active + ? XfjDesignTokens.primary + : XfjDesignTokens.muted, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ); + }).toList(), + ), + ), + ), + ), + ], + ), + ); + } +} + +class SettingInfoRow extends StatelessWidget { + const SettingInfoRow({ + super.key, + required this.icon, + required this.label, + this.value, + this.actionLabel, + this.onTap, + this.showDivider = true, + this.trailing, + this.subtitle, + }); + + final SvgAsset icon; + final String label; + final String? value; + final String? actionLabel; + final VoidCallback? onTap; + final bool showDivider; + final Widget? trailing; + final String? subtitle; + + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + final hasAction = actionLabel != null; + return InkWell( + onTap: onTap, + child: Container( + constraints: BoxConstraints(minHeight: subtitle == null ? 68.h : 80.h), + padding: EdgeInsets.symmetric(horizontal: 20.w, vertical: 10.h), + decoration: BoxDecoration( + border: showDivider + ? const Border(bottom: BorderSide(color: XfjDesignTokens.line)) + : null, + ), + child: Row( + children: [ + Container( + width: 40.w, + height: 40.w, + alignment: Alignment.center, + decoration: BoxDecoration( + color: XfjDesignTokens.control, + borderRadius: BorderRadius.circular( + XfjDesignTokens.controlRadius, + ), + ), + child: FIcon(icon, color: XfjDesignTokens.muted, size: 22.sp), + ), + SizedBox(width: 16.w), + SizedBox( + width: subtitle == null ? 230.w : 520.w, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.typography.base.copyWith( + color: XfjDesignTokens.ink, + fontWeight: FontWeight.w600, + ), + ), + if (subtitle != null) ...[ + SizedBox(height: 4.h), + Text( + subtitle!, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: theme.typography.sm.copyWith( + color: XfjDesignTokens.muted, + ), + ), + ], + ], + ), + ), + SizedBox(width: 16.w), + Expanded( + child: value == null + ? const SizedBox.shrink() + : Text( + value!, + maxLines: subtitle == null ? 1 : 2, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.right, + style: theme.typography.base.copyWith( + color: XfjDesignTokens.muted, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + ), + if (trailing != null) ...[SizedBox(width: 16.w), trailing!], + if (hasAction) ...[ + SizedBox(width: 16.w), + SizedBox( + width: 148.w, + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Flexible( + child: Text( + actionLabel!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.typography.base.copyWith( + color: XfjDesignTokens.primary, + fontWeight: FontWeight.w600, + ), + ), + ), + SizedBox(width: 6.w), + FIcon( + actionLabel == '复制' + ? FAssets.icons.copy + : FAssets.icons.chevronRight, + color: XfjDesignTokens.primary, + size: 18.sp, + ), + ], + ), + ), + ], + ], + ), + ), + ); + } +} + +class SettingNotice extends StatelessWidget { + const SettingNotice({ + super.key, + required this.text, + this.color = XfjDesignTokens.muted, + this.background = XfjDesignTokens.control, + this.borderColor, + this.icon, + }); + + final String text; + final Color color; + final Color background; + final Color? borderColor; + final SvgAsset? icon; + + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + return Container( + width: double.infinity, + padding: EdgeInsets.all(16.w), + decoration: BoxDecoration( + color: background, + border: borderColor == null ? null : Border.all(color: borderColor!), + borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), + ), + child: Row( + children: [ + if (icon != null) ...[ + FIcon(icon!, color: color, size: 20.sp), + SizedBox(width: 10.w), + ], + Expanded( + child: Text( + text, + style: theme.typography.sm.copyWith(color: color), + ), + ), + ], + ), + ); + } +} + +class SettingEmpty extends StatelessWidget { + const SettingEmpty({super.key, required this.message, this.icon}); + + final String message; + final SvgAsset? icon; + + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + FIcon( + icon ?? FAssets.icons.inbox, + color: XfjDesignTokens.muted, + size: 48.sp, + ), + SizedBox(height: 12.h), + Text( + message, + style: theme.typography.base.copyWith( + color: XfjDesignTokens.muted, + ), + ), + ], + ), + ); + } +} + +class SettingActionButton extends StatelessWidget { + const SettingActionButton({ + super.key, + required this.label, + required this.onTap, + this.icon, + this.primary = false, + this.danger = false, + this.enabled = true, + }); + + final String label; + final VoidCallback onTap; + final SvgAsset? icon; + final bool primary; + final bool danger; + final bool enabled; + + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + final foreground = danger + ? XfjDesignTokens.danger + : primary + ? Colors.white + : XfjDesignTokens.primary; + final background = primary + ? XfjDesignTokens.primary + : danger + ? const Color(0xFFFFF3F2) + : XfjDesignTokens.surface; + final sizeScale = XfjDesignTokens.buttonSizeScale; + return Opacity( + opacity: enabled ? 1 : 0.45, + child: InkWell( + onTap: enabled ? onTap : null, + borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), + child: Container( + constraints: BoxConstraints(minHeight: 48.h), + padding: EdgeInsets.symmetric( + horizontal: 18.w * sizeScale, + vertical: 10.h * sizeScale, + ), + decoration: BoxDecoration( + color: background, + border: Border.all( + color: primary + ? XfjDesignTokens.primary + : danger + ? XfjDesignTokens.danger.withValues(alpha: 0.35) + : XfjDesignTokens.line, + ), + borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (icon != null) ...[ + FIcon(icon!, color: foreground, size: 20.sp), + SizedBox(width: 8.w), + ], + Text( + label, + style: theme.typography.base.copyWith( + color: foreground, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/pages/setting/widgets/setting_face.dart b/lib/pages/setting/widgets/setting_face.dart new file mode 100644 index 0000000..1729c2e --- /dev/null +++ b/lib/pages/setting/widgets/setting_face.dart @@ -0,0 +1,386 @@ +import 'dart:async'; + +import 'package:common_utils/common_utils.dart'; +import 'package:ducafe_ui_core/ducafe_ui_core.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_easyloading/flutter_easyloading.dart'; +import 'package:forui/forui.dart'; +import 'package:get/get.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +import 'setting_components.dart'; + +class FaceManageController extends GetxController { + final appC = Get.find(); + + late SysSetting sysSetting; + + Rx sysType = SysType.CANTEEN.obs; + + RxList faceList = RxList(); + + RxInt currentPage = RxInt(1); + + RxInt totalPage = RxInt(0); + + RxInt pageSize = RxInt(15); + + RxBool isLoading = false.obs; + + RxString errorMessage = ''.obs; + + final usernameC = TextEditingController(); + + StreamSubscription? mqUpdateListen; + + @override + void onInit() async { + super.onInit(); + sysSetting = await AppSettingTool.getSysSetting(); + sysType.value = sysSetting.sysType; + getFaceList(); + _listenMqCallback(); + } + + @override + void onClose() { + mqUpdateListen?.cancel(); + usernameC.dispose(); + } + + Future getFaceList({bool isLoading = false}) async { + this.isLoading.value = true; + errorMessage.value = ''; + if (isLoading) { + EasyLoading.show( + status: '查询中...', + maskType: EasyLoadingMaskType.black, + ); + } + try { + final faceDao = appC.dataBase.getFaceDao; + String userName = usernameC.text; + if (ObjectUtil.isEmpty(userName)) userName = "-1"; + final findCount = await faceDao.findCount(userName); + totalPage.value = findCount ?? 0; + final list = await faceDao.findAll( + userName, + pageSize.value, + (currentPage.value - 1) * pageSize.value, + ); + faceList.value = list; + } catch (e, stack) { + errorMessage.value = '人脸数据加载失败,请稍后重试'; + logger.e('|人脸管理|加载失败error:|$e|stack:|$stack|'); + } finally { + this.isLoading.value = false; + if (isLoading) { + Future.delayed(const Duration(seconds: 1), () { + EasyLoading.dismiss(); + }); + } + } + } + + // 同步人脸 + Future asyncFace({bool isAll = false}) async { + final isNetwork = AppNetworkTool.isAutoNetworkMsg(); + if (!isNetwork) return; + final setting = await AppSettingTool.getSysSetting(); + final serviceMerchantId = setting.serviceMerchantId; + if (ObjectUtil.isEmpty(serviceMerchantId)) { + Toast.error(msg: "请先绑定设备"); + return; + } + if (AppSysTool.isProd()) { + final isActive = await AppFaceTool.isActiveSdk(); + if (!isActive) { + Toast.error(msg: "人脸sdk激活失败"); + return; + } + } + final isSure = await appMessageDialog( + Get.context!, + "是否同步所有人脸,这个过程可能比较长", + ); + if (!isSure) return; + EasyLoading.show( + status: '同步中...', + maskType: EasyLoadingMaskType.black, + ); + try { + await SyncFaceTool.delEmptyFace(); + await SyncFaceTool.syncFace( + isAll: isAll, + onSyncGroup: () { + currentPage.value = 1; + getFaceList(); + }, + ); + await getFaceList(); + Toast.success(msg: '同步成功'); + } catch (e, stack) { + Toast.error(msg: '同步人脸失败,请稍后重试'); + logger.e('|人脸管理|同步失败error:|$e|stack:|$stack|'); + } finally { + currentPage.value = 1; + EasyLoading.dismiss(); + } + } + + Future delAllFace() async { + final isSure = await appMessageDialog( + Get.context!, + "是否删除全部人脸", + ); + if (!isSure) return; + EasyLoading.show( + status: '删除中...', + maskType: EasyLoadingMaskType.black, + ); + try { + final faceDao = appC.dataBase.getFaceDao; + await faceDao.delAll(); + await AppFaceTool.removeFace(sampleId: '-1'); + await getFaceList(); + Toast.success(msg: '删除成功'); + } catch (e, stack) { + Toast.error(msg: '删除全部人脸失败'); + logger.e('|人脸管理|删除全部失败error:|$e|stack:|$stack|'); + } finally { + EasyLoading.dismiss(); + } + } + + _listenMqCallback() { + mqUpdateListen = + appC.mqUpdateEventBus.on().listen((event) async { + switch (event) { + case MqtActionEnum.FACE: + getFaceList(isLoading: true); + break; + default: + } + }); + } + + /// 搜索 + Future doPage({int? page}) async { + if (page != null) currentPage.value = page; + // 收起键盘 + FocusManager.instance.primaryFocus?.unfocus(); + await getFaceList(isLoading: true); + } + + /// 重置 + Future doReset() async { + currentPage.value = 1; + usernameC.clear(); + // 收起键盘 + FocusManager.instance.primaryFocus?.unfocus(); + await getFaceList(isLoading: true); + } + + // 删除人脸 + Future deleteFace(FaceEntity v) async { + final isSure = await appMessageDialog(Get.context!, "是否删除人脸"); + if (!isSure) return; + EasyLoading.show( + status: '删除中...', + maskType: EasyLoadingMaskType.black, + ); + try { + final faceDao = appC.dataBase.getFaceDao; + if (ObjectUtil.isNotEmpty(v.path)) { + await SyncFaceTool.delFaceByStorage(filePath: v.path!); + } + await faceDao.delById(v.id!); + await AppFaceTool.removeFace(sampleId: v.sampleId); + await getFaceList(); + Toast.success(msg: '删除成功'); + } catch (e, stack) { + Toast.error(msg: '删除人脸失败'); + logger.e('|人脸管理|删除失败error:|$e|stack:|$stack|'); + } finally { + EasyLoading.dismiss(); + } + } +} + +/// 搜索 +Widget buildFaceSearch(BuildContext context) { + final faceC = Get.find(); + final theme = FTheme.of(context); + return [ + Obx( + () => FTextField( + textInputAction: faceC.sysType.value == SysType.SUPERMARKET + ? TextInputAction.none + : TextInputAction.newline, + keyboardType: faceC.sysType.value == SysType.SUPERMARKET + ? TextInputType.none + : TextInputType.multiline, + controller: faceC.usernameC, + hint: '请输入姓名', + maxLines: 1, + onSubmit: (_) => faceC.doPage(), + ), + ).width(300.w), + FButton( + prefix: FIcon( + FAssets.icons.ban, + size: theme.typography.base.fontSize, + ), + label: Text( + '重置', + style: theme.typography.base.copyWith( + color: theme.colorScheme.secondary, + ), + ), + onPress: () => faceC.doReset(), + ), + FButton( + prefix: FIcon( + FAssets.icons.search, + size: theme.typography.base.fontSize, + ), + label: Text( + '搜索', + style: theme.typography.base.copyWith( + color: theme.colorScheme.secondary, + ), + ), + onPress: () => faceC.doPage(), + ), + FButton( + label: Text( + '同步本部门人脸', + style: theme.typography.base.copyWith( + color: theme.colorScheme.primary, + ), + ), + style: FButtonStyle.outline, + onPress: () => faceC.asyncFace(isAll: false), + ), + FButton( + label: Text( + '同步全部人脸', + style: theme.typography.base.copyWith( + color: theme.colorScheme.primary, + ), + ), + style: FButtonStyle.outline, + onPress: () => faceC.asyncFace(isAll: true), + ), + const Spacer(), + FButton( + label: Text( + '删除全部人脸', + style: theme.typography.base.copyWith(color: Colors.white), + ), + style: FButtonStyle.destructive, + onPress: () => faceC.delAllFace(), + ), + ].toRowSpace(space: 20.w); +} + +/// 列表 +Widget buildFaceList(context) { + final theme = FTheme.of(context); + final productC = Get.find(); + return Obx( + () { + if (productC.isLoading.value) { + return const Center(child: CircularProgressIndicator()); + } + if (productC.errorMessage.isNotEmpty) { + return SettingEmpty(message: productC.errorMessage.value); + } + if (productC.faceList.isEmpty) { + return const SettingEmpty(message: '暂无本地人脸样本'); + } + return GridView.builder( + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 5, + mainAxisSpacing: XfjDesignTokens.cardGap.h, + crossAxisSpacing: XfjDesignTokens.cardGap.w, + childAspectRatio: 1.2, + ), + itemCount: productC.faceList.length, + itemBuilder: (BuildContext context, int index) { + final v = productC.faceList[index]; + return InkWell( + onTap: () => productC.deleteFace(v), + borderRadius: BorderRadius.circular(XfjDesignTokens.cardRadius), + child: Container( + padding: EdgeInsets.all(14.w), + decoration: BoxDecoration( + color: XfjDesignTokens.surface, + border: Border.all(color: XfjDesignTokens.line), + borderRadius: BorderRadius.circular(XfjDesignTokens.cardRadius), + boxShadow: XfjDesignTokens.surfaceShadow, + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + ClipOval( + child: appImg(v.path, width: 72.w, height: 72.w), + ), + SizedBox(height: 10.h), + Text( + v.userName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.typography.base.copyWith( + color: XfjDesignTokens.ink, + fontWeight: FontWeight.w800, + ), + ), + SizedBox(height: 5.h), + Text( + '用户编号:${_maskFaceId(v.userId.toString())}', + style: theme.typography.xs.copyWith( + color: XfjDesignTokens.muted, + ), + ), + SizedBox(height: 3.h), + Text( + '样本编号:${_maskFaceId(v.sampleId)}', + style: theme.typography.xs.copyWith( + color: XfjDesignTokens.muted, + ), + ), + ], + ), + ), + ); + }, + ); + }, + ); +} + +Widget settingFace(BuildContext context) { + final faceC = Get.isRegistered() + ? Get.find() + : Get.put(FaceManageController()); + return [ + buildFaceSearch(context), + buildFaceList(context).expanded(), + Obx( + () => appPage( + context, + pageNo: faceC.currentPage.value, + pageSize: faceC.pageSize.value, + total: faceC.totalPage.value, + onPageChanged: (pageNo) => faceC.doPage(page: pageNo), + ), + ), + ].toColumnSpace(space: XfjDesignTokens.cardGap.h); +} + +String _maskFaceId(String value) { + if (value.length <= 4) return '***$value'; + return '${value.substring(0, 1)}***${value.substring(value.length - 3)}'; +} diff --git a/lib/pages/setting/widgets/setting_pay.dart b/lib/pages/setting/widgets/setting_pay.dart new file mode 100644 index 0000000..bbc6965 --- /dev/null +++ b/lib/pages/setting/widgets/setting_pay.dart @@ -0,0 +1,227 @@ +import 'package:ducafe_ui_core/ducafe_ui_core.dart'; +import 'package:flutter/material.dart'; +import 'package:forui/forui.dart'; +import 'package:get/get.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +import '../controller.dart'; +import 'setting_components.dart'; + +Widget settingPay(BuildContext context) { + final controller = Get.find(); + return Obx(() { + final sys = controller.sysSetting.value; + if (sys == null) return const SettingEmpty(message: '暂无支付设置'); + final enabled = sys.usePayDisplayType ?? OrderPayDisplayType.values; + final defaultType = sys.defaultPayDisplayType ?? enabled.firstOrNull; + return SingleChildScrollView( + child: Column( + children: [ + SettingGroup( + title: '使用支付方式', + child: Padding( + padding: EdgeInsets.all(16.w), + child: GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 4, + crossAxisSpacing: XfjDesignTokens.cardGap.w, + mainAxisSpacing: XfjDesignTokens.cardGap.h, + childAspectRatio: 2.35, + ), + itemCount: OrderPayDisplayType.values.length, + itemBuilder: (context, index) { + final type = OrderPayDisplayType.values[index]; + return _PayTypeCard( + type: type, + selected: enabled.contains(type), + onTap: () => controller.togglePayType(type), + ); + }, + ), + ), + ), + SizedBox(height: XfjDesignTokens.cardGap.h), + SettingGroup( + title: '默认支付方式', + child: Padding( + padding: EdgeInsets.all(16.w), + child: GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 4, + crossAxisSpacing: XfjDesignTokens.cardGap.w, + mainAxisSpacing: XfjDesignTokens.cardGap.h, + childAspectRatio: 3.3, + ), + itemCount: enabled.length, + itemBuilder: (context, index) { + final type = enabled[index]; + return _DefaultPayCard( + type: type, + selected: type == defaultType, + onTap: () => controller.setDefaultPayType(type), + ); + }, + ), + ), + ), + SizedBox(height: XfjDesignTokens.cardGap.h), + const SettingNotice( + text: '修改支付方式只影响收银端选择入口,不改变服务端权限、钱包范围和渠道支付能力。', + ), + ], + ), + ); + }); +} + +class _PayTypeCard extends StatelessWidget { + const _PayTypeCard({ + required this.type, + required this.selected, + required this.onTap, + }); + + final OrderPayDisplayType type; + final bool selected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), + child: Container( + padding: EdgeInsets.all(14.w), + decoration: BoxDecoration( + color: + selected ? XfjDesignTokens.primarySoft : XfjDesignTokens.surface, + border: Border.all( + color: selected ? XfjDesignTokens.primary : XfjDesignTokens.line, + ), + borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Row( + children: [ + FIcon( + _payIcon(type), + color: selected + ? XfjDesignTokens.primary + : XfjDesignTokens.muted, + size: 24.sp, + ), + SizedBox(width: 10.w), + Expanded( + child: Text( + _payLabel(type), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.typography.base.copyWith( + color: XfjDesignTokens.ink, + fontWeight: FontWeight.w700, + ), + ), + ), + Container( + width: 24.w, + height: 24.w, + alignment: Alignment.center, + decoration: BoxDecoration( + color: + selected ? XfjDesignTokens.primary : Colors.transparent, + border: selected + ? null + : Border.all(color: XfjDesignTokens.line), + shape: BoxShape.circle, + ), + child: selected + ? FIcon(FAssets.icons.check, + color: Colors.white, size: 14.sp) + : null, + ), + ], + ), + SizedBox(height: 8.h), + Text( + selected ? '收银时可选择' : '当前已停用', + style: theme.typography.sm.copyWith( + color: XfjDesignTokens.muted, + ), + ), + ], + ), + ), + ); + } +} + +class _DefaultPayCard extends StatelessWidget { + const _DefaultPayCard({ + required this.type, + required this.selected, + required this.onTap, + }); + + final OrderPayDisplayType type; + final bool selected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), + child: Container( + alignment: Alignment.center, + decoration: BoxDecoration( + color: selected ? XfjDesignTokens.primary : XfjDesignTokens.surface, + border: Border.all( + color: selected ? XfjDesignTokens.primary : XfjDesignTokens.line, + ), + borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), + ), + child: Text( + _payLabel(type), + style: theme.typography.base.copyWith( + color: selected ? Colors.white : XfjDesignTokens.ink, + fontWeight: FontWeight.w700, + ), + ), + ), + ); + } +} + +String _payLabel(OrderPayDisplayType type) { + return switch (type) { + OrderPayDisplayType.walletCard => '饭卡', + OrderPayDisplayType.qrCode => '付款码', + OrderPayDisplayType.qrCodeScan => '扫一扫', + OrderPayDisplayType.face => '人脸', + OrderPayDisplayType.cash => '现金', + OrderPayDisplayType.zyy => '中医院', + OrderPayDisplayType.rmyy => '人民医院', + OrderPayDisplayType.csxfk => '超市消费卡', + }; +} + +SvgAsset _payIcon(OrderPayDisplayType type) { + return switch (type) { + OrderPayDisplayType.walletCard => FAssets.icons.idCard, + OrderPayDisplayType.qrCode => FAssets.icons.qrCode, + OrderPayDisplayType.qrCodeScan => FAssets.icons.scanLine, + OrderPayDisplayType.face => FAssets.icons.scanFace, + OrderPayDisplayType.cash => FAssets.icons.banknote, + _ => FAssets.icons.creditCard, + }; +} diff --git a/lib/pages/setting/widgets/setting_product.dart b/lib/pages/setting/widgets/setting_product.dart new file mode 100644 index 0000000..aa4ef73 --- /dev/null +++ b/lib/pages/setting/widgets/setting_product.dart @@ -0,0 +1,426 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_easyloading/flutter_easyloading.dart'; +import 'package:forui/forui.dart'; +import 'package:ducafe_ui_core/ducafe_ui_core.dart'; +import 'package:get/get.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +import 'setting_components.dart'; + +class ProductManageController extends GetxController { + final appC = Get.find(); + + late SysSetting sysSetting; + + Rx sysType = SysType.CANTEEN.obs; + + // 食堂商品 + final canteenSpuList = [].obs; + + // 超市商品 + final supermarketSpuList = [].obs; + + ProductHourType hourType = ProductHourType.MORNING; + + // 自编码 + final selfCodeC = TextEditingController(); + + RxInt currentPage = RxInt(1); + RxInt totalPage = RxInt(0); + RxInt pageSize = RxInt(24); + + RxBool isLoading = false.obs; + + RxString errorMessage = ''.obs; + + final hourTypeC = FRadioSelectGroupController( + value: ProductHourType.MORNING.code, + ); + + @override + void onInit() async { + super.onInit(); + sysSetting = await AppSettingTool.getSysSetting(); + sysType.value = sysSetting.sysType; + await getProductList(); + hourTypeC.addValueListener((v) async { + final currentHourType = ProductHourType.values.fromCode( + v.first, + ); + if (currentHourType == null) return; + hourType = currentHourType; + await doPage(page: 1); + }); + } + + @override + void onClose() { + super.onClose(); + hourTypeC.dispose(); + selfCodeC.dispose(); + } + + // 更新商品和分类 + Future updateProductAndClassify() async { + EasyLoading.show( + status: '更新中...', + maskType: EasyLoadingMaskType.black, + ); + try { + await SyncProductTool.updateDatabase(); + await getProductList(); + Toast.success(msg: '商品和分类更新成功'); + } catch (e, stack) { + Toast.error(msg: '商品和分类更新失败'); + logger.e('|商品管理|更新失败error:|$e|stack:|$stack|'); + } finally { + EasyLoading.dismiss(); + } + } + + // 删除全部 + Future doDelAll() async { + final confirmed = await appMessageDialog( + Get.context!, + '是否删除全部本地商品和分类数据?删除后需重新同步。', + ); + if (!confirmed) return; + EasyLoading.show( + status: '删除中...', + maskType: EasyLoadingMaskType.black, + ); + try { + await SyncProductTool.delDatabase(); + await getProductList(); + Toast.success(msg: '本地商品数据已删除'); + } catch (e, stack) { + Toast.error(msg: '删除本地商品数据失败'); + logger.e('|商品管理|删除全部失败error:|$e|stack:|$stack|'); + } finally { + EasyLoading.dismiss(); + } + } + + // 获取商品列表 + Future getProductList({bool isLoading = false}) async { + this.isLoading.value = true; + errorMessage.value = ''; + if (isLoading) { + EasyLoading.show( + status: '查询中...', + maskType: EasyLoadingMaskType.black, + ); + } + try { + final currnetHourType = hourType.code; + SysType sysType = sysSetting.sysType; + + if (sysType == SysType.SUPERMARKET) { + // 超市 + final supermarketSpuDao = appC.dataBase.getSupermarketSkuDao; + final findCount = await supermarketSpuDao.getCount("-1", "-1"); + totalPage.value = findCount ?? 0; + final list = await supermarketSpuDao.getAllPage( + selfCodeC.text, + "-1", + pageSize.value, + (currentPage.value - 1) * pageSize.value, + ); + supermarketSpuList.value = list; + } else if (sysType == SysType.CANTEEN) { + // 食堂 + final productDao = appC.dataBase.getCanteenSpuDao; + final findCount = await productDao.getCount( + currnetHourType, + ); + totalPage.value = findCount ?? 0; + final list = await productDao.getAllPage( + "-1", + currnetHourType, + pageSize.value, + (currentPage.value - 1) * pageSize.value, + ); + canteenSpuList.value = list; + } + } catch (e, stack) { + errorMessage.value = '商品数据加载失败,请稍后重试'; + logger.e('|商品管理|加载失败error:|$e|stack:|$stack|'); + } finally { + this.isLoading.value = false; + if (isLoading) { + Future.delayed(const Duration(seconds: 1), () { + EasyLoading.dismiss(); + }); + } + } + } + + /// 搜索 + Future doPage({int? page}) async { + if (page != null) currentPage.value = page; + // 收起键盘 + FocusManager.instance.primaryFocus?.unfocus(); + await getProductList(isLoading: true); + } + + /// 重置 + Future doReset() async { + currentPage.value = 1; + selfCodeC.clear(); + // 收起键盘 + FocusManager.instance.primaryFocus?.unfocus(); + await getProductList(isLoading: true); + } + + // 切换是否默认 + Future trigger(CanteenSpuEntity v) async { + try { + final isSure = await appMessageDialog( + Get.context!, + "是否${v.isDefault ? '取消' : '设置为'}默认菜品", + ); + if (!isSure) return; + EasyLoading.show( + status: '设置中...', + maskType: EasyLoadingMaskType.black, + ); + final productDao = appC.dataBase.getCanteenSpuDao; + final editObj = CanteenSpuEntity.fromJson(v.toJson()); + editObj.isDefault = !editObj.isDefault; + await productDao.editDefault( + editObj.isDefault, + editObj.serviceUniqueCode, + hourType.code, + ); + await getProductList(); + Toast.success(msg: editObj.isDefault ? '已设为默认菜品' : '已取消默认菜品'); + } catch (e, stack) { + Toast.error(msg: '默认菜品设置失败'); + logger.e('|商品管理|默认菜品设置失败error:|$e|stack:|$stack|'); + } finally { + EasyLoading.dismiss(); + } + } +} + +// 搜索条件 +Widget _buildSearch(BuildContext context) { + final productC = Get.find(); + final filters = [ + TerminalToolbarControl( + width: 260.w, + child: Obx( + () => appRadioHourTypeDialog( + context, + productC.hourTypeC, + ).offstage( + offstage: productC.sysType.value != SysType.CANTEEN, + ), + ), + ), + TerminalToolbarControl( + width: 360.w, + child: Obx( + () => FTextField( + textInputAction: TextInputAction.none, + keyboardType: TextInputType.none, + controller: productC.selfCodeC, + hint: '请输入自编码/商品名', + maxLines: 1, + onSubmit: (_) => productC.doPage(), + ).offstage( + offstage: productC.sysType.value != SysType.SUPERMARKET, + ), + ), + ), + TerminalToolbarButton( + label: '重置', + icon: FAssets.icons.ban, + style: TerminalToolbarButtonStyle.secondary, + onTap: productC.doReset, + ), + TerminalToolbarButton( + label: '搜索', + icon: FAssets.icons.search, + onTap: productC.doPage, + ), + TerminalToolbarButton( + label: '更新商品和分类', + icon: FAssets.icons.pencilRuler, + minWidth: 188.w, + onTap: productC.updateProductAndClassify, + ), + ]; + return LayoutBuilder( + builder: (context, constraints) { + final deleteButton = TerminalToolbarButton( + label: '删除全部数据', + icon: FAssets.icons.trash2, + style: TerminalToolbarButtonStyle.danger, + minWidth: 190.w, + onTap: productC.doDelAll, + ); + return Row( + children: [ + Expanded( + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + ...filters.expand( + (item) => [item, SizedBox(width: 12.w)], + ), + ], + ), + ), + ), + SizedBox(width: 16.w), + SizedBox(width: 190.w, child: deleteButton), + ], + ); + }, + ); +} + +// 食堂列表 +Widget _buildCanteenList(BuildContext context) { + final theme = FTheme.of(context); + final productC = Get.find(); + return Obx( + () { + if (productC.isLoading.value) { + return const Center(child: CircularProgressIndicator()); + } + if (productC.errorMessage.isNotEmpty) { + return SettingEmpty(message: productC.errorMessage.value); + } + if (productC.canteenSpuList.isEmpty) { + return const SettingEmpty(message: '暂无本地菜品'); + } + return GridView.builder( + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 5, + mainAxisSpacing: XfjDesignTokens.cardGap.h, + crossAxisSpacing: XfjDesignTokens.cardGap.w, + childAspectRatio: 1.45, + ), + itemCount: productC.canteenSpuList.length, + itemBuilder: (BuildContext context, int index) { + final v = productC.canteenSpuList[index]; + return Container( + padding: EdgeInsets.all(12.w), + decoration: BoxDecoration( + color: XfjDesignTokens.surface, + border: Border.all(color: XfjDesignTokens.line), + borderRadius: BorderRadius.circular(XfjDesignTokens.cardRadius), + boxShadow: XfjDesignTokens.surfaceShadow, + ), + child: Row( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular( + XfjDesignTokens.controlRadius, + ), + child: appImg(v.image, width: 86.w, height: 86.w), + ), + SizedBox(width: 12.w), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + v.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.typography.base.copyWith( + color: XfjDesignTokens.ink, + fontWeight: FontWeight.w700, + ), + ), + SizedBox(height: 8.h), + appPrice(context, v.price.toString()), + SizedBox(height: 8.h), + Row( + children: [ + Text( + '默认菜品', + style: theme.typography.xs.copyWith( + color: XfjDesignTokens.muted, + ), + ), + const Spacer(), + FSwitch( + value: v.isDefault, + onChange: (_) => productC.trigger(v), + ), + ], + ), + ], + ), + ), + ], + ), + ); + }, + ); + }, + ); +} + +// 超市列表 +Widget _buildSupermarketList(BuildContext context) { + final productC = Get.find(); + return Obx( + () { + if (productC.isLoading.value) { + return const Center(child: CircularProgressIndicator()); + } + if (productC.errorMessage.isNotEmpty) { + return SettingEmpty(message: productC.errorMessage.value); + } + if (productC.supermarketSpuList.isEmpty) { + return const SettingEmpty(message: '暂无本地商品'); + } + return GridView.builder( + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 5, + mainAxisSpacing: XfjDesignTokens.cardGap.h, + crossAxisSpacing: XfjDesignTokens.cardGap.w, + childAspectRatio: 1.25, + ), + itemCount: productC.supermarketSpuList.length, + itemBuilder: (BuildContext context, int index) { + final v = productC.supermarketSpuList[index]; + return orderSupermarketSpuCard(context, supermarketSpu: v); + }, + ); + }, + ); +} + +Widget settingProduct(BuildContext context) { + final productC = Get.isRegistered() + ? Get.find() + : Get.put(ProductManageController()); + return [ + _buildSearch(context), + Obx( + () => Visibility( + visible: productC.sysType.value == SysType.CANTEEN, + replacement: _buildSupermarketList(context), + child: _buildCanteenList(context), + ), + ).expanded(), + Obx( + () => appPage( + context, + pageNo: productC.currentPage.value, + pageSize: productC.pageSize.value, + total: productC.totalPage.value, + onPageChanged: (pageNo) => productC.doPage(page: pageNo), + ), + ) + ].toColumnSpace(space: XfjDesignTokens.cardGap.h); +} diff --git a/lib/pages/setting/widgets/setting_sync.dart b/lib/pages/setting/widgets/setting_sync.dart new file mode 100644 index 0000000..d6735ba --- /dev/null +++ b/lib/pages/setting/widgets/setting_sync.dart @@ -0,0 +1,194 @@ +import 'package:common_utils/common_utils.dart'; +import 'package:ducafe_ui_core/ducafe_ui_core.dart'; +import 'package:flutter/material.dart'; +import 'package:forui/forui.dart'; +import 'package:get/get.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +import '../controller.dart'; +import 'setting_components.dart'; + +Widget settingSync(BuildContext context) { + final controller = Get.find(); + return Obx(() { + final sys = controller.sysSetting.value; + if (sys == null) return const SettingEmpty(message: '暂无同步信息'); + final items = <_SyncItem>[ + _SyncItem('config', '同步设备信息', sys.syncTime, controller.syncConfig), + _SyncItem('mq', '同步长链接', sys.mqSyncTime, controller.syncMq), + _SyncItem( + 'classify', + '同步分类', + sys.sysType == SysType.CANTEEN + ? sys.canteenClassifySyncTime + : sys.supermarketClassifySyncTime, + controller.syncClassify, + ), + _SyncItem( + 'product', + '同步商品', + sys.sysType == SysType.CANTEEN + ? sys.canteenProductSyncTime + : sys.supermarketProductSyncTime, + controller.syncProduct, + ), + _SyncItem( + 'offlineOrder', + '同步离线订单', + sys.offlineOrderSyncTime, + controller.syncOfflineOrder, + icon: FAssets.icons.cloudDownload, + ), + ]; + return Column( + children: [ + Expanded( + child: GridView.builder( + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + crossAxisSpacing: XfjDesignTokens.cardGap.w, + mainAxisSpacing: XfjDesignTokens.cardGap.h, + childAspectRatio: 3.25, + ), + itemCount: items.length, + itemBuilder: (context, index) { + final item = items[index]; + return _SyncCard( + item: item, + syncing: controller.syncingKey.value == item.key, + failed: controller.syncFailedKeys.contains(item.key), + enabled: controller.syncingKey.isEmpty, + ); + }, + ), + ), + SizedBox(height: XfjDesignTokens.cardGap.h), + const SettingNotice( + text: '网络接口可用不等于业务服务可用。同步失败时会保留本地数据,可在网络恢复后重新执行。', + ), + ], + ); + }); +} + +class _SyncItem { + const _SyncItem(this.key, this.title, this.time, this.action, {this.icon}); + + final String key; + final String title; + final DateTime? time; + final Future Function() action; + final SvgAsset? icon; +} + +class _SyncCard extends StatelessWidget { + const _SyncCard({ + required this.item, + required this.syncing, + required this.failed, + required this.enabled, + }); + + final _SyncItem item; + final bool syncing; + final bool failed; + final bool enabled; + + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + final statusColor = failed + ? XfjDesignTokens.danger + : syncing + ? XfjDesignTokens.primary + : XfjDesignTokens.success; + return Opacity( + opacity: enabled || syncing ? 1 : 0.55, + child: InkWell( + onTap: enabled ? item.action : null, + borderRadius: BorderRadius.circular(XfjDesignTokens.cardRadius), + child: Container( + padding: EdgeInsets.all(20.w), + decoration: BoxDecoration( + color: XfjDesignTokens.surface, + border: Border.all(color: XfjDesignTokens.line), + borderRadius: BorderRadius.circular(XfjDesignTokens.cardRadius), + boxShadow: XfjDesignTokens.surfaceShadow, + ), + child: Row( + children: [ + Container( + width: 56.w, + height: 56.w, + alignment: Alignment.center, + decoration: BoxDecoration( + color: XfjDesignTokens.primarySoft, + borderRadius: BorderRadius.circular( + XfjDesignTokens.cardRadius, + ), + ), + child: syncing + ? SizedBox( + width: 28.w, + height: 28.w, + child: const CircularProgressIndicator(strokeWidth: 3), + ) + : FIcon( + item.icon ?? FAssets.icons.refreshCw, + color: XfjDesignTokens.primary, + size: 30.sp, + ), + ), + SizedBox(width: 16.w), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + item.title, + style: theme.typography.lg.copyWith( + color: XfjDesignTokens.ink, + fontWeight: FontWeight.w800, + ), + ), + SizedBox(height: 8.h), + Text( + '最后同步时间:${_formatTime(item.time)}', + style: theme.typography.sm.copyWith( + color: XfjDesignTokens.muted, + ), + ), + SizedBox(height: 8.h), + Text( + failed + ? '同步失败,点击重试' + : syncing + ? '同步中...' + : '状态正常', + style: theme.typography.sm.copyWith( + color: statusColor, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + FIcon( + FAssets.icons.chevronRight, + color: XfjDesignTokens.muted, + size: 22.sp, + ), + ], + ), + ), + ), + ); + } +} + +String _formatTime(DateTime? time) { + if (time == null) return '未同步'; + return DateUtil.formatDate(time, format: DateFormats.full); +} diff --git a/lib/pages/supermarket_order/controller.dart b/lib/pages/supermarket_order/controller.dart new file mode 100644 index 0000000..ba6ca20 --- /dev/null +++ b/lib/pages/supermarket_order/controller.dart @@ -0,0 +1,631 @@ +import 'package:common_utils/common_utils.dart'; +import 'dart:convert'; +import 'package:flutter/material.dart'; +import 'package:flutter_easyloading/flutter_easyloading.dart'; +import 'package:flutter_subscreen_plugin/flutter_subscreen_plugin.dart'; +import 'package:get/get.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +class SupermarketOrderController extends GetxController with OrderMixin { + SupermarketOrderController(); + + static const _tag = "食堂购买页面"; + + final appC = Get.find(); + + final selfCodeC = TextEditingController(); + final focusNode = FocusNode(); + + late SysSetting appSetting; + + // 称重商品 + Rxn widgetProduct = Rxn(null); + + _initData() { + update(["supermarket_order"]); + } + + @override + void onInit() async { + super.onInit(); + await _initState(); + } + + @override + void onReady() { + super.onReady(); + _initData(); + } + + @override + void onClose() { + super.onClose(); + SubScreenPlugin.sendMsgToViceScreen(SubScreenAction.jumpSubBack.name); + // 读卡器 + appC.serialCardManage.readerClose(); + appC.serialCardManage.close(); + // 扫码枪 + BarcodeScanManage.instance.dispose(); + selfCodeC.dispose(); + focusNode.dispose(); + BarcodeScanManage.instance.removeWhitelistNode(focusNode); + } + + Future _initState() async { + EasyLoading.show( + status: '初始化资源...', + maskType: EasyLoadingMaskType.black, + ); + try { + appSetting = await AppSettingTool.getSysSetting(); + // 打开副屏 + await AppSubScreenTool.show(); + SubScreenPlugin.sendMsgToViceScreen(SubScreenAction.jumpSubPage.name); + BarcodeScanManage.instance.addWhitelistNode(focusNode); + // 组件初始化 + hourType.value = ProductHourType.TOTAL; + sysType = SysType.SUPERMARKET; + // 支付信息 + _listenPayTypeSelectCallback(); + // 称信息 + _listenSteelyardCallback(); + // 扫码枪 + await _listenBarCodeCallback(); + // 获取分类和商品 + await getClassify(); + await getProduct(); + // 清空购物车 + await _clearShopCard(); + } catch (e) { + logger.e("|$_tag|,初始化资源失败:$e"); + } finally { + EasyLoading.dismiss(); + } + } + + // |下单|点击购买 + Future doPay() async { + final validShopCar = _validateShopCar(); + if (!validShopCar) { + await selectPayTypeDialog( + paymentEnabled: false, + disabledMessage: '请先添加商品,再选择支付方式', + ); + return; + } + // 创建本地订单 + await createSqlOrder(); + await selectPayTypeDialog(); + } + + // |下单|点击取消下单 + Future doCancelPay({bool cancelShop = true}) async { + if (cancelShop) await _clearShopCard(); + sendSubScreen(); + } + + // |下单|点击更多 + Future doMore() async { + final selectType = await selectOrderMoreDialog(); + // 未选择 + if (ObjectUtil.isEmpty(selectType)) return; + switch (selectType) { + case OrderMoreTypeEnum.addHoldBill: + // 加入挂单 + await addHoldBill(); + break; + case OrderMoreTypeEnum.holdBillList: + // 挂单列表 + await holdBillList(); + break; + default: + break; + } + } + +// ************************************************************************** +// 更多 start +// ************************************************************************** + // 加入挂单 + Future addHoldBill() async { + final validShopCar = _validateShopCar(); + if (!validShopCar) return; + // 加入挂单数据库 + final holdBillDao = appC.dataBase.getOrderHoldBillDao; + final holdBill = OrderHoldBillEntity( + sysType: SysType.SUPERMARKET, + carListJson: jsonEncode( + shopCardList.map((e) => e.toJson()).toList(), + ), + userJson: + payUser.value != null ? jsonEncode(payUser.value!.toJson()) : null, + price: totalPrice.value, + extraPrice: amountMealPrice.value, + payPrice: totalPrice.value, + createTime: DateTime.now(), + ); + await holdBillDao.save(holdBill); + Toast.success(msg: "已加入挂单列表"); + TtsManage.speak("已加入挂单列表"); + // 挂单是把当前交易移出工作区,保存后必须清空,避免继续支付同一批商品造成重复交易。 + await _clearShopCard(); + sendSubScreen(); + } + + // 挂单列表 + Future holdBillList() async { + final holdBill = await selectHoldBillDialog(); + if (ObjectUtil.isEmpty(holdBill)) return; + // 清空状态 + await _clearShopCard(); + // 使用挂单数据 + final carList = (jsonDecode(holdBill?.carListJson ?? '[]') as List) + .map((v) => ShopCardEntity.fromJson(v as Map)) + .toList(); + if (!ObjectUtil.isEmptyList(carList)) { + shopCardList.addAll(carList); + } + final userJson = holdBill?.userJson; + payUser.value = ObjectUtil.isEmpty(userJson) + ? null + : ServiceUser.fromJson( + jsonDecode(userJson!) as Map, + ); + amountMealPrice.value = holdBill?.extraPrice ?? 0; + computedTotalPrice(isSendSubScreen: false); + sendSubScreen(); + if (holdBill != null) { + await appC.dataBase.getOrderHoldBillDao.del(holdBill); + } + Toast.success(msg: "挂单已取回"); + } +// ************************************************************************** +// 更多 end +// ************************************************************************** + + // 分页 + Future doPage({int? page}) async { + EasyLoading.show( + status: '加载中...', + maskType: EasyLoadingMaskType.black, + ); + if (page != null) currentPage.value = page; + final codeText = selfCodeC.text; + if (ObjectUtil.isNotEmpty(codeText)) { + barCode = codeText; + } + // 收起键盘 + FocusManager.instance.primaryFocus?.unfocus(); + await getProduct(); + EasyLoading.dismiss(); + } + + // 重置 + Future doReset() async { + EasyLoading.show( + status: '加载中...', + maskType: EasyLoadingMaskType.black, + ); + currentPage.value = 1; + selfCodeC.clear(); + barCode = ""; + // 收起键盘 + FocusManager.instance.primaryFocus?.unfocus(); + await getProduct(); + EasyLoading.dismiss(); + } + + // 点击商品 + Future doProduct(SupermarketSpuEntity current) async { + final target = shopCardList.firstWhereOrNull((e) { + return e.productServiceUniqueCode == current.serviceUniqueCode; + }); + if (ObjectUtil.isEmpty(target)) { + final currentPrice = getProudctPrice(current); + final data = ShopCardEntity( + orderId: -1, + productServiceUniqueCode: current.serviceUniqueCode, + productName: current.productName, + productPic: current.picUrl ?? "", + productPrice: currentPrice, + nature: current.productNature, + productNumber: current.productNature == ProductNature.WEIGHT ? 0 : 1, + productWidget: 0, + totalPrice: currentPrice, + // todo 超市暂时用不到这个字段 + isPriceIncreased: false, + ); + if (data.nature == ProductNature.WEIGHT) { + data.totalPrice = 0; + } + shopCardList.add(data); + } else { + // 称重商品在购物车就返回 + if (target!.nature == ProductNature.WEIGHT) { + return; + } + // 旧商品 + target.productNumber += 1; + target.totalPrice = _computedProductTotalPrice(target); + } + shopCardList.refresh(); + computedTotalPrice(); + } + + // |+|或者|-|点击更改商品数量 + void doComputedInputChange(int number, String productCard) async { + final current = shopCardList + .firstWhere((item) => item.productServiceUniqueCode == productCard); + // 称重商品在购物车就直接删除 + if (current.nature == ProductNature.WEIGHT) { + final shopCard = _findShopProductByCode(current.productServiceUniqueCode); + if (shopCard != null) { + shopCardList.remove(shopCard); + } + } else if (current.nature == ProductNature.DEFAULT) { + current.productNumber = number; + current.totalPrice = _computedProductTotalPrice(current); + if (current.productNumber == 0) { + shopCardList.removeWhere((e) { + return e.productServiceUniqueCode == current.productServiceUniqueCode; + }); + } + } + shopCardList.refresh(); + computedTotalPrice(); + sendSubScreen(); + } + + // |购买商品|点击称重 + Future computedPanelWidget(double widget, String productCode) async { + final current = shopCardList + .firstWhere((item) => item.productServiceUniqueCode == productCode); + // 直接查数据库 + final skuDao = appC.dataBase.getSupermarketSkuDao; + final targetProduct = await skuDao.getBySkuCode( + current.productServiceUniqueCode, + ); + if (targetProduct == null) { + Toast.error(msg: "查询不到该商品"); + return; + } + if (widgetProduct.value != null) { + if (widgetProduct.value!.serviceUniqueCode == + current.productServiceUniqueCode) { + Toast.error(msg: "该商品已在称重"); + return; + } + } + widgetProduct.value = targetProduct; + final supermarketC = Get.find(); + final isOpen = supermarketC.open(targetProduct); + if (!isOpen) { + Toast.error(msg: "连接电子称错误"); + return; + } else { + widgetProduct.value = targetProduct; + } + } + +// ************************************************************************** +// 支付的回调 start +// ************************************************************************** + + void _listenPayTypeSelectCallback() { + final payTypeSelect = Get.find(); + // |支付方式|点击|开始识别| + payTypeSelect.onRecognition = () async {}; + // 识别出用户的回调 + payTypeSelect.onDiscernUser = (payUser) { + if (payUser == null) return; + this.payUser.value = payUser; + sendSubScreen(); + }; + // |支付方式|点击|确定| + payTypeSelect.onSubmit = ( + user, + orderType, + orderPayType, + orderPayDisplayType, + qrCode, + ) async { + final log = + "|$_tag|回调,识别到用户:|${user.toJson()}|orderType:|$orderType|;orderPayType:|$orderPayType|;orderPayDisplayType|$orderPayDisplayType|;"; + logger.i(log); + this.orderType = orderType; + this.orderPayType.value = orderPayType; + this.orderPayDisplayType.value = orderPayDisplayType; + this.globalQrCode = qrCode; + payUser.value = user; + final validPayUser = _validatePayUser(); + if (!validPayUser) return; + final validShopCar = _validateShopCar(); + if (!validShopCar) return; + EasyLoading.show( + status: '支付中...', + maskType: EasyLoadingMaskType.black, + ); + await updateSqlOrder(); + await createApiOrder(); + EasyLoading.dismiss(); + _clearShopCard(); + putOffSendSubScreen(); + }; + // |支付方式|点击|取消| + payTypeSelect.onCancel = () {}; + + // |"二维码扫一扫支付"点击"开始识别"| + payTypeSelect.onQrCodeScanRecognition = (onOrderCreate) async { + orderType = OrderType.BELOW; + orderPayType.value = OrderPayType.WALLET_QR_CODE_SCAN; + orderPayDisplayType.value = OrderPayDisplayType.qrCodeScan; + final validShopCar = _validateShopCar(); + if (!validShopCar) return; + final orderRes = + await createApiOrder(); + TtsManage.speak("请扫描屏幕二维码"); + await onOrderCreate.call(orderRes!); + }; + + // |"二维码扫一扫支付" "支付成功"| + payTypeSelect.onQrCodeScanPaySuccess = (payUser) async { + if (payUser != null) this.payUser.value = payUser; + final validShopCar = _validateShopCar(); + if (!validShopCar) return; + // 总金额 + final price = AppPriceTool.fenToYuan(totalPrice.value); + await createSqlOrder(); + await updateSqlOrder(); + _clearShopCard(); + putOffSendSubScreen(); + TtsManage.speak("购买成功总金额$price元"); + Toast.success(msg: "购买成功"); + }; + } + +// ************************************************************************** +// 支付的回调 start +// ************************************************************************** + +// ************************************************************************** +// 称重的回调 start +// ************************************************************************** + + void _listenSteelyardCallback() { + final supermarketC = Get.find(); + // |称重|点击|开始称重| + supermarketC.onWeightStart = () {}; + // |称重|点击|确定重量| + supermarketC.onWeightSubmit = () { + final supermarketC = Get.find(); + final targetCode = widgetProduct.value?.serviceUniqueCode; + final targetShopProduct = shopCardList.firstWhereOrNull( + (v) => v.productServiceUniqueCode == targetCode, + ); + if (targetShopProduct != null) { + targetShopProduct.productWidget = supermarketC.widget.value; + targetShopProduct.totalPrice = supermarketC.price.value; + shopCardList.refresh(); + computedTotalPrice(); + } + supermarketC.close(); + widgetProduct.value = null; + }; + // |称重|点击|取消| + supermarketC.onWeightCancel = () { + supermarketC.close(); + widgetProduct.value = null; + }; + } + +// ************************************************************************** +// 称重的回调 end +// ************************************************************************** + +// ************************************************************************** +// 扫码枪的回调 start +// ************************************************************************** + + Future _listenBarCodeCallback() async { + BarcodeScanManage.instance.startListening(); + BarcodeScanManage.instance.gunScanStream.listen((event) async { + await _handleBarCode(event); + }); + if (AppRuntimeTool.useManualHardwareInput) { + await doManualScan(); + } + } + + Future doManualScan() async { + final code = await appHardwareInputDialog( + Get.context!, + scene: HardwareInputScene.scanCode, + description: "请输入商品条码或用户付款码,提交后按扫码枪结果处理。", + ); + if (ObjectUtil.isEmpty(code)) return; + BarcodeScanManage.instance.addManualGunCode(code!); + } + + Future _handleBarCode(String event) async { + if (ObjectUtil.isEmpty(event)) return; + // 判断是否为支付宝、微信或者钱包付款码。 + if (payQrCode(event)) { + await handlePayQrCode(event); + return; + } + final skuDao = appC.dataBase.getSupermarketSkuDao; + SupermarketSpuEntity? scanProduct = await skuDao.getByBarCode(event); + if (scanProduct == null) { + final codeDao = appC.dataBase.getSupermarketCodeDao; + scanProduct = await codeDao.getByBarCode(event); + } + if (scanProduct == null) { + TtsManage.speak("扫码商品未知"); + return; + } + if (scanProduct.productNature == ProductNature.WEIGHT) { + final target = _findShopProductIndexByCode( + scanProduct.serviceUniqueCode, + ); + if (target != -1) { + await computedPanelWidget(0, scanProduct.serviceUniqueCode); + return; + } + } + TtsManage.speak("扫码商品成功"); + await doProduct(scanProduct); + } + + // 判断是否为支付包或者微信或者钱包二维码 + bool payQrCode(String event) { + if (event.length == 18 && RegExp(r'^(2[5-9]|30)').hasMatch(event)) { + // 支付宝支付 + return true; + } else if (event.length == 18 && event.startsWith("13")) { + // 微信支付 + return true; + } else if (event.length == 32) { + // 钱包二维码支付 + return true; + } + return false; + } + + // 处理扫码枪支付 + Future handlePayQrCode(String event) async { + OrderPayType orderPayType = OrderPayType.WECHANT_SELF_QR_CODE_PAY; + + if (event.length == 18 && RegExp(r'^(2[5-9]|30)').hasMatch(event)) { + // 支付宝支付 + orderPayType = OrderPayType.ALI_SELF_QR_CODE_PAY; + } else if (event.length == 18 && event.startsWith("13")) { + // 微信支付 + orderPayType = OrderPayType.WECHANT_SELF_QR_CODE_PAY; + } else if (event.length == 32) { + // 钱包二维码支付 + orderPayType = OrderPayType.WALLET_QR_CODE; + } + // 创建本地订单 + this.orderType = OrderType.BELOW; + this.orderPayType.value = orderPayType; + this.orderPayDisplayType.value = OrderPayDisplayType.qrCode; + this.globalQrCode = ServiceQRCode( + id: event, + itemId: "-1", + typeId: "-1", + userId: "-1", + orderType: OrderType.BELOW, + payType: orderPayType, + exceedTime: -1, + extra: "-1", + ); + payUser.value = AppUserTool.generateExternalUser(); + final validShopCar = _validateShopCar(); + if (!validShopCar) return; + EasyLoading.show( + status: '支付中...', + maskType: EasyLoadingMaskType.black, + ); + try { + await createSqlOrder(); + await updateSqlOrder(); + await createApiOrder(); + } catch (error, stack) { + logger.e("|$_tag|,处理扫码枪支付失败|$error|stack|$stack|"); + } finally { + EasyLoading.dismiss(); + _clearShopCard(); + putOffSendSubScreen(); + } + } + +// ************************************************************************** +// 扫码枪的回调 end +// ************************************************************************** + + // 根据商品code查找购物车商品 + ShopCardEntity? _findShopProductByCode(String code) { + return shopCardList.firstWhereOrNull((e) { + return e.productServiceUniqueCode == code; + }); + } + + // 根据商品code查找购物车商品的下标 + int _findShopProductIndexByCode(String code) { + return shopCardList.indexWhere((e) { + return e.productServiceUniqueCode == code; + }); + } + + // 清空购物车 + Future _clearShopCard() async { + shopCardList.clear(); + payUser.value = null; + totalPrice.value = 0; + totalProduct.value = 0; + amountMealPrice.value = 0; + } + + // 校验支付用户 + bool _validatePayUser() { + bool flag = true; + if (orderPayDisplayType.value == null) return false; + final isOffline = OrderPayDisplayType.isOffline(orderPayDisplayType.value!); + if (isOffline) return true; + + if (ObjectUtil.isEmpty(payUser.value?.walletBalance)) { + TtsManage.speak("用户钱包不存在"); + Toast.error(msg: "校验购物车失败,用户钱包不存在"); + return false; + } + if (payUser.value!.walletBalance! < totalPrice.value) { + TtsManage.speak("用户余额不足"); + Toast.error(msg: "校验购物车失败,用户余额不足"); + return false; + } + return flag; + } + + // 校验购物车 + bool _validateShopCar() { + bool flag = true; + if (ObjectUtil.isEmptyList(shopCardList)) { + TtsManage.speak("校验购物车失败,商品为空"); + return false; + } + for (final v in shopCardList) { + if (v.nature == ProductNature.DEFAULT) { + if (v.productNumber <= 0) { + TtsManage.speak("校验购物车失败,未选择商品"); + return false; + } + } else if (v.nature == ProductNature.WEIGHT) { + if (v.productWidget <= 0) { + TtsManage.speak("校验购物车失败,商品未称重"); + return false; + } + } + } + return flag; + } + + // 计算单个的价格 + int _computedProductTotalPrice(ShopCardEntity product) { + if (product.nature == ProductNature.DEFAULT) { + return product.productPrice * product.productNumber; + } else if (product.nature == ProductNature.WEIGHT) { + return (product.productPrice * product.productWidget).ceil(); + } else { + return 0; + } + } + + // 获取当前商品的价格,有促销价先用促销价 + int getProudctPrice(SupermarketSpuEntity product) { + if (product.promotionPrice != 0) { + return product.promotionPrice; + } + return product.retailPrice; + } +} diff --git a/lib/pages/supermarket_order/view.dart b/lib/pages/supermarket_order/view.dart new file mode 100644 index 0000000..07173e0 --- /dev/null +++ b/lib/pages/supermarket_order/view.dart @@ -0,0 +1,103 @@ +import 'package:ducafe_ui_core/ducafe_ui_core.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +import 'controller.dart'; + +class SupermarketOrderPage extends GetView { + const SupermarketOrderPage({super.key}); + + @override + Widget build(BuildContext context) { + return GetBuilder( + init: Get.find(), + id: 'supermarket_order', + builder: (_) => TerminalShell( + title: '智慧收银', + activeNav: TerminalNavType.cashier, + child: Obx(() { + final productEntities = controller.supermarketSpuList.toList(); + final products = productEntities.map(_toProductData).toList(); + final weighing = controller.widgetProduct.value != null; + return CashierWorkspace( + categories: controller.classifyList.toList(), + selectedCategory: controller.currentTabIndex, + onCategoryChanged: controller.doTab, + products: products, + onProductTap: (product) { + final entity = productEntities.firstWhere( + (item) => item.serviceUniqueCode == product.id, + ); + controller.doProduct(entity); + }, + cartItems: controller.shopCardList.toList(), + user: controller.payUser.value, + totalPrice: controller.totalPrice.value, + extraPrice: controller.amountMealPrice.value, + productCount: controller.totalProduct.value, + onQuantityChanged: (item, quantity) => + controller.doComputedInputChange( + quantity, + item.productServiceUniqueCode, + ), + onRemoveCartItem: (item) => controller.doComputedInputChange( + 0, + item.productServiceUniqueCode, + ), + onWeight: (item) => controller.computedPanelWidget( + item.productWidget, + item.productServiceUniqueCode, + ), + onClearCart: controller.doCancelPay, + onPay: controller.doPay, + onCancel: controller.doCancelPay, + onHold: controller.addHoldBill, + onMore: controller.doMore, + searchController: controller.selfCodeC, + searchFocusNode: controller.focusNode, + onSearch: controller.doPage, + onReset: controller.doReset, + onScan: AppRuntimeTool.useManualHardwareInput + ? controller.doManualScan + : null, + showScaleCard: true, + productAreaReplacement: weighing + ? TerminalSurface( + padding: EdgeInsets.all(16.w), + child: steelyardContainer(context), + ) + : null, + totalProductCount: controller.totalPage.value, + currentPage: controller.currentPage.value, + pageSize: controller.pageSize.value, + onPageChanged: (page) => controller.doPage(page: page), + ); + }), + ), + ); + } + + CashierProductData _toProductData(SupermarketSpuEntity product) { + return CashierProductData( + id: product.serviceUniqueCode, + name: product.productName, + image: product.picUrl, + price: product.retailPrice, + promotionPrice: product.promotionPrice, + barcode: product.barCode ?? product.productCode, + stock: _stockText(product), + badge: product.productNature == ProductNature.WEIGHT ? '称重商品' : null, + ); + } + + String _stockText(SupermarketSpuEntity product) { + final stock = product.stock; + if (stock == null) return '未知'; + final value = stock == stock.roundToDouble() + ? stock.toInt().toString() + : stock.toStringAsFixed(2); + return '$value ${product.unit}'; + } +} diff --git a/lib/pages/take_food/controller.dart b/lib/pages/take_food/controller.dart new file mode 100644 index 0000000..1ce813f --- /dev/null +++ b/lib/pages/take_food/controller.dart @@ -0,0 +1,203 @@ +import 'package:common_utils/common_utils.dart'; +import 'package:flutter_easyloading/flutter_easyloading.dart'; +import 'package:flutter_subscreen_plugin/flutter_subscreen_plugin.dart'; +import 'package:get/get.dart'; +import 'package:pj_supermarket_cloud/common/index.dart'; + +class TakeFoodController extends GetxController { + final appC = Get.find(); + + Rxn payUser = Rxn(ServiceUser()); + + RxList carList = [].obs; + + OrderPayType orderPayType = OrderPayType.RESERVE_QR_CODE; + + OrderType orderType = OrderType.RESERVE; + + int totalPrice = 0; + + String? orderId; + + ServiceQRCode? globalQrCode; + + _initData() { + update(["take_food"]); + } + + @override + void onInit() { + super.onInit(); + AppSubScreenTool.show(); + SubScreenPlugin.sendMsgToViceScreen(SubScreenAction.jumpSubPage.name); + // 开启扫码功能 + startQrcode(); + } + + @override + void onReady() { + super.onReady(); + _initData(); + } + + @override + void onClose() { + super.onClose(); + BarcodeScanManage.instance.dispose(); + } + + doCancelPay() { + orderId = null; + globalQrCode = null; + orderPayType = OrderPayType.RESERVE_QR_CODE; + payUser.value = ServiceUser(); + payUser.refresh(); + carList.value = []; + } + + doPay() async { + payUser.refresh(); + await orderQrCodeCancel(); + } + + /// 获取订单详情 + Future getOrderDetail() async { + if (ObjectUtil.isEmpty(orderId)) return; + final pa = {"id": orderId}; + try { + final data = await HardApi.getOrder(pa); + if (data == null) throw Error(); + if (ObjectUtil.isEmpty(data)) return; + payUser.value?.username = data.username; + payUser.value?.userId = data.userId; + payUser.value?.userAvatar = data.userAvatar; + payUser.refresh(); + await getCarList(); + } catch (e) { + logger.e("获取订单详情错误error:|$e|"); + await TtsManage.speak("订单不存在"); + } + } + + /// 获取购物车详情 + Future getCarList() async { + if (ObjectUtil.isEmpty(orderId)) return; + final pa = {"orderId": orderId}; + try { + final data = await HardApi.getCarByOrderId(pa); + if (data == null) throw Error(); + carList.value = data; + senSubScreen(); + } catch (e, stack) { + logger.e("获取购物车详情错误error:|$e|stack:|$stack|"); + } + } + + /// 开启扫码 + Future startQrcode() async { + try { + // 监听二维码测试 + BarcodeScanManage.instance.startListening(); + BarcodeScanManage.instance.qrcodeScanStream.listen((event) async { + final serviceCode = await BarcodeScanManage.instance.getCodeInfo(event); + if (serviceCode == null) return; + if (serviceCode.payType == OrderPayType.RESERVE_QR_CODE) { + orderPayType = OrderPayType.RESERVE_QR_CODE; + globalQrCode = serviceCode; + orderId = serviceCode.id; + await getOrderDetail(); + } else if (serviceCode.payType == OrderPayType.ACT_CANCEL) { + orderPayType = OrderPayType.ACT_CANCEL; + globalQrCode = serviceCode; + orderId = serviceCode.id; + // 查询核销活动 + final act = await WalletApi.getComAct({ + "actItemId": serviceCode.id, + "userId": serviceCode.userId, + }); + if (act == null) { + await TtsManage.speak("核销活动不存在"); + return; + } + // 更新用户 + payUser.value?.username = act.username; + payUser.value?.userId = act.userId; + payUser.value?.userAvatar = act.userAvatar; + payUser.refresh(); + // 生成简单购物车 + carList.value = [ + ServiceCar(productName: "生日活动核销", productPrice: 0, productCount: 1) + ]; + senSubScreen(); + } else { + await TtsManage.speak("无效的二维码"); + } + return; + }); + } catch (e) { + doCancelPay(); + logger.e("开启扫码错误error:|$e|"); + } + } + + /// 订单核销 + Future orderQrCodeCancel() async { + if (ObjectUtil.isEmpty(orderId)) { + Toast.error(msg: "请前往设置绑定设备"); + return; + } + final appSetting = await AppSettingTool.getSysSetting(); + final serviceId = appSetting.serviceId; + if (ObjectUtil.isEmpty(serviceId)) { + Toast.error(msg: "请前往设置绑定设备"); + return; + } + EasyLoading.show( + status: '核销中...', + maskType: EasyLoadingMaskType.black, + ); + disposeOrderQrCodeCancel( + serviceId: serviceId!, + userId: payUser.value!.userId!, + ); + } + + Future disposeOrderQrCodeCancel({ + required String serviceId, + required String userId, + }) async { + try { + // 线上订餐二维码支付 + if (orderPayType == OrderPayType.RESERVE_QR_CODE) { + final pa = { + "orderId": orderId, + "hardwareId": serviceId, + }; + final flag = await HardApi.cancelQrCode(pa); + if (flag == false) throw Error(); + } + // 活动核销 + if (orderPayType == OrderPayType.ACT_CANCEL) { + final pa = { + "actItemId": orderId, + "userId": userId, + "hardwareId": serviceId, + }; + final flag = await HardApi.cancelActQrCode(pa); + if (flag == false) throw Error(); + } + EasyLoading.dismiss(); + Toast.success(msg: "核销成功"); + TtsManage.speak("核销成功"); + doCancelPay(); + } catch (e, stack) { + logger.e("核销订单orderType:|${orderPayType.title}|error:|$e|stack:|$stack|"); + EasyLoading.dismiss(); + } + } + + /// 发送给父屏订单信息 + void senSubScreen() { + if (ObjectUtil.isEmpty(carList)) return; + } +} diff --git a/lib/pages/take_food/view.dart b/lib/pages/take_food/view.dart new file mode 100644 index 0000000..4997194 --- /dev/null +++ b/lib/pages/take_food/view.dart @@ -0,0 +1,578 @@ +import 'package:ducafe_ui_core/ducafe_ui_core.dart'; +import 'package:flutter/material.dart'; +import 'package:forui/forui.dart'; +import 'package:get/get.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +import 'controller.dart'; + +class TakeFoodPage extends GetView { + const TakeFoodPage({super.key}); + + @override + Widget build(BuildContext context) { + return GetBuilder( + id: 'take_food', + builder: (_) => TerminalShell( + title: '核销', + activeNav: TerminalNavType.verification, + child: Column( + children: [ + _VerificationHeader(controller: controller), + SizedBox(height: XfjDesignTokens.cardGap.h), + Expanded( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded(child: _VerificationDetail(controller: controller)), + SizedBox(width: XfjDesignTokens.cardGap.w), + SizedBox( + width: 430.w, + child: _VerificationSidebar(controller: controller), + ), + ], + ), + ), + ], + ), + ), + ); + } +} + +class _VerificationHeader extends StatelessWidget { + const _VerificationHeader({required this.controller}); + + final TakeFoodController controller; + + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + return TerminalSurface( + height: 104.h, + padding: EdgeInsets.symmetric(horizontal: 22.w, vertical: 12.h), + child: Row( + children: [ + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '取餐与活动核销', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.typography.xl.copyWith( + color: XfjDesignTokens.ink, + fontWeight: FontWeight.w800, + height: 1.1, + ), + ), + SizedBox(height: 8.h), + Text( + '扫描线上订餐二维码或活动核销码,核对后完成核销', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.typography.xs.copyWith( + color: XfjDesignTokens.muted, + height: 1.1, + ), + ), + ], + ), + ), + SizedBox(width: 24.w), + Container( + width: 320.w, + height: 76.h, + padding: EdgeInsets.symmetric(horizontal: 18.w), + decoration: BoxDecoration( + color: XfjDesignTokens.primarySoft, + borderRadius: + BorderRadius.circular(XfjDesignTokens.controlRadius), + ), + child: Row( + children: [ + FIcon(FAssets.icons.scan, color: XfjDesignTokens.primary), + SizedBox(width: 12.w), + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '扫码器监听中', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.typography.sm.copyWith( + color: XfjDesignTokens.primary, + fontWeight: FontWeight.w700, + height: 1.1, + ), + ), + SizedBox(height: 6.h), + Text( + '支持订餐二维码、活动核销码', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.typography.xs.copyWith( + color: XfjDesignTokens.muted, + height: 1.05, + ), + ), + ], + ), + ), + ], + ), + ), + ], + ), + ); + } +} + +class _VerificationDetail extends StatelessWidget { + const _VerificationDetail({required this.controller}); + + final TakeFoodController controller; + + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + return TerminalSurface( + clipBehavior: Clip.antiAlias, + child: Obx(() { + final items = controller.carList; + final total = + items.fold(0, (sum, item) => sum + (item.totalPrice ?? 0)); + return Column( + children: [ + Container( + height: 70.h, + padding: EdgeInsets.symmetric(horizontal: 22.w), + decoration: const BoxDecoration( + border: Border(bottom: BorderSide(color: XfjDesignTokens.line)), + ), + child: Row( + children: [ + Text( + '待核销明细', + style: theme.typography.lg.copyWith( + fontWeight: FontWeight.w700, + height: 1.1, + ), + ), + if (items.isNotEmpty) ...[ + SizedBox(width: 12.w), + _VerificationBadge( + label: controller.orderPayType == OrderPayType.ACT_CANCEL + ? '活动核销' + : '线上订餐', + ), + ], + const Spacer(), + if (controller.orderId?.isNotEmpty == true) + Text( + '订单号:${_maskOrderId(controller.orderId!)}', + style: theme.typography.sm.copyWith( + color: XfjDesignTokens.muted, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + ], + ), + ), + if (items.isEmpty) + const Expanded(child: _VerificationEmpty()) + else ...[ + Container( + height: 56.h, + color: const Color(0xFFFAFBFC), + padding: EdgeInsets.symmetric(horizontal: 28.w), + child: const Row( + children: [ + Expanded(flex: 5, child: Text('物品名称')), + Expanded(flex: 2, child: Center(child: Text('数量'))), + Expanded( + flex: 3, + child: Align( + alignment: Alignment.centerRight, + child: Text('单价金额'))), + Expanded( + flex: 3, + child: Align( + alignment: Alignment.centerRight, + child: Text('总金额'))), + ], + ), + ), + Expanded( + child: ListView.separated( + itemCount: items.length, + separatorBuilder: (_, __) => const Divider( + height: 1, + color: XfjDesignTokens.line, + ), + itemBuilder: (context, index) { + final item = items[index]; + return SizedBox( + height: 96.h, + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 28.w), + child: Row( + children: [ + Expanded( + flex: 5, + child: Text( + item.productName ?? '未命名物品', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.typography.lg.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ), + Expanded( + flex: 2, + child: + Center(child: Text('${item.productCount}')), + ), + Expanded( + flex: 3, + child: Align( + alignment: Alignment.centerRight, + child: appPrice(context, item.productPrice), + ), + ), + Expanded( + flex: 3, + child: Align( + alignment: Alignment.centerRight, + child: DefaultTextStyle.merge( + style: const TextStyle( + fontWeight: FontWeight.w700), + child: appPrice(context, item.totalPrice), + ), + ), + ), + ], + ), + ), + ); + }, + ), + ), + Container( + height: 82.h, + padding: EdgeInsets.symmetric(horizontal: 28.w), + decoration: const BoxDecoration( + color: Color(0xFFFAFBFC), + border: Border(top: BorderSide(color: XfjDesignTokens.line)), + ), + child: Row( + children: [ + Text( + '共 ${items.fold(0, (sum, item) => sum + (item.productCount ?? 0))} 件物品', + style: theme.typography.sm + .copyWith(color: XfjDesignTokens.muted), + ), + const Spacer(), + Text('合计', style: theme.typography.lg), + SizedBox(width: 20.w), + Text( + '¥ ${(total / 100).toStringAsFixed(2)}', + style: theme.typography.xl3.copyWith( + color: XfjDesignTokens.primary, + fontWeight: FontWeight.w800, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + ], + ), + ), + ], + ], + ); + }), + ); + } +} + +class _VerificationSidebar extends StatelessWidget { + const _VerificationSidebar({required this.controller}); + + final TakeFoodController controller; + + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + return Column( + children: [ + Obx(() { + final username = controller.payUser.value?.username; + final recognized = username?.isNotEmpty == true; + return TerminalSurface( + height: 132.h, + padding: EdgeInsets.symmetric(horizontal: 18.w, vertical: 14.h), + child: Row( + children: [ + Container( + width: 72.w, + height: 72.w, + decoration: const BoxDecoration( + color: XfjDesignTokens.primarySoft, + shape: BoxShape.circle, + ), + child: + FIcon(FAssets.icons.user, color: XfjDesignTokens.primary), + ), + SizedBox(width: 14.w), + Expanded( + child: FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerLeft, + child: SizedBox( + width: 220.w, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + recognized ? username! : '暂未识别用户', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.typography.lg.copyWith( + fontWeight: FontWeight.w800, + height: 1.1, + ), + ), + SizedBox(height: 8.h), + Text( + recognized ? '用户信息已识别' : '扫描二维码后显示用户信息', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.typography.xs.copyWith( + color: XfjDesignTokens.muted, + height: 1.05, + ), + ), + ], + ), + ), + ), + ), + if (recognized) + const _VerificationBadge(label: '已识别', success: true), + ], + ), + ); + }), + SizedBox(height: XfjDesignTokens.cardGap.h), + Expanded( + child: TerminalSurface( + padding: EdgeInsets.all(20.w), + child: Obx(() { + final ready = controller.carList.isNotEmpty; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + '核销信息', + style: theme.typography.lg + .copyWith(fontWeight: FontWeight.w800), + ), + SizedBox(height: 20.h), + _InfoRow( + label: '核销类型', + value: controller.orderPayType == OrderPayType.ACT_CANCEL + ? '活动核销码' + : '线上订餐二维码', + ), + SizedBox(height: 12.h), + _InfoRow( + label: '订单状态', + value: ready ? '待核销' : '等待扫码', + success: ready, + ), + SizedBox(height: 12.h), + _InfoRow( + label: '物品数量', value: '${controller.carList.length} 项'), + SizedBox(height: 12.h), + const _InfoRow(label: '扫码状态', value: '监听中', success: true), + SizedBox(height: 24.h), + Container( + padding: EdgeInsets.all(16.w), + decoration: BoxDecoration( + color: const Color(0xFFFFF9E8), + border: Border.all( + color: XfjDesignTokens.warning.withValues(alpha: 0.35), + ), + borderRadius: + BorderRadius.circular(XfjDesignTokens.controlRadius), + ), + child: Text.rich( + TextSpan( + children: [ + const TextSpan( + text: '核销前请确认\n', + style: TextStyle(fontWeight: FontWeight.w700), + ), + TextSpan( + text: '商品、数量和用户信息无误。确认后该二维码不可重复核销。', + style: TextStyle( + color: XfjDesignTokens.muted, + height: 1.45, + ), + ), + ], + ), + maxLines: 4, + overflow: TextOverflow.ellipsis, + style: theme.typography.xs, + ), + ), + const Spacer(), + Row( + children: [ + Expanded( + child: TerminalToolbarButton( + label: '取消', + style: TerminalToolbarButtonStyle.danger, + onTap: ready ? controller.doCancelPay : null, + ), + ), + SizedBox(width: 12.w), + Expanded( + child: TerminalToolbarButton( + label: '确认核销', + onTap: ready ? controller.doPay : null, + ), + ), + ], + ), + ], + ); + }), + ), + ), + ], + ); + } +} + +class _VerificationEmpty extends StatelessWidget { + const _VerificationEmpty(); + + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 112.w, + height: 112.w, + decoration: const BoxDecoration( + color: XfjDesignTokens.primarySoft, + shape: BoxShape.circle, + ), + child: FIcon(FAssets.icons.qrCode, + size: 60.w, color: XfjDesignTokens.primary), + ), + SizedBox(height: 24.h), + Text( + '请扫描核销二维码', + style: theme.typography.xl.copyWith(fontWeight: FontWeight.w700), + ), + SizedBox(height: 8.h), + Text( + '识别成功后将在此显示用户与商品明细', + style: theme.typography.sm.copyWith(color: XfjDesignTokens.muted), + ), + ], + ), + ); + } +} + +class _InfoRow extends StatelessWidget { + const _InfoRow( + {required this.label, required this.value, this.success = false}); + + final String label; + final String value; + final bool success; + + @override + Widget build(BuildContext context) => Container( + constraints: BoxConstraints(minHeight: 58.h), + padding: EdgeInsets.symmetric(horizontal: 16.w), + decoration: BoxDecoration( + color: const Color(0xFFF7F8FA), + borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), + ), + child: Row( + children: [ + Expanded( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: FTheme.of(context) + .typography + .xs + .copyWith(color: XfjDesignTokens.muted), + ), + ), + SizedBox(width: 12.w), + Flexible( + child: Text( + value, + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.right, + style: FTheme.of(context).typography.xs.copyWith( + color: success + ? XfjDesignTokens.success + : XfjDesignTokens.ink, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ); +} + +class _VerificationBadge extends StatelessWidget { + const _VerificationBadge({required this.label, this.success = false}); + + final String label; + final bool success; + + @override + Widget build(BuildContext context) => Container( + padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 6.h), + decoration: BoxDecoration( + color: + success ? const Color(0xFFECFBF3) : XfjDesignTokens.primarySoft, + borderRadius: BorderRadius.circular(999), + ), + child: Text( + label, + style: FTheme.of(context).typography.sm.copyWith( + color: + success ? XfjDesignTokens.success : XfjDesignTokens.primary, + fontWeight: FontWeight.w600, + ), + ), + ); +} + +String _maskOrderId(String value) { + if (value.length <= 4) return value; + return '********${value.substring(value.length - 4)}'; +} diff --git a/lib/sub_screen/common/index.dart b/lib/sub_screen/common/index.dart new file mode 100644 index 0000000..095647d --- /dev/null +++ b/lib/sub_screen/common/index.dart @@ -0,0 +1,9 @@ +library sub_common; + +export './key/index.dart'; + +export './routers/index.dart'; + +export './utils/index.dart'; + +export './values/constants.dart'; diff --git a/lib/sub_screen/common/key/index.dart b/lib/sub_screen/common/key/index.dart new file mode 100644 index 0000000..2a31b4d --- /dev/null +++ b/lib/sub_screen/common/key/index.dart @@ -0,0 +1,3 @@ +library sub_key; + +export './navigator_key.dart'; diff --git a/lib/sub_screen/common/key/navigator_key.dart b/lib/sub_screen/common/key/navigator_key.dart new file mode 100644 index 0000000..5b78357 --- /dev/null +++ b/lib/sub_screen/common/key/navigator_key.dart @@ -0,0 +1,8 @@ +import 'package:flutter/cupertino.dart'; + +import '../routers/index.dart'; + +abstract class SubNavigatorKey { + static GlobalKey navigatorKey = GlobalKey(); + static String currentRouteName = SubRouteNames.root; +} diff --git a/lib/sub_screen/common/routers/index.dart b/lib/sub_screen/common/routers/index.dart new file mode 100644 index 0000000..af1bbc6 --- /dev/null +++ b/lib/sub_screen/common/routers/index.dart @@ -0,0 +1,3 @@ +library sub_router; + +export './names.dart'; diff --git a/lib/sub_screen/common/routers/names.dart b/lib/sub_screen/common/routers/names.dart new file mode 100644 index 0000000..992d59f --- /dev/null +++ b/lib/sub_screen/common/routers/names.dart @@ -0,0 +1,7 @@ +class SubRouteNames { + /// 商品页面 + static const subProduct = '/sub_product'; + + /// 首页 + static const root = '/'; +} diff --git a/lib/sub_screen/common/utils/index.dart b/lib/sub_screen/common/utils/index.dart new file mode 100644 index 0000000..e6859c3 --- /dev/null +++ b/lib/sub_screen/common/utils/index.dart @@ -0,0 +1,3 @@ +library sub_utils; + +export './sub_page_tool.dart'; diff --git a/lib/sub_screen/common/utils/sub_page_tool.dart b/lib/sub_screen/common/utils/sub_page_tool.dart new file mode 100644 index 0000000..1dae746 --- /dev/null +++ b/lib/sub_screen/common/utils/sub_page_tool.dart @@ -0,0 +1,40 @@ +import 'package:flutter/material.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; +import 'package:pj_supermarket_cloud/sub_screen/common/index.dart'; + +class SubPageToool { + static void switchJump( + SubScreenAction type, { + Object? arguments, + }) { + // 判断是否要跳转的页面就是当前页面 + switch (type) { + case SubScreenAction.jumpSubPage: + if (SubNavigatorKey.currentRouteName == SubRouteNames.subProduct) { + return; + } + logger.i("跳转到商品页面"); + SubNavigatorKey.currentRouteName = SubRouteNames.subProduct; + SubNavigatorKey.navigatorKey.currentState?.pushNamedAndRemoveUntil( + SubRouteNames.subProduct, + (Route route) => false, + arguments: arguments, + ); + break; + case SubScreenAction.jumpSubBack: + if (SubNavigatorKey.currentRouteName == SubRouteNames.root) { + return; + } + logger.i("跳转到欢迎页面"); + SubNavigatorKey.currentRouteName = SubRouteNames.root; + SubNavigatorKey.navigatorKey.currentState?.pushNamedAndRemoveUntil( + SubRouteNames.root, + (Route route) => false, + arguments: arguments, + ); + break; + default: + } + } +} diff --git a/lib/sub_screen/common/values/constants.dart b/lib/sub_screen/common/values/constants.dart new file mode 100644 index 0000000..e69de29 diff --git a/lib/sub_screen/common/values/index.dart b/lib/sub_screen/common/values/index.dart new file mode 100644 index 0000000..3a02abd --- /dev/null +++ b/lib/sub_screen/common/values/index.dart @@ -0,0 +1,3 @@ +library sub_values; + +export './constants.dart'; diff --git a/lib/sub_screen/page/index.dart b/lib/sub_screen/page/index.dart new file mode 100644 index 0000000..34523a0 --- /dev/null +++ b/lib/sub_screen/page/index.dart @@ -0,0 +1,4 @@ +library sub_page; + +export './sub_app.dart'; +export './sub_product.dart'; diff --git a/lib/sub_screen/page/sub_app.dart b/lib/sub_screen/page/sub_app.dart new file mode 100644 index 0000000..3b72df8 --- /dev/null +++ b/lib/sub_screen/page/sub_app.dart @@ -0,0 +1,72 @@ +import 'dart:async'; + +import 'package:ducafe_ui_core/ducafe_ui_core.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_subscreen_plugin/flutter_subscreen_plugin.dart'; +import 'package:forui/forui.dart'; +import 'package:pj_supermarket_cloud/common/index.dart'; + +import 'package:pj_supermarket_cloud/sub_screen/page/index.dart'; +import 'package:pj_supermarket_cloud/sub_screen/common/index.dart'; + +class SubAppPage extends StatefulWidget { + const SubAppPage({super.key}); + + @override + State createState() => _SubAppPageState(); +} + +class _SubAppPageState extends State { + late StreamSubscription _streamSubscription; + + @override + void initState() { + super.initState(); + _streamSubscription = SubScreenPlugin.viceStream.listen((event) async { + final action = SubScreenAction.values.fromCode(event.method); + if (action == null) return; + SubPageToool.switchJump(action, arguments: event.arguments); + if (action == SubScreenAction.updateCard && + SubNavigatorKey.currentRouteName != SubRouteNames.subProduct) { + SubPageToool.switchJump( + SubScreenAction.jumpSubPage, + arguments: event.arguments, + ); + } else { + SubPageToool.switchJump(action); + } + }); + } + + @override + void dispose() { + super.dispose(); + _streamSubscription.cancel(); + } + + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + return ScreenUtilInit( + designSize: const Size(1920, 1080), + minTextAdapt: true, + splitScreenMode: true, + builder: (_, child) { + return MaterialApp( + navigatorKey: SubNavigatorKey.navigatorKey, + debugShowCheckedModeBanner: false, + routes: { + SubRouteNames.subProduct: (context) => const SubProductPage(), + }, + home: Scaffold( + backgroundColor: Colors.white, + body: Text( + '欢迎使用', + style: theme.typography.xl6, + ).center(), + ), + ); + }, + ); + } +} diff --git a/lib/sub_screen/page/sub_product.dart b/lib/sub_screen/page/sub_product.dart new file mode 100644 index 0000000..3f1650f --- /dev/null +++ b/lib/sub_screen/page/sub_product.dart @@ -0,0 +1,280 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:ducafe_ui_core/ducafe_ui_core.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_subscreen_plugin/flutter_subscreen_plugin.dart'; +import 'package:forui/forui.dart'; +import 'package:data_table_2/data_table_2.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +class SubProductPage extends StatefulWidget { + const SubProductPage({super.key}); + + @override + State createState() => _SubProductPageState(); +} + +class _SubProductPageState extends State { + static const String _tag = "副屏购物车"; + + late StreamSubscription _streamSubscription; + + AppQrCodeDialogController qrCodeController = AppQrCodeDialogController(); + + // 购物车商品列表 + List _shopList = []; + + // 系统类型 + SysType _sysType = SysType.CANTEEN; + + // 支付用户 + ServiceUser? _payUser; + + // 购物车总价 + int _totalPrice = 0; + + // 额外支付 + int _amountMealPrice = 0; + + @override + void initState() { + super.initState(); + + // 获取上个页面的传参并监听变化 + WidgetsBinding.instance.addPostFrameCallback((_) { + final arguments = ModalRoute.of(context)?.settings.arguments; + if (arguments == null) return; + if (arguments is Map) { + if (arguments.keys.isEmpty) return; + final subScreenEvent = SubScreenProductEvent.fromJson(arguments); + setPageData(subScreenEvent); + return; + } + var convertedArgs = json.decode(json.encode(arguments)); + if (convertedArgs is Map) { + if (convertedArgs.keys.isEmpty) return; + final subScreenEvent = SubScreenProductEvent.fromJson(convertedArgs); + setPageData(subScreenEvent); + } + }); + + _streamSubscription = SubScreenPlugin.viceStream.listen((event) { + try { + dynamic params = event.arguments; + final action = event.method; + // 订单更新 + if (action == SubScreenAction.updateCard.name) { + if (params is! Map) { + params = json.decode(json.encode(params)); + } + final subScreenEvent = SubScreenProductEvent.fromJson(params); + setPageData(subScreenEvent); + } + // 二维码扫一扫 + if (action == SubScreenAction.qrCodeScan.name) { + if (params is! Map) { + params = json.decode(json.encode(params)); + } + final subScreenEvent = SubScreenQrCodeScanEvent.fromJson(params); + + qrCodeController = AppQrCodeDialogController(); + + showQrCodeDialog( + context, + controller: qrCodeController, + qrData: subScreenEvent.qrCodeData, + countDownSeconds: 300, + onFinished: () {}, + ); + } + if (action == SubScreenAction.qrCodeScanSuccess.name) { + qrCodeController.paySuccess(); + } + if (action == SubScreenAction.qrCodeScanCancel.name) { + qrCodeController.payCancel(); + } + } catch (e) { + logger.e("|$_tag|接收主屏消息错误error|$e|"); + } + }); + } + + @override + void dispose() { + super.dispose(); + _streamSubscription.cancel(); + } + + void setPageData(SubScreenProductEvent subScreenEvent) { + setState(() { + _shopList = subScreenEvent.shopList + .map((v) => ShopCardEntity.fromJson(v)) + .toList(); + _sysType = subScreenEvent.sysType; + if (subScreenEvent.payUser != null) { + _payUser = ServiceUser.fromJson(subScreenEvent.payUser!); + } else { + _payUser = null; + } + _totalPrice = subScreenEvent.totalPrice; + _amountMealPrice = subScreenEvent.amountMealPrice; + }); + } + + // 商品区域 + Widget _buildProductArea(BuildContext context) { + final theme = FTheme.of(context); + return LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + final width = constraints.maxWidth; + final height = constraints.maxHeight; + final hdHeight = height / 11; + final hdWidth = width / (_sysType == SysType.CANTEEN ? 5.1 : 4.1); + + final columns = [ + DataColumn2( + label: Text("名称", textAlign: TextAlign.center), + headingRowAlignment: MainAxisAlignment.center, + fixedWidth: hdWidth, + ), + DataColumn2( + label: Text("数量", textAlign: TextAlign.center), + headingRowAlignment: MainAxisAlignment.center, + fixedWidth: hdWidth, + ), + DataColumn2( + label: Text("单价", textAlign: TextAlign.center), + headingRowAlignment: MainAxisAlignment.center, + fixedWidth: hdWidth, + ), + DataColumn2( + label: Text("总价", textAlign: TextAlign.center), + headingRowAlignment: MainAxisAlignment.center, + fixedWidth: hdWidth, + ), + ]; + if (_sysType == SysType.CANTEEN) { + columns.insert( + 1, + DataColumn2( + label: Text("是否额外收费", textAlign: TextAlign.center), + headingRowAlignment: MainAxisAlignment.center, + fixedWidth: hdWidth, + ), + ); + } + final rows = _shopList.map((v) { + final cells = [ + DataCell( + Text( + v.productName, + textAlign: TextAlign.center, + ).center(), + ), + DataCell( + Text( + v.nature == ProductNature.DEFAULT + ? v.productNumber.toString() + : "${v.productWidget}/kg", + textAlign: TextAlign.center, + ).center(), + ), + DataCell( + appPrice(context, v.productPrice).center(), + ), + DataCell( + appPrice(context, v.totalPrice).center(), + ), + ]; + if (_sysType == SysType.CANTEEN) { + cells.insert( + 1, + DataCell( + Text( + v.isPriceIncreased ? "是" : "否", + textAlign: TextAlign.center, + ).center(), + ), + ); + } + return DataRow(cells: cells); + }); + + return DataTable2( + columnSpacing: 12.w, + horizontalMargin: 12.w, + minWidth: hdWidth, + showBottomBorder: true, + headingTextStyle: theme.typography.lg, + dataTextStyle: theme.typography.base, + dataRowHeight: hdHeight, + columns: columns, + rows: rows.toList(), + ); + }, + ); + } + + // 用户区域 + Widget _buildUserArea(BuildContext context) { + final theme = FTheme.of(context); + return [ + appUserInfo( + context, + username: _payUser?.username, + balance: _payUser?.walletBalance, + avatar: _payUser?.userAvatar, + ), + SizedBox(height: 10.h), + FDivider( + style: theme.dividerStyles.verticalStyle.copyWith( + color: theme.colorScheme.primary, + padding: EdgeInsets.zero, + ), + ), + [ + orderAllPrice( + context, + totalPrice: _totalPrice, + amountMealPrice: _amountMealPrice, + productPrice: "商品总价", + visibleDivider: false, + ), + SizedBox(height: 10.h), + ].toColumn(mainAxisAlignment: MainAxisAlignment.end).expanded(), + ].toColumn().padding(vertical: 10.h, horizontal: 10.w); + } + + @override + Widget build(BuildContext context) { + final width = MediaQuery.of(context).size.width; + final theme = FTheme.of(context); + return Scaffold( + appBar: AppBar( + title: Text( + _sysType.cnStr, + textAlign: TextAlign.center, + style: theme.typography.lg.copyWith( + color: theme.colorScheme.secondary, + ), + ).center().width(double.infinity).padding(vertical: 1).decorated( + color: theme.colorScheme.mutedForeground, + borderRadius: theme.style.borderRadius / 2, + ), + ), + body: [ + _buildProductArea(context).expanded(), + SizedBox(width: 20.w), + _buildUserArea(context).width(width * 0.25).decorated( + border: Border.all( + color: theme.colorScheme.primary, + width: 1, + ), + borderRadius: theme.style.borderRadius, + ), + ].toRow().padding(vertical: 20.h, horizontal: 30.w), + ); + } +} diff --git a/pubspec.lock b/pubspec.lock new file mode 100644 index 0000000..4dc0c38 --- /dev/null +++ b/pubspec.lock @@ -0,0 +1,1460 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: "16e298750b6d0af7ce8a3ba7c18c69c3785d11b15ec83f6dcd0ad2a0009b3cab" + url: "https://pub.flutter-io.cn" + source: hosted + version: "76.0.0" + _macros: + dependency: transitive + description: dart + source: sdk + version: "0.3.3" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: "1f14db053a8c23e260789e9b0980fa27f2680dd640932cae5e1137cce0e46e1e" + url: "https://pub.flutter-io.cn" + source: hosted + version: "6.11.0" + android_id: + dependency: "direct main" + description: + name: android_id + sha256: "748ba5f93dd5c497e675d8eaa1404346ce4d1794464ea654576ff192d153b92a" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.4.0" + archive: + dependency: transitive + description: + name: archive + sha256: "0c64e928dcbefddecd234205422bcfc2b5e6d31be0b86fef0d0dd48d7b4c9742" + url: "https://pub.flutter-io.cn" + source: hosted + version: "4.0.4" + args: + dependency: transitive + description: + name: args + sha256: bf9f5caeea8d8fe6721a9c358dd8a5c1947b27f1cfaa18b39c301273594919e6 + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.6.0" + async: + dependency: transitive + description: + name: async + sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.11.0" + background_downloader: + dependency: "direct main" + description: + name: background_downloader + sha256: "0873a939f5a301872cb7614e12f759fbccef2e3ed0d0d37b540dde7611275b12" + url: "https://pub.flutter-io.cn" + source: hosted + version: "7.12.3" + barcode: + dependency: transitive + description: + name: barcode + sha256: "7b6729c37e3b7f34233e2318d866e8c48ddb46c1f7ad01ff7bb2a8de1da2b9f4" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.2.9" + bidi: + dependency: transitive + description: + name: bidi + sha256: "9a712c7ddf708f7c41b1923aa83648a3ed44cfd75b04f72d598c45e5be287f9d" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.0.12" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.1.1" + build: + dependency: transitive + description: + name: build + sha256: cef23f1eda9b57566c81e2133d196f8e3df48f244b317368d65c5943d91148f0 + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.4.2" + build_config: + dependency: transitive + description: + name: build_config + sha256: "4ae2de3e1e67ea270081eaee972e1bd8f027d459f249e0f1186730784c2e7e33" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.1.2" + build_daemon: + dependency: transitive + description: + name: build_daemon + sha256: "8e928697a82be082206edb0b9c99c5a4ad6bc31c9e9b8b2f291ae65cd4a25daa" + url: "https://pub.flutter-io.cn" + source: hosted + version: "4.0.4" + build_resolvers: + dependency: transitive + description: + name: build_resolvers + sha256: b9e4fda21d846e192628e7a4f6deda6888c36b5b69ba02ff291a01fd529140f0 + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.4.4" + build_runner: + dependency: "direct dev" + description: + name: build_runner + sha256: "74691599a5bc750dc96a6b4bfd48f7d9d66453eab04c7f4063134800d6a5c573" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.4.14" + build_runner_core: + dependency: transitive + description: + name: build_runner_core + sha256: "22e3aa1c80e0ada3722fe5b63fd43d9c8990759d0a2cf489c8c5d7b2bdebc021" + url: "https://pub.flutter-io.cn" + source: hosted + version: "8.0.0" + built_collection: + dependency: transitive + description: + name: built_collection + sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100" + url: "https://pub.flutter-io.cn" + source: hosted + version: "5.1.1" + built_value: + dependency: transitive + description: + name: built_value + sha256: "8b158ab94ec6913e480dc3f752418348b5ae099eb75868b5f4775f0572999c61" + url: "https://pub.flutter-io.cn" + source: hosted + version: "8.9.4" + cached_network_image: + dependency: "direct main" + description: + name: cached_network_image + sha256: "7c1183e361e5c8b0a0f21a28401eecdbde252441106a9816400dd4c2b2424916" + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.4.1" + cached_network_image_platform_interface: + dependency: transitive + description: + name: cached_network_image_platform_interface + sha256: "35814b016e37fbdc91f7ae18c8caf49ba5c88501813f73ce8a07027a395e2829" + url: "https://pub.flutter-io.cn" + source: hosted + version: "4.1.1" + cached_network_image_web: + dependency: transitive + description: + name: cached_network_image_web + sha256: "980842f4e8e2535b8dbd3d5ca0b1f0ba66bf61d14cc3a17a9b4788a3685ba062" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.3.1" + characters: + dependency: transitive + description: + name: characters + sha256: "04a925763edad70e8443c99234dc3328f442e811f1d8fd1a72f1c8ad0f69a605" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.3.0" + charcode: + dependency: transitive + description: + name: charcode + sha256: fb0f1107cac15a5ea6ef0a6ef71a807b9e4267c713bb93e00e92d737cc8dbd8a + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.4.0" + checked_yaml: + dependency: transitive + description: + name: checked_yaml + sha256: feb6bed21949061731a7a75fc5d2aa727cf160b91af9a3e464c5e3a32e28b5ff + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.0.3" + clock: + dependency: transitive + description: + name: clock + sha256: cb6d7f03e1de671e34607e909a7213e31d7752be4fb66a86d29fe1eb14bfb5cf + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.1.1" + code_builder: + dependency: transitive + description: + name: code_builder + sha256: "0ec10bf4a89e4c613960bf1e8b42c64127021740fb21640c29c909826a5eea3e" + url: "https://pub.flutter-io.cn" + source: hosted + version: "4.10.1" + collection: + dependency: transitive + description: + name: collection + sha256: a1ace0a119f20aabc852d165077c036cd864315bd99b7eaa10a60100341941bf + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.19.0" + common_utils: + dependency: "direct main" + description: + name: common_utils + sha256: c26884339b13ff99b0739e56f4b02090c84054ed9dd3a045435cd24e7b99c2c1 + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.1.0" + connectivity_plus: + dependency: "direct main" + description: + name: connectivity_plus + sha256: "04bf81bb0b77de31557b58d052b24b3eee33f09a6e7a8c68a3e247c7df19ec27" + url: "https://pub.flutter-io.cn" + source: hosted + version: "6.1.3" + connectivity_plus_platform_interface: + dependency: transitive + description: + name: connectivity_plus_platform_interface + sha256: "42657c1715d48b167930d5f34d00222ac100475f73d10162ddf43e714932f204" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.0.1" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.1.2" + crypto: + dependency: transitive + description: + name: crypto + sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855" + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.0.6" + csslib: + dependency: transitive + description: + name: csslib + sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.0.2" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.0.8" + dart_style: + dependency: transitive + description: + name: dart_style + sha256: "7306ab8a2359a48d22310ad823521d723acfed60ee1f7e37388e8986853b6820" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.3.8" + data_table_2: + dependency: "direct main" + description: + name: data_table_2 + sha256: "5a540a7b64809eb46b3809c67fc6faa29ca217864341294df66b1f0eb040c20e" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.5.18" + dbus: + dependency: transitive + description: + name: dbus + sha256: "79e0c23480ff85dc68de79e2cd6334add97e48f7f4865d17686dd6ea81a47e8c" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.7.11" + decimal: + dependency: transitive + description: + name: decimal + sha256: "4140a688f9e443e2f4de3a1162387bf25e1ac6d51e24c9da263f245210f41440" + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.0.2" + dio: + dependency: "direct main" + description: + name: dio + sha256: "5598aa796bbf4699afd5c67c0f5f6e2ed542afc956884b9cd58c306966efc260" + url: "https://pub.flutter-io.cn" + source: hosted + version: "5.7.0" + dio_web_adapter: + dependency: transitive + description: + name: dio_web_adapter + sha256: "33259a9276d6cea88774a0000cfae0d861003497755969c92faa223108620dc8" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.0.0" + drago_pos_printer: + dependency: "direct main" + description: + name: drago_pos_printer + sha256: "3ad14d15872204541a964005005a535ecf78f052a9d295b1703fddebc3e50def" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.0.5" + drago_usb_printer: + dependency: transitive + description: + name: drago_usb_printer + sha256: "08272fe48797fe513cbdd57747dfe2edb2814cc438f9623311ec5e2601fcd1ab" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.1.2" + ducafe_ui_core: + dependency: "direct main" + description: + name: ducafe_ui_core + sha256: "92ff9b05654e1b5ae2adbeac7227c669655da73f134bede75ad9aa1c505af904" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.0.6" + dylib: + dependency: transitive + description: + name: dylib + sha256: bf609b3eb6492a3309b3d1dbe8f83a4031de5535dd7686be33487051cc760bb0 + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.3.3" + equatable: + dependency: "direct main" + description: + name: equatable + sha256: "567c64b3cb4cf82397aac55f4f0cbd3ca20d77c6c03bedbc4ceaddc08904aef7" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.0.7" + event_bus: + dependency: transitive + description: + name: event_bus + sha256: "1a55e97923769c286d295240048fc180e7b0768902c3c2e869fe059aafa15304" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.0.1" + external_path: + dependency: "direct main" + description: + name: external_path + sha256: "68a18a2aa51ec012d7013ea2a80305dc5372f3577a2bbcc7dcc5550b25a5a73b" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.2.0" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "511392330127add0b769b75a987850d136345d9227c6b94c96a04cf4a391bf78" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.3.1" + ffi: + dependency: transitive + description: + name: ffi + sha256: "16ed7b077ef01ad6170a3d0c57caa4a112a38d7a2ed5602e0aca9ca6f3d98da6" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.1.3" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.flutter-io.cn" + source: hosted + version: "7.0.1" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.1.1" + floor: + dependency: "direct main" + description: + name: floor + sha256: "52a8eac2c8d274e7c0c54251226f59786bb5b749365a2d8537d8095aa5132d92" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.4.2" + floor_annotation: + dependency: transitive + description: + name: floor_annotation + sha256: a40949580a7ab0eee572686e2d3b1638fd6bd6a753e661d792ab4236b365b23b + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.5.0" + floor_generator: + dependency: "direct dev" + description: + name: floor_generator + sha256: "1499b3ab878a807e6fbe6f140dc014124845cd1df3090a113aae5fa7577a1e77" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.5.0" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_cache_manager: + dependency: "direct main" + description: + name: flutter_cache_manager + sha256: "400b6592f16a4409a7f2bb929a9a7e38c72cceb8ffb99ee57bbf2cb2cecf8386" + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.4.1" + flutter_easyloading: + dependency: "direct main" + description: + name: flutter_easyloading + sha256: ba21a3c883544e582f9cc455a4a0907556714e1e9cf0eababfcb600da191d17c + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.0.5" + flutter_libserialport: + dependency: "direct main" + description: + name: flutter_libserialport + sha256: d193b5ac819db6540d20cc8d20d9f5ec9e3396edb4d2c4a23c97863fa9132336 + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.5.0" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "5398f14efa795ffb7a33e9b6a08798b26a180edac4ad7db3f231e40f82ce11e1" + url: "https://pub.flutter-io.cn" + source: hosted + version: "5.0.0" + flutter_localizations: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + flutter_spinkit: + dependency: transitive + description: + name: flutter_spinkit + sha256: d2696eed13732831414595b98863260e33e8882fc069ee80ec35d4ac9ddb0472 + url: "https://pub.flutter-io.cn" + source: hosted + version: "5.2.1" + flutter_subscreen_plugin: + dependency: "direct main" + description: + name: flutter_subscreen_plugin + sha256: b89bc8ba87956d441726557ece140ccea418ca4569e8f06e028e1017740ff6f3 + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.3.2+5" + flutter_svg: + dependency: transitive + description: + name: flutter_svg + sha256: c200fd79c918a40c5cd50ea0877fa13f81bdaf6f0a5d3dbcc2a13e3285d6aa1b + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.0.17" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_tts: + dependency: "direct main" + description: + name: flutter_tts + sha256: baa3cb6b4990318460fe28bfa8c7869399e97223971532c02bd97c5e876aa3c5 + url: "https://pub.flutter-io.cn" + source: hosted + version: "4.2.2" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + fluttertoast: + dependency: "direct main" + description: + name: fluttertoast + sha256: "25e51620424d92d3db3832464774a6143b5053f15e382d8ffbfd40b6e795dcf1" + url: "https://pub.flutter-io.cn" + source: hosted + version: "8.2.12" + forui: + dependency: "direct main" + description: + name: forui + sha256: "3ebbf39d57f0667afc47cbab86b0afe679bba2173a51afc21855053663dc5062" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.9.1+1" + forui_assets: + dependency: "direct main" + description: + name: forui_assets + sha256: df0f24b47ff0c3e16811bb4ad597b426b8e1a62fcf2fea7d95923ef4d6e8aa2c + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.9.1" + frontend_server_client: + dependency: transitive + description: + name: frontend_server_client + sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 + url: "https://pub.flutter-io.cn" + source: hosted + version: "4.0.0" + gbk_codec: + dependency: transitive + description: + name: gbk_codec + sha256: "3af5311fc9393115e3650ae6023862adf998051a804a08fb804f042724999f61" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.4.0" + get: + dependency: "direct main" + description: + name: get + sha256: c79eeb4339f1f3deffd9ec912f8a923834bec55f7b49c9e882b8fef2c139d425 + url: "https://pub.flutter-io.cn" + source: hosted + version: "4.7.2" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.1.3" + google_fonts: + dependency: transitive + description: + name: google_fonts + sha256: b1ac0fe2832c9cc95e5e88b57d627c5e68c223b9657f4b96e1487aa9098c7b82 + url: "https://pub.flutter-io.cn" + source: hosted + version: "6.2.1" + graphs: + dependency: transitive + description: + name: graphs + sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.3.2" + hex: + dependency: transitive + description: + name: hex + sha256: "4e7cd54e4b59ba026432a6be2dd9d96e4c5205725194997193bf871703b82c4a" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.2.0" + html: + dependency: transitive + description: + name: html + sha256: "1fc58edeaec4307368c60d59b7e15b9d658b57d7f3125098b6294153c75337ec" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.15.5" + http: + dependency: "direct main" + description: + name: http + sha256: fe7ab022b76f3034adc518fb6ea04a82387620e19977665ea18d30a1cf43442f + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.3.0" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.2.2" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.flutter-io.cn" + source: hosted + version: "4.1.2" + icons_launcher: + dependency: "direct dev" + description: + name: icons_launcher + sha256: a7c83fbc837dc6f81944ef35c3756f533bb2aba32fcca5cbcdb2dbcd877d5ae9 + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.0.0" + image: + dependency: "direct main" + description: + name: image + sha256: "13d3349ace88f12f4a0d175eb5c12dcdd39d35c4c109a8a13dfeb6d0bd9e31c3" + url: "https://pub.flutter-io.cn" + source: hosted + version: "4.5.3" + intl: + dependency: transitive + description: + name: intl + sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.19.0" + io: + dependency: transitive + description: + name: io + sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.0.5" + js: + dependency: transitive + description: + name: js + sha256: c1b2e9b5ea78c45e1a0788d29606ba27dc5f71f019f32ca5140f61ef071838cf + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.7.1" + json_annotation: + dependency: "direct main" + description: + name: json_annotation + sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" + url: "https://pub.flutter-io.cn" + source: hosted + version: "4.9.0" + json_serializable: + dependency: "direct dev" + description: + name: json_serializable + sha256: ea1432d167339ea9b5bb153f0571d0039607a873d6e04e0117af043f14a1fd4b + url: "https://pub.flutter-io.cn" + source: hosted + version: "6.8.0" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "7bb2830ebd849694d1ec25bf1f44582d6ac531a57a365a803a6034ff751d2d06" + url: "https://pub.flutter-io.cn" + source: hosted + version: "10.0.7" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "9491a714cca3667b60b5c420da8217e6de0d1ba7a5ec322fab01758f6998f379" + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.0.8" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.0.1" + libserialport: + dependency: transitive + description: + name: libserialport + sha256: "392e1592def65282429832ec66fa25e9e163d3b37716b97691482e2406720727" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.3.0+1" + lints: + dependency: transitive + description: + name: lints + sha256: c35bb79562d980e9a453fc715854e1ed39e24e7d0297a880ef54e17f9874a9d7 + url: "https://pub.flutter-io.cn" + source: hosted + version: "5.1.1" + logger: + dependency: "direct main" + description: + name: logger + sha256: af05cc8714f356fd1f3888fb6741cbe9fbe25cdb6eedbab80e1a6db21047d4a4 + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.3.0" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.3.0" + macros: + dependency: transitive + description: + name: macros + sha256: "1d9e801cd66f7ea3663c45fc708450db1fa57f988142c64289142c9b7ee80656" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.1.3-main.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.12.16+1" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.11.1" + meta: + dependency: transitive + description: + name: meta + sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7 + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.15.0" + mime: + dependency: transitive + description: + name: mime + sha256: "801fd0b26f14a4a58ccb09d5892c3fbdeff209594300a542492cf13fba9d247a" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.0.6" + mqtt_client: + dependency: "direct main" + description: + name: mqtt_client + sha256: "37aae360fac0b3322cb37267696c1535e1ad2984a13064d877273ffe4b16ca7a" + url: "https://pub.flutter-io.cn" + source: hosted + version: "10.5.1" + network_info_plus: + dependency: transitive + description: + name: network_info_plus + sha256: "08f4166bbb77da9e407edef6322a33f87b18c0ca46483fb25606cb3d2bfcdd2a" + url: "https://pub.flutter-io.cn" + source: hosted + version: "6.1.3" + network_info_plus_platform_interface: + dependency: transitive + description: + name: network_info_plus_platform_interface + sha256: "7e7496a8a9d8136859b8881affc613c4a21304afeb6c324bcefc4bd0aff6b94b" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.0.2" + nitrogen_flutter_svg: + dependency: transitive + description: + name: nitrogen_flutter_svg + sha256: "103313861b182fd4069bac896519b1ee64cbe1af0023d818f6c300b81c16853c" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.4.0" + nitrogen_types: + dependency: transitive + description: + name: nitrogen_types + sha256: "5d4ec453aea14e34607bc6021ff1381192e8fcd8541bf103638baa4d013e668f" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.4.0+1" + nm: + dependency: transitive + description: + name: nm + sha256: "2c9aae4127bdc8993206464fcc063611e0e36e72018696cd9631023a31b24254" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.5.0" + number_pagination: + dependency: "direct main" + description: + name: number_pagination + sha256: "75d3a28616196e7c8df431d0fb7c48e811e462155f4cf3b5b4167b3408421327" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.1.6" + octo_image: + dependency: transitive + description: + name: octo_image + sha256: "34faa6639a78c7e3cbe79be6f9f96535867e879748ade7d17c9b1ae7536293bd" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.1.0" + package_config: + dependency: transitive + description: + name: package_config + sha256: "92d4488434b520a62570293fbd33bb556c7d49230791c1b4bbd973baf6d2dc67" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.1.1" + path: + dependency: transitive + description: + name: path + sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.9.0" + path_parsing: + dependency: transitive + description: + name: path_parsing + sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.1.0" + path_provider: + dependency: transitive + description: + name: path_provider + sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.1.5" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: "4adf4fd5423ec60a29506c76581bc05854c55e3a0b72d35bb28d661c9686edf2" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.2.15" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "4843174df4d288f5e29185bd6e72a6fbdf5a4a4602717eed565497429f179942" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.4.1" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.2.1" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.1.2" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.3.0" + pdf: + dependency: transitive + description: + name: pdf + sha256: "28eacad99bffcce2e05bba24e50153890ad0255294f4dd78a17075a2ba5c8416" + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.11.3" + pdf_widget_wrapper: + dependency: transitive + description: + name: pdf_widget_wrapper + sha256: c930860d987213a3d58c7ec3b7ecf8085c3897f773e8dc23da9cae60a5d6d0f5 + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.0.4" + permission_handler: + dependency: "direct main" + description: + name: permission_handler + sha256: "59adad729136f01ea9e35a48f5d1395e25cba6cea552249ddbe9cf950f5d7849" + url: "https://pub.flutter-io.cn" + source: hosted + version: "11.4.0" + permission_handler_android: + dependency: transitive + description: + name: permission_handler_android + sha256: d3971dcdd76182a0c198c096b5db2f0884b0d4196723d21a866fc4cdea057ebc + url: "https://pub.flutter-io.cn" + source: hosted + version: "12.1.0" + permission_handler_apple: + dependency: transitive + description: + name: permission_handler_apple + sha256: f84a188e79a35c687c132a0a0556c254747a08561e99ab933f12f6ca71ef3c98 + url: "https://pub.flutter-io.cn" + source: hosted + version: "9.4.6" + permission_handler_html: + dependency: transitive + description: + name: permission_handler_html + sha256: "38f000e83355abb3392140f6bc3030660cfaef189e1f87824facb76300b4ff24" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.1.3+5" + permission_handler_platform_interface: + dependency: transitive + description: + name: permission_handler_platform_interface + sha256: eb99b295153abce5d683cac8c02e22faab63e50679b937fa1bf67d58bb282878 + url: "https://pub.flutter-io.cn" + source: hosted + version: "4.3.0" + permission_handler_windows: + dependency: transitive + description: + name: permission_handler_windows + sha256: "1a790728016f79a41216d88672dbc5df30e686e811ad4e698bfc51f76ad91f1e" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.2.1" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: c15605cd28af66339f8eb6fbe0e541bfe2d1b72d5825efc6598f3e0a31b9ad27 + url: "https://pub.flutter-io.cn" + source: hosted + version: "6.0.2" + pinyin: + dependency: "direct main" + description: + name: pinyin + sha256: "240f271a3c71af20c8d2757756b5ee8e9d79a955d37abad4b3568fd406b22411" + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.3.0" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.1.8" + pool: + dependency: transitive + description: + name: pool + sha256: "20fe868b6314b322ea036ba325e6fc0711a22948856475e2c2b6306e8ab39c2a" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.5.1" + posix: + dependency: transitive + description: + name: posix + sha256: a0117dc2167805aa9125b82eee515cc891819bac2f538c83646d355b16f58b9a + url: "https://pub.flutter-io.cn" + source: hosted + version: "6.0.1" + printing: + dependency: "direct main" + description: + name: printing + sha256: "482cd5a5196008f984bb43ed0e47cbfdca7373490b62f3b27b3299275bf22a93" + url: "https://pub.flutter-io.cn" + source: hosted + version: "5.14.2" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "7b3cfbf654f3edd0c6298ecd5be782ce997ddf0e00531b9464b55245185bbbbd" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.1.5" + pubspec_parse: + dependency: transitive + description: + name: pubspec_parse + sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.5.0" + qr: + dependency: transitive + description: + name: qr + sha256: "5a1d2586170e172b8a8c8470bbbffd5eb0cd38a66c0d77155ea138d3af3a4445" + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.0.2" + qr_flutter: + dependency: "direct main" + description: + name: qr_flutter + sha256: "5095f0fc6e3f71d08adef8feccc8cea4f12eec18a2e31c2e8d82cb6019f4b097" + url: "https://pub.flutter-io.cn" + source: hosted + version: "4.1.0" + rational: + dependency: transitive + description: + name: rational + sha256: cb808fb6f1a839e6fc5f7d8cb3b0a10e1db48b3be102de73938c627f0b636336 + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.2.3" + rxdart: + dependency: transitive + description: + name: rxdart + sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.28.0" + shelf: + dependency: transitive + description: + name: shelf + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.4.2" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + sha256: cc36c297b52866d203dbf9332263c94becc2fe0ceaa9681d07b6ef9807023b67 + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.0.1" + simple_sparse_list: + dependency: transitive + description: + name: simple_sparse_list + sha256: aa648fd240fa39b49dcd11c19c266990006006de6699a412de485695910fbc1f + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.1.4" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_gen: + dependency: transitive + description: + name: source_gen + sha256: "14658ba5f669685cd3d63701d01b31ea748310f7ab854e471962670abcf57832" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.5.0" + source_helper: + dependency: transitive + description: + name: source_helper + sha256: "86d247119aedce8e63f4751bd9626fc9613255935558447569ad42f9f5b48b3c" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.3.5" + source_span: + dependency: transitive + description: + name: source_span + sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.10.0" + sprintf: + dependency: transitive + description: + name: sprintf + sha256: "1fc9ffe69d4df602376b52949af107d8f5703b77cda567c4d7d86a0693120f23" + url: "https://pub.flutter-io.cn" + source: hosted + version: "7.0.0" + sqflite: + dependency: "direct main" + description: + name: sqflite + sha256: "2d7299468485dca85efeeadf5d38986909c5eb0cd71fd3db2c2f000e6c9454bb" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.4.1" + sqflite_android: + dependency: transitive + description: + name: sqflite_android + sha256: "78f489aab276260cdd26676d2169446c7ecd3484bbd5fead4ca14f3ed4dd9ee3" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.4.0" + sqflite_common: + dependency: transitive + description: + name: sqflite_common + sha256: "761b9740ecbd4d3e66b8916d784e581861fd3c3553eda85e167bc49fdb68f709" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.5.4+6" + sqflite_common_ffi: + dependency: transitive + description: + name: sqflite_common_ffi + sha256: "883dd810b2b49e6e8c3b980df1829ef550a94e3f87deab5d864917d27ca6bf36" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.3.4+4" + sqflite_darwin: + dependency: transitive + description: + name: sqflite_darwin + sha256: "22adfd9a2c7d634041e96d6241e6e1c8138ca6817018afc5d443fef91dcefa9c" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.4.1+1" + sqflite_platform_interface: + dependency: transitive + description: + name: sqflite_platform_interface + sha256: "8dd4515c7bdcae0a785b0062859336de775e8c65db81ae33dd5445f35be61920" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.4.0" + sqlite3: + dependency: transitive + description: + name: sqlite3 + sha256: "32b632dda27d664f85520093ed6f735ae5c49b5b75345afb8b19411bc59bb53d" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.7.4" + sqlparser: + dependency: transitive + description: + name: sqlparser + sha256: "91f47610aa54d8abf9d795a7b4e49b2a788f65d7493d5a68fbf180c3cbcc6f38" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.27.0" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "9f47fd3630d76be3ab26f0ee06d213679aa425996925ff3feffdec504931c377" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.12.0" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7 + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.1.2" + stream_transform: + dependency: transitive + description: + name: stream_transform + sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.1.1" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "688af5ed3402a4bde5b3a6c15fd768dbf2621a614950b17f04626c431ab3c4c3" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.3.0" + strings: + dependency: transitive + description: + name: strings + sha256: "482f1511d2cd8ab9f2c4cc148e6837be8a00c03b9a8eaedbc981a84d9c6305d8" + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.2.0" + sugar: + dependency: transitive + description: + name: sugar + sha256: a0a97f1d3552a3ef4cbec7c6e62c7b52c151cd5c11e984e8311700a918f73dc5 + url: "https://pub.flutter-io.cn" + source: hosted + version: "4.0.0" + syncfusion_flutter_core: + dependency: transitive + description: + name: syncfusion_flutter_core + sha256: "59b6d2a7deacade6129d2f15615ca49ed56278fea055cd2e52cace78a343dd5e" + url: "https://pub.flutter-io.cn" + source: hosted + version: "28.2.11" + syncfusion_flutter_datepicker: + dependency: "direct main" + description: + name: syncfusion_flutter_datepicker + sha256: "7e598b8933d906ef7a3b8f47c59e942409f71c8e59e3d1b88e67acc9eea48817" + url: "https://pub.flutter-io.cn" + source: hosted + version: "28.2.11" + synchronized: + dependency: transitive + description: + name: synchronized + sha256: "69fe30f3a8b04a0be0c15ae6490fc859a78ef4c43ae2dd5e8a623d45bfcf9225" + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.3.0+3" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84 + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.2.1" + test_api: + dependency: transitive + description: + name: test_api + sha256: "664d3a9a64782fcdeb83ce9c6b39e78fd2971d4e37827b9b06c3aa1edc5e760c" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.7.3" + timing: + dependency: transitive + description: + name: timing + sha256: "62ee18aca144e4a9f29d212f5a4c6a053be252b895ab14b5821996cff4ed90fe" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.0.2" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.4.0" + unicode: + dependency: transitive + description: + name: unicode + sha256: "0d99edbd2e74726bed2e4989713c8bec02e5581628e334d8c88c0271593fb402" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.1.8" + universal_html: + dependency: transitive + description: + name: universal_html + sha256: "56536254004e24d9d8cfdb7dbbf09b74cf8df96729f38a2f5c238163e3d58971" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.2.4" + universal_io: + dependency: transitive + description: + name: universal_io + sha256: "1722b2dcc462b4b2f3ee7d188dad008b6eb4c40bbd03a3de451d82c78bba9aad" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.2.2" + uuid: + dependency: transitive + description: + name: uuid + sha256: a5be9ef6618a7ac1e964353ef476418026db906c4facdedaa299b7a2e71690ff + url: "https://pub.flutter-io.cn" + source: hosted + version: "4.5.1" + vector_graphics: + dependency: transitive + description: + name: vector_graphics + sha256: "44cc7104ff32563122a929e4620cf3efd584194eec6d1d913eb5ba593dbcf6de" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.1.18" + vector_graphics_codec: + dependency: transitive + description: + name: vector_graphics_codec + sha256: "99fd9fbd34d9f9a32efd7b6a6aae14125d8237b10403b422a6a6dfeac2806146" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.1.13" + vector_graphics_compiler: + dependency: transitive + description: + name: vector_graphics_compiler + sha256: "1b4b9e706a10294258727674a340ae0d6e64a7231980f9f9a3d12e4b42407aad" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.1.16" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.1.4" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: f6be3ed8bd01289b34d679c2b62226f63c0e69f9fd2e50a6b3c1c729a961041b + url: "https://pub.flutter-io.cn" + source: hosted + version: "14.3.0" + watcher: + dependency: transitive + description: + name: watcher + sha256: "69da27e49efa56a15f8afe8f4438c4ec02eff0a117df1b22ea4aad194fe1c104" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.1.1" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.1.1" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "3c12d96c0c9a4eec095246debcea7b86c0324f22df69893d538fcc6f1b8cce83" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.1.6" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + sha256: "0b8e2457400d8a859b7b2030786835a28a8e80836ef64402abef392ff4f1d0e5" + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.0.2" + win32: + dependency: transitive + description: + name: win32 + sha256: daf97c9d80197ed7b619040e86c8ab9a9dad285e7671ee7390f9180cc828a51e + url: "https://pub.flutter-io.cn" + source: hosted + version: "5.10.1" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.1.0" + xml: + dependency: transitive + description: + name: xml + sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226 + url: "https://pub.flutter-io.cn" + source: hosted + version: "6.5.0" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.1.3" +sdks: + dart: ">=3.6.0 <4.0.0" + flutter: ">=3.27.0" diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..9a0a236 --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,94 @@ +name: pj_supermarket_cloud +description: "资安信智慧后勤-测试" +publish_to: "none" +version: 1.1.1+1 + +environment: + sdk: ^3.6.0 + +dependencies: + flutter: + sdk: flutter + cupertino_icons: ^1.0.8 + # http请求 + dio: 5.7.0 + http: ^1.3.0 + # mq + mqtt_client: ^10.0.2 + # 文字转拼音 + pinyin: ^3.3.0 + + # ui框架 + ducafe_ui_core: ^1.0.6 + forui: ^0.9.1+1 + forui_assets: ^0.9.1 + # 吐司弹框 + fluttertoast: ^8.2.5 + # loading + flutter_easyloading: ^3.0.5 + # 图片处理 + image: ^4.3.0 + # 图片缓存 + cached_network_image: ^3.4.1 + # 表格 + data_table_2: ^2.5.18 + # 分页 + number_pagination: ^1.1.6 + # 日期选择 + syncfusion_flutter_datepicker: ^28.2.11 + # tts转语音 + flutter_tts: ^4.2.2 + # 二维码 + qr_flutter: ^4.1.0 + + # 权限判断 + permission_handler: ^11.4.0 + # 串口 + flutter_libserialport: ^0.5.0 + # 状态管理、路由 + get: ^4.7.2 + # 双屏 + flutter_subscreen_plugin: ^1.3.2+5 + # 数据库 + sqflite: 2.4.1 + floor: 1.4.2 + # 缓存 + flutter_cache_manager: ^3.4.1 + # json序列化 + json_annotation: ^4.9.0 + # 外部路径获取 + external_path: ^2.2.0 + # 获取唯一id + android_id: ^0.4.0 + # 监听网络状态 + connectivity_plus: ^6.0.3 + # 日志 + logger: 2.3.0 + # 工具类 + common_utils: ^2.1.0 + # 判断两个对象相等 + equatable: ^2.0.7 + # 打印机 + drago_pos_printer: ^1.0.5 + printing: ^5.13.4 + # 后台下载(兼容当前AGP 8.1) + background_downloader: ^7.12.3 +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^5.0.0 + # logo + icons_launcher: 3.0.0 + # orm + floor_generator: ^1.5.0 + # 自动生成代码 + build_runner: 2.4.14 + # json + json_serializable: 6.8.0 + +flutter: + uses-material-design: true + + assets: + - assets/images/ + - assets/crt/