feat(kiosk): add android kiosk terminal mode support
完整实现收银终端的Android专用Kiosk模式支持,包括: - 添加原生设备管理与Kiosk模式核心逻辑 - 新增Flutter设置页的Kiosk相关配置与控制UI - 更新README添加部署命令 - 补充Android权限与组件声明
This commit is contained in:
1 parent
94f8c5e67b
commit
a0ad3a06ff
14 files changed
+557
-37
No files matched your search
@@ -1,8 +1,11 @@
|
||||
## 生成代码命令
|
||||
|
||||
```
|
||||
flutter pub run build_runner build --delete-conflicting-outputs~
|
||||
```
|
||||
|
||||
## flutter打包
|
||||
|
||||
```
|
||||
flutter build apk --build-name 1.3.0 --build-number 130
|
||||
--build-name为版本号(展示)
|
||||
@@ -10,6 +13,14 @@ flutter build apk --build-name 1.3.0 --build-number 130
|
||||
```
|
||||
|
||||
## 生成log命令
|
||||
|
||||
```
|
||||
dart run icons_launcher:create
|
||||
```
|
||||
```
|
||||
|
||||
### 开启Kiosk模式命令
|
||||
|
||||
```
|
||||
adb shell dpm set-device-owner \
|
||||
com.example.pj_supermarket_cloud/.KioskDeviceAdminReceiver
|
||||
```
|
||||
@@ -12,6 +12,7 @@
|
||||
<uses-permission android:name="android.permission.READ_PHONE_NUMBERS" />
|
||||
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
|
||||
<uses-permission android:name="android.permission.SYSTEM_OVERLAY_WINDOW" />
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
|
||||
<queries>
|
||||
<intent>
|
||||
@@ -47,6 +48,29 @@
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<receiver
|
||||
android:name=".KioskDeviceAdminReceiver"
|
||||
android:description="@string/kiosk_device_admin_description"
|
||||
android:exported="true"
|
||||
android:label="@string/kiosk_device_admin_label"
|
||||
android:permission="android.permission.BIND_DEVICE_ADMIN">
|
||||
<meta-data
|
||||
android:name="android.app.device_admin"
|
||||
android:resource="@xml/kiosk_device_admin" />
|
||||
<intent-filter>
|
||||
<action android:name="android.app.action.DEVICE_ADMIN_ENABLED" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<receiver
|
||||
android:name=".KioskBootReceiver"
|
||||
android:enabled="true"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<activity
|
||||
android:name="io.flutter.plugins.facediscern.ui.FaceDiscern"
|
||||
android:exported="true"
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.example.pj_supermarket_cloud
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
|
||||
/**
|
||||
* 仅在应用已经是 Device Owner 时响应开机广播,避免普通安装包无条件打断系统启动流程。
|
||||
*/
|
||||
class KioskBootReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (intent.action != Intent.ACTION_BOOT_COMPLETED) return
|
||||
if (!KioskModeManager.prepareForBoot(context)) return
|
||||
|
||||
val launchIntent = context.packageManager.getLaunchIntentForPackage(context.packageName) ?: return
|
||||
launchIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
|
||||
context.startActivity(launchIntent)
|
||||
}
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
package com.example.pj_supermarket_cloud
|
||||
|
||||
import android.app.admin.DeviceAdminReceiver
|
||||
|
||||
/**
|
||||
* 专用收银终端的设备管理接收器。
|
||||
*
|
||||
* 该接收器只作为 Device Owner 的系统入口,应用不会在普通安装状态下自行提升权限。
|
||||
*/
|
||||
class KioskDeviceAdminReceiver : DeviceAdminReceiver()
|
||||
@@ -0,0 +1,172 @@
|
||||
package com.example.pj_supermarket_cloud
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.ActivityManager
|
||||
import android.app.admin.DevicePolicyManager
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.WindowInsetsControllerCompat
|
||||
|
||||
/**
|
||||
* 管理 Android 专用设备模式。
|
||||
*
|
||||
* 只有 Device Owner 才能可靠限制主页、最近任务和状态栏。普通设备管理员或沉浸式全屏
|
||||
* 都不视为 Kiosk 可用,避免界面把“看起来全屏”误报为“无法退出”。
|
||||
*/
|
||||
object KioskModeManager {
|
||||
private const val PREFERENCES_NAME = "kiosk_runtime"
|
||||
private const val AUTO_ENTER_ENABLED_KEY = "auto_enter_enabled"
|
||||
|
||||
fun status(context: Context): Map<String, Any> {
|
||||
val policyManager = policyManager(context)
|
||||
val deviceOwner = policyManager.isDeviceOwnerApp(context.packageName)
|
||||
val autoEnterEnabled = isAutoEnterEnabled(context)
|
||||
return mapOf(
|
||||
"deviceAdminActive" to policyManager.isAdminActive(adminComponent(context)),
|
||||
"deviceOwner" to deviceOwner,
|
||||
"available" to deviceOwner,
|
||||
"active" to isKioskActive(context),
|
||||
"lockTaskPermitted" to policyManager.isLockTaskPermitted(context.packageName),
|
||||
"autoEnterEnabled" to autoEnterEnabled,
|
||||
"bootAutoStartSupported" to deviceOwner
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Activity 每次回到前台时按配置补齐白名单并进入锁定任务。管理员退出到桌面的过程
|
||||
* 不会触发新的 onPostResume;用户再次主动打开 App 时才会重新进入 Kiosk。
|
||||
*/
|
||||
fun enterIfAvailable(activity: Activity) {
|
||||
if (!isAutoEnterEnabled(activity) || !ensureLockTaskPolicy(activity)) return
|
||||
if (!isKioskActive(activity)) {
|
||||
try {
|
||||
activity.startLockTask()
|
||||
} catch (_: IllegalArgumentException) {
|
||||
return
|
||||
} catch (_: IllegalStateException) {
|
||||
return
|
||||
} catch (_: SecurityException) {
|
||||
return
|
||||
}
|
||||
}
|
||||
if (isKioskActive(activity)) hideSystemBars(activity)
|
||||
}
|
||||
|
||||
/**
|
||||
* Device Owner 设备始终支持开机拉起 App;只有开启自动进入时才预先恢复 Kiosk 策略。
|
||||
*/
|
||||
fun prepareForBoot(context: Context): Boolean {
|
||||
if (!policyManager(context).isDeviceOwnerApp(context.packageName)) return false
|
||||
if (isAutoEnterEnabled(context)) {
|
||||
return ensureLockTaskPolicy(context)
|
||||
}
|
||||
return try {
|
||||
policyManager(context).setStatusBarDisabled(adminComponent(context), false)
|
||||
true
|
||||
} catch (_: Exception) {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解除当前锁定并回到系统 HOME。保留 Lock Task 白名单,自动进入配置开启时,用户
|
||||
* 下次主动打开 App 会通过 onPostResume 重新锁定。
|
||||
*/
|
||||
fun exitToHome(activity: Activity): Boolean {
|
||||
val policyManager = policyManager(activity)
|
||||
if (!policyManager.isDeviceOwnerApp(activity.packageName)) return false
|
||||
|
||||
return try {
|
||||
if (isKioskActive(activity)) activity.stopLockTask()
|
||||
policyManager.setStatusBarDisabled(adminComponent(activity), false)
|
||||
showSystemBars(activity)
|
||||
|
||||
val homeIntent = Intent(Intent.ACTION_MAIN).apply {
|
||||
addCategory(Intent.CATEGORY_HOME)
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED)
|
||||
}
|
||||
if (homeIntent.resolveActivity(activity.packageManager) != null) {
|
||||
activity.startActivity(homeIntent)
|
||||
} else {
|
||||
activity.moveTaskToBack(true)
|
||||
}
|
||||
true
|
||||
} catch (_: Exception) {
|
||||
// 退出失败时恢复终端策略,不能把设备留在既未锁定也未回到桌面的中间状态。
|
||||
if (isAutoEnterEnabled(activity) && ensureLockTaskPolicy(activity)) {
|
||||
enterIfAvailable(activity)
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存“进入 App 自动开启”配置。关闭配置只影响后续进入,当前 Kiosk 会继续运行,
|
||||
* 仍需通过管理密码出口返回桌面,避免一个普通开关绕过终端退出保护。
|
||||
*/
|
||||
fun setAutoEnterEnabled(activity: Activity, enabled: Boolean): Map<String, Any> {
|
||||
if (!policyManager(activity).isDeviceOwnerApp(activity.packageName)) return status(activity)
|
||||
preferences(activity).edit().putBoolean(AUTO_ENTER_ENABLED_KEY, enabled).apply()
|
||||
if (enabled) {
|
||||
enterIfAvailable(activity)
|
||||
}
|
||||
return status(activity)
|
||||
}
|
||||
|
||||
fun reapplySystemBars(activity: Activity) {
|
||||
if (isKioskActive(activity)) hideSystemBars(activity)
|
||||
}
|
||||
|
||||
fun isKioskActive(context: Context): Boolean {
|
||||
val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
|
||||
return activityManager.lockTaskModeState == ActivityManager.LOCK_TASK_MODE_LOCKED
|
||||
}
|
||||
|
||||
private fun ensureLockTaskPolicy(context: Context): Boolean {
|
||||
val policyManager = policyManager(context)
|
||||
if (!policyManager.isDeviceOwnerApp(context.packageName)) return false
|
||||
return try {
|
||||
policyManager.setLockTaskPackages(adminComponent(context), arrayOf(context.packageName))
|
||||
policyManager.setStatusBarDisabled(adminComponent(context), true)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||
policyManager.setLockTaskFeatures(
|
||||
adminComponent(context),
|
||||
DevicePolicyManager.LOCK_TASK_FEATURE_NONE
|
||||
)
|
||||
}
|
||||
policyManager.isLockTaskPermitted(context.packageName)
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun hideSystemBars(activity: Activity) {
|
||||
WindowCompat.setDecorFitsSystemWindows(activity.window, false)
|
||||
WindowCompat.getInsetsController(activity.window, activity.window.decorView).apply {
|
||||
hide(WindowInsetsCompat.Type.systemBars())
|
||||
systemBarsBehavior =
|
||||
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
||||
}
|
||||
}
|
||||
|
||||
private fun showSystemBars(activity: Activity) {
|
||||
WindowCompat.setDecorFitsSystemWindows(activity.window, true)
|
||||
WindowCompat.getInsetsController(activity.window, activity.window.decorView)
|
||||
.show(WindowInsetsCompat.Type.systemBars())
|
||||
}
|
||||
|
||||
private fun adminComponent(context: Context) = ComponentName(context, KioskDeviceAdminReceiver::class.java)
|
||||
|
||||
private fun policyManager(context: Context) =
|
||||
context.getSystemService(Context.DEVICE_POLICY_SERVICE) as DevicePolicyManager
|
||||
|
||||
private fun isAutoEnterEnabled(context: Context) =
|
||||
preferences(context).getBoolean(AUTO_ENTER_ENABLED_KEY, true)
|
||||
|
||||
private fun preferences(context: Context) =
|
||||
context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE)
|
||||
}
|
||||
@@ -36,6 +36,16 @@ class MainActivity : FlutterActivity() {
|
||||
)
|
||||
}
|
||||
"getNetworkSnapshot" -> result.success(getNetworkSnapshot())
|
||||
"getKioskStatus" -> result.success(KioskModeManager.status(this))
|
||||
"setKioskAutoEnterEnabled" -> {
|
||||
val enabled = call.argument<Boolean>("enabled")
|
||||
if (enabled == null) {
|
||||
result.error("INVALID_ARGUMENT", "缺少 Kiosk 自动进入配置", null)
|
||||
} else {
|
||||
result.success(KioskModeManager.setAutoEnterEnabled(this, enabled))
|
||||
}
|
||||
}
|
||||
"exitKioskToHome" -> result.success(KioskModeManager.exitToHome(this))
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
@@ -51,6 +61,23 @@ class MainActivity : FlutterActivity() {
|
||||
// )
|
||||
}
|
||||
|
||||
override fun onPostResume() {
|
||||
super.onPostResume()
|
||||
KioskModeManager.enterIfAvailable(this)
|
||||
}
|
||||
|
||||
override fun onWindowFocusChanged(hasFocus: Boolean) {
|
||||
super.onWindowFocusChanged(hasFocus)
|
||||
if (hasFocus) KioskModeManager.reapplySystemBars(this)
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
override fun onBackPressed() {
|
||||
// 当前 FlutterActivity 基线没有 OnBackPressedDispatcher,Kiosk 运行时直接拦截系统返回。
|
||||
if (KioskModeManager.isKioskActive(this)) return
|
||||
super.onBackPressed()
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用 Android Build 特征识别常见虚拟机,避免在虚拟设备上初始化串口、人脸和副屏硬件。
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="kiosk_device_admin_label">资安信收银终端设备管理</string>
|
||||
<string name="kiosk_device_admin_description">用于启用开机自启和专用设备锁定模式</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<device-admin xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-policies />
|
||||
</device-admin>
|
||||
@@ -18,6 +18,75 @@ class AppRuntimeEnvironment {
|
||||
}
|
||||
}
|
||||
|
||||
/// Android 专用设备模式的实时状态。
|
||||
class AppKioskStatus {
|
||||
final bool deviceAdminActive;
|
||||
final bool deviceOwner;
|
||||
final bool available;
|
||||
final bool active;
|
||||
final bool lockTaskPermitted;
|
||||
final bool autoEnterEnabled;
|
||||
final bool bootAutoStartSupported;
|
||||
|
||||
const AppKioskStatus({
|
||||
required this.deviceAdminActive,
|
||||
required this.deviceOwner,
|
||||
required this.available,
|
||||
required this.active,
|
||||
required this.lockTaskPermitted,
|
||||
required this.autoEnterEnabled,
|
||||
required this.bootAutoStartSupported,
|
||||
});
|
||||
|
||||
const AppKioskStatus.unavailable()
|
||||
: deviceAdminActive = false,
|
||||
deviceOwner = false,
|
||||
available = false,
|
||||
active = false,
|
||||
lockTaskPermitted = false,
|
||||
autoEnterEnabled = false,
|
||||
bootAutoStartSupported = false;
|
||||
|
||||
factory AppKioskStatus.fromMap(Map<dynamic, dynamic>? data) {
|
||||
return AppKioskStatus(
|
||||
deviceAdminActive: data?['deviceAdminActive'] == true,
|
||||
deviceOwner: data?['deviceOwner'] == true,
|
||||
available: data?['available'] == true,
|
||||
active: data?['active'] == true,
|
||||
lockTaskPermitted: data?['lockTaskPermitted'] == true,
|
||||
autoEnterEnabled: data?['autoEnterEnabled'] == true,
|
||||
bootAutoStartSupported: data?['bootAutoStartSupported'] == true,
|
||||
);
|
||||
}
|
||||
|
||||
bool get canExitToAndroidHome => available && active;
|
||||
|
||||
String get statusLabel {
|
||||
if (!available) return '暂不可实现';
|
||||
if (active && !autoEnterEnabled) return 'Kiosk 运行中 · 自动开启已关闭';
|
||||
if (active) return '可以实现 · Kiosk 运行中';
|
||||
if (!autoEnterEnabled) return '可以实现 · 自动开启已关闭';
|
||||
if (lockTaskPermitted) return '可以实现 · 正在进入';
|
||||
return '可以实现 · 等待策略生效';
|
||||
}
|
||||
|
||||
String get description {
|
||||
if (available && active && !autoEnterEnabled) {
|
||||
return '当前仍在 Kiosk;管理员退出后,再次打开或回到 App 将不再自动锁定。';
|
||||
}
|
||||
if (available && !autoEnterEnabled) {
|
||||
return '设备具备 Device Owner 权限;开启右侧配置后,每次打开或回到 App 都会自动进入 Kiosk。';
|
||||
}
|
||||
if (available) {
|
||||
return '已配置 Device Owner;每次打开或回到 App 都会自动进入 Kiosk,并隐藏主页、最近任务和系统栏。';
|
||||
}
|
||||
if (deviceAdminActive) {
|
||||
return '当前仅激活了设备管理员,仍不能可靠禁用主页和最近任务;需要配置为 Device Owner。';
|
||||
}
|
||||
return '未配置 Device Owner,普通全屏不能保证无法退出;需在恢复出厂或未初始化的设备上部署。';
|
||||
}
|
||||
}
|
||||
|
||||
class AppNetworkSnapshot {
|
||||
final bool connected;
|
||||
final String type;
|
||||
@@ -53,6 +122,7 @@ abstract class AppRuntimeTool {
|
||||
);
|
||||
static bool _initialized = false;
|
||||
static bool _manualHardwareInput = false;
|
||||
static AppKioskStatus _kioskStatus = const AppKioskStatus.unavailable();
|
||||
|
||||
static bool get isEmulator => _environment.isEmulator;
|
||||
|
||||
@@ -65,6 +135,8 @@ abstract class AppRuntimeTool {
|
||||
|
||||
static bool get hasMultipleDisplays => displayCount >= 2;
|
||||
|
||||
static AppKioskStatus get kioskStatus => _kioskStatus;
|
||||
|
||||
static Future<void> initializePlatform() async {
|
||||
if (_initialized) return;
|
||||
try {
|
||||
@@ -72,16 +144,54 @@ abstract class AppRuntimeTool {
|
||||
'getRuntimeEnvironment',
|
||||
);
|
||||
_environment = AppRuntimeEnvironment.fromMap(data);
|
||||
_kioskStatus = await refreshKioskStatus();
|
||||
} on PlatformException {
|
||||
// 原生通道异常时按单屏处理,避免错误地启动副屏。
|
||||
_environment = const AppRuntimeEnvironment(
|
||||
isEmulator: false,
|
||||
displayCount: 1,
|
||||
);
|
||||
_kioskStatus = const AppKioskStatus.unavailable();
|
||||
}
|
||||
_initialized = true;
|
||||
}
|
||||
|
||||
static Future<AppKioskStatus> refreshKioskStatus() async {
|
||||
try {
|
||||
final data = await _channel.invokeMapMethod<dynamic, dynamic>(
|
||||
'getKioskStatus',
|
||||
);
|
||||
_kioskStatus = AppKioskStatus.fromMap(data);
|
||||
} on PlatformException {
|
||||
_kioskStatus = const AppKioskStatus.unavailable();
|
||||
}
|
||||
return _kioskStatus;
|
||||
}
|
||||
|
||||
/// 配置每次进入 App 时是否自动恢复 Kiosk,配置由 Android 原生层持久化。
|
||||
static Future<AppKioskStatus> setKioskAutoEnterEnabled(bool enabled) async {
|
||||
try {
|
||||
final data = await _channel.invokeMapMethod<dynamic, dynamic>(
|
||||
'setKioskAutoEnterEnabled',
|
||||
{'enabled': enabled},
|
||||
);
|
||||
_kioskStatus = AppKioskStatus.fromMap(data);
|
||||
} on PlatformException {
|
||||
// 保留原状态,设置页会据此判断本次配置没有生效。
|
||||
}
|
||||
return _kioskStatus;
|
||||
}
|
||||
|
||||
/// 只有原生层确认 Device Owner/Kiosk 可用时才允许退出到系统桌面。
|
||||
static Future<bool> exitKioskToAndroidHome() async {
|
||||
if (!_kioskStatus.canExitToAndroidHome) return false;
|
||||
try {
|
||||
return await _channel.invokeMethod<bool>('exitKioskToHome') ?? false;
|
||||
} on PlatformException {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static Future<AppNetworkSnapshot> getNetworkSnapshot() async {
|
||||
try {
|
||||
final data = await _channel.invokeMapMethod<dynamic, dynamic>(
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:common_utils/common_utils.dart';
|
||||
import 'package:flutter/widgets.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 {
|
||||
class SettingController extends GetxController with WidgetsBindingObserver {
|
||||
static const String _tag = '设置 Controller';
|
||||
|
||||
final appC = Get.find<AppControl>();
|
||||
final sysSetting = Rxn<SysSetting>();
|
||||
final userSetting = Rxn<UserSetting>();
|
||||
final manualHardwareInput = false.obs;
|
||||
final kioskStatus = AppRuntimeTool.kioskStatus.obs;
|
||||
final cuurentMenuType = SettingMenuType.baseInfo.obs;
|
||||
final isLoading = true.obs;
|
||||
final isBound = false.obs;
|
||||
@@ -27,6 +29,7 @@ class SettingController extends GetxController {
|
||||
@override
|
||||
void onInit() {
|
||||
super.onInit();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
_initialize();
|
||||
}
|
||||
|
||||
@@ -49,15 +52,22 @@ class SettingController extends GetxController {
|
||||
|
||||
@override
|
||||
void onClose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
_mqUpdateListen?.cancel();
|
||||
super.onClose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
if (state == AppLifecycleState.resumed) refreshKioskStatus();
|
||||
}
|
||||
|
||||
void doMenu(SettingMenuType type) {
|
||||
cuurentMenuType.value = type;
|
||||
}
|
||||
|
||||
Future<void> getSetting() async {
|
||||
await refreshKioskStatus();
|
||||
final setting = await AppSettingTool.getSysSetting();
|
||||
sysSetting.value = setting;
|
||||
userSetting.value = await AppSettingTool.getUserSetting();
|
||||
@@ -67,6 +77,10 @@ class SettingController extends GetxController {
|
||||
AppRuntimeTool.applyManualHardwareInput(setting.manualHardwareInput);
|
||||
}
|
||||
|
||||
Future<void> refreshKioskStatus() async {
|
||||
kioskStatus.value = await AppRuntimeTool.refreshKioskStatus();
|
||||
}
|
||||
|
||||
Future<void> setGlobalFontSize(SettingGlobalFontSize fontSize) async {
|
||||
final setting = sysSetting.value;
|
||||
if (setting == null || setting.globalFontSize == fontSize) return;
|
||||
@@ -255,4 +269,40 @@ class SettingController extends GetxController {
|
||||
sysSetting.refresh();
|
||||
Toast.success(msg: enabled ? '已设置为主控手动输入模式' : '已恢复真实硬件模式');
|
||||
}
|
||||
|
||||
/// 该配置只控制进入 App 时是否自动锁定;关闭后仍需通过管理密码退出当前 Kiosk。
|
||||
Future<void> setKioskAutoEnterEnabled(bool enabled) async {
|
||||
if (!kioskStatus.value.available) return;
|
||||
final status = await AppRuntimeTool.setKioskAutoEnterEnabled(enabled);
|
||||
kioskStatus.value = status;
|
||||
final applied = status.autoEnterEnabled == enabled &&
|
||||
(!enabled || status.active || status.lockTaskPermitted);
|
||||
if (!applied) {
|
||||
Toast.error(msg: 'Kiosk 自动开启配置失败,请检查设备策略');
|
||||
return;
|
||||
}
|
||||
Toast.success(
|
||||
msg: enabled ? '每次进入 App 将自动开启 Kiosk' : '已关闭进入 App 自动开启 Kiosk');
|
||||
}
|
||||
|
||||
/// 管理密码验证由按钮组件负责,此处只处理原生退出结果与失败反馈。
|
||||
Future<void> exitKioskToAndroidHome() async {
|
||||
if (!kioskStatus.value.canExitToAndroidHome) return;
|
||||
EasyLoading.show(
|
||||
status: '正在退出专用设备模式...',
|
||||
maskType: EasyLoadingMaskType.black,
|
||||
);
|
||||
try {
|
||||
final success = await AppRuntimeTool.exitKioskToAndroidHome();
|
||||
kioskStatus.value = await AppRuntimeTool.refreshKioskStatus();
|
||||
if (!success) {
|
||||
Toast.error(msg: '退出 Android 桌面失败,请检查设备策略');
|
||||
}
|
||||
} catch (e, stack) {
|
||||
logger.e('|$_tag|退出Kiosk失败error:|$e|stack:|$stack|');
|
||||
Toast.error(msg: '退出 Android 桌面失败,请稍后重试');
|
||||
} finally {
|
||||
EasyLoading.dismiss();
|
||||
}
|
||||
}
|
||||
}
|
||||
+44
-35
@@ -67,51 +67,60 @@ class _SettingMenu extends StatelessWidget {
|
||||
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(
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
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: [
|
||||
Container(
|
||||
width: 8.w,
|
||||
height: 8.w,
|
||||
decoration: BoxDecoration(
|
||||
color: bound
|
||||
? XfjDesignTokens.success
|
||||
: XfjDesignTokens.warning,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
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(width: 8.w),
|
||||
SizedBox(height: 8.h),
|
||||
Text(
|
||||
bound ? '设备已绑定' : '设备未绑定',
|
||||
bound ? '部分设置修改后会自动同步到本机服务。' : '请先在基本信息中绑定设备后使用业务设置。',
|
||||
style: theme.typography.sm.copyWith(
|
||||
color: bound
|
||||
? XfjDesignTokens.success
|
||||
: XfjDesignTokens.warning,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: XfjDesignTokens.muted,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (controller.kioskStatus.value.canExitToAndroidHome) ...[
|
||||
SizedBox(height: 8.h),
|
||||
Text(
|
||||
bound ? '部分设置修改后会自动同步到本机服务。' : '请先在基本信息中绑定设备后使用业务设置。',
|
||||
style: theme.typography.sm.copyWith(
|
||||
color: XfjDesignTokens.muted,
|
||||
),
|
||||
),
|
||||
const SettingKioskExitButton(),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}),
|
||||
],
|
||||
|
||||
@@ -3,6 +3,7 @@ library;
|
||||
export './setting_backup.dart';
|
||||
export './setting_baseInfo.dart';
|
||||
export './setting_face.dart';
|
||||
export './setting_kiosk_exit_button.dart';
|
||||
export './setting_pay.dart';
|
||||
export './setting_product.dart';
|
||||
export './setting_sync.dart';
|
||||
|
||||
@@ -136,6 +136,20 @@ Widget settingBaseInfo(BuildContext context) {
|
||||
? 'Android 虚拟机'
|
||||
: 'Android 真实设备',
|
||||
),
|
||||
SettingInfoRow(
|
||||
icon: controller.kioskStatus.value.available
|
||||
? FAssets.icons.shieldCheck
|
||||
: FAssets.icons.circleAlert,
|
||||
label: '专用设备 / Kiosk',
|
||||
subtitle: controller.kioskStatus.value.description,
|
||||
value: controller.kioskStatus.value.statusLabel,
|
||||
trailing: FSwitch(
|
||||
semanticLabel: '进入 App 自动开启 Kiosk',
|
||||
value: controller.kioskStatus.value.autoEnterEnabled,
|
||||
enabled: controller.kioskStatus.value.available,
|
||||
onChange: controller.setKioskAutoEnterEnabled,
|
||||
),
|
||||
),
|
||||
SettingInfoRow(
|
||||
icon: FAssets.icons.keyboard,
|
||||
label: '设置为主控',
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
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';
|
||||
|
||||
/// Kiosk 能力成立时才展示系统出口,普通全屏设备不能出现具有误导性的“退出桌面”按钮。
|
||||
class SettingKioskExitButton extends StatefulWidget {
|
||||
const SettingKioskExitButton({super.key});
|
||||
|
||||
@override
|
||||
State<SettingKioskExitButton> createState() => _SettingKioskExitButtonState();
|
||||
}
|
||||
|
||||
class _SettingKioskExitButtonState extends State<SettingKioskExitButton> {
|
||||
bool _busy = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final controller = Get.find<SettingController>();
|
||||
final theme = FTheme.of(context);
|
||||
return Obx(() {
|
||||
if (!controller.kioskStatus.value.canExitToAndroidHome) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return SizedBox(
|
||||
width: double.infinity,
|
||||
child: FButton(
|
||||
style: FButtonStyle.destructive,
|
||||
prefix: FIcon(FAssets.icons.house, color: Colors.white, size: 20.sp),
|
||||
label: Text(
|
||||
'退出 Android 桌面',
|
||||
style: theme.typography.sm.copyWith(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
onPress: _busy ? null : () => _exitToAndroidHome(context, controller),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _exitToAndroidHome(
|
||||
BuildContext context,
|
||||
SettingController controller,
|
||||
) async {
|
||||
if (_busy) return;
|
||||
setState(() => _busy = true);
|
||||
try {
|
||||
final verified = await terminalAdminPasswordDialog(
|
||||
context,
|
||||
actionLabel: '退出到 Android 桌面',
|
||||
);
|
||||
if (!verified) return;
|
||||
await controller.exitKioskToAndroidHome();
|
||||
} finally {
|
||||
if (mounted) setState(() => _busy = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user