feat(bridge): 新增 Phase 2 协议函数 — getDeviceInfo / getNetworkStatus / showToast / setStatusBar

- 新增 device_info_plus 和 connectivity_plus 依赖
- bridge_protocol.dart: AppShell API 从 8 个扩展到 12 个
- bridge_actions.dart: 实现 4 个 Dart 处理器 + 原生风格 Toast 动效组件
- shell_page.dart: 新增 4 个 action 分支
- test_bridge.html: 新增 4 个测试区块,Bridge 检测覆盖全部 12 个方法
This commit is contained in:
Max 2026-03-20 10:30:29 +08:00
parent dad37faecb
commit d4f7899351
6 changed files with 316 additions and 4 deletions

View File

@ -4,6 +4,8 @@ library;
import 'dart:async'; import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:file_picker/file_picker.dart'; import 'package:file_picker/file_picker.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';

View File

@ -1,5 +1,197 @@
part of '../../core_app.dart'; part of '../../core_app.dart';
// `bridge_actions.dart` /// Android WebView
// `shell_page.dart` `_handleBridgeMessage` Future<Map<String, dynamic>> _getDeviceInfoFromBridge() async {
// 使 final androidInfo = await DeviceInfoPlugin().androidInfo;
return <String, dynamic>{
'platform': 'android',
'brand': androidInfo.brand,
'model': androidInfo.model,
'manufacturer': androidInfo.manufacturer,
'androidVersion': androidInfo.version.release,
'sdkInt': androidInfo.version.sdkInt,
'isPhysicalDevice': androidInfo.isPhysicalDevice,
'display': androidInfo.display,
'product': androidInfo.product,
'appName': _env.appName,
'appKey': _env.appKey,
'shellVersion': _appShellBridgeVersion,
};
}
///
Future<Map<String, dynamic>> _getNetworkStatusFromBridge() async {
final results = await Connectivity().checkConnectivity();
// connectivity_plus List<ConnectivityResult>
final primary =
results.isNotEmpty ? results.first : ConnectivityResult.none;
String type;
switch (primary) {
case ConnectivityResult.wifi:
type = 'wifi';
case ConnectivityResult.mobile:
type = 'cellular';
case ConnectivityResult.ethernet:
type = 'ethernet';
case ConnectivityResult.bluetooth:
type = 'bluetooth';
case ConnectivityResult.vpn:
type = 'vpn';
case ConnectivityResult.none:
type = 'none';
case ConnectivityResult.other:
type = 'unknown';
}
return <String, dynamic>{
'connected': primary != ConnectivityResult.none,
'type': type,
};
}
/// Toast
///
/// 使 Overlay
void _showToastFromBridge(BuildContext context, Map<String, dynamic> payload) {
final message = (payload['message'] ?? '').toString();
if (message.isEmpty) return;
final duration = switch ((payload['duration'] ?? 'short').toString()) {
'long' => const Duration(milliseconds: 3500),
_ => const Duration(milliseconds: 2000),
};
final overlay = Overlay.of(context);
late final OverlayEntry entry;
entry = OverlayEntry(
builder: (context) => _ToastWidget(
message: message,
duration: duration,
onDismiss: () => entry.remove(),
),
);
overlay.insert(entry);
}
///
void _setStatusBarFromBridge(Map<String, dynamic> payload) {
final style = (payload['style'] ?? '').toString();
final color = payload['color']?.toString();
Brightness? brightness;
if (style == 'light') {
brightness = Brightness.light;
} else if (style == 'dark') {
brightness = Brightness.dark;
}
Color? bgColor;
if (color != null && color.isNotEmpty) {
final parsed = int.tryParse(
color.replaceFirst('#', '0xFF'),
);
if (parsed != null) bgColor = Color(parsed);
}
SystemChrome.setSystemUIOverlayStyle(
SystemUiOverlayStyle(
statusBarIconBrightness: brightness,
statusBarColor: bgColor ?? Colors.transparent,
),
);
}
// Toast
class _ToastWidget extends StatefulWidget {
const _ToastWidget({
required this.message,
required this.duration,
required this.onDismiss,
});
final String message;
final Duration duration;
final VoidCallback onDismiss;
@override
State<_ToastWidget> createState() => _ToastWidgetState();
}
class _ToastWidgetState extends State<_ToastWidget>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
late final Animation<double> _fadeAnimation;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 250),
);
_fadeAnimation = CurvedAnimation(
parent: _controller,
curve: Curves.easeInOut,
);
unawaited(_controller.forward());
unawaited(
Future<void>.delayed(widget.duration).then((_) {
if (mounted) {
unawaited(
_controller.reverse().then((_) {
if (mounted) widget.onDismiss();
}),
);
}
}),
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Positioned(
bottom: 100,
left: 0,
right: 0,
child: Center(
child: FadeTransition(
opacity: _fadeAnimation,
child: Material(
color: Colors.transparent,
child: Container(
constraints: const BoxConstraints(maxWidth: 300),
padding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 12,
),
decoration: BoxDecoration(
color: const Color(0xDD333333),
borderRadius: BorderRadius.circular(24),
),
child: Text(
widget.message,
textAlign: TextAlign.center,
style: const TextStyle(
color: Colors.white,
fontSize: 14,
),
),
),
),
),
),
);
}
}

View File

@ -93,6 +93,10 @@ String _buildAppShellBridgeScript() {
reloadPage: () => send('reloadPage'), reloadPage: () => send('reloadPage'),
goBack: () => send('goBack'), goBack: () => send('goBack'),
closeApp: () => send('closeApp'), closeApp: () => send('closeApp'),
getDeviceInfo: () => send('getDeviceInfo'),
getNetworkStatus: () => send('getNetworkStatus'),
showToast: (message, options = {}) => send('showToast', { message, ...options }),
setStatusBar: (options = {}) => send('setStatusBar', options),
}; };
window.dispatchEvent(new CustomEvent('app-shell-ready', { window.dispatchEvent(new CustomEvent('app-shell-ready', {

View File

@ -644,6 +644,16 @@ class _WebShellPageState extends State<WebShellPage>
); );
await SystemNavigator.pop(); await SystemNavigator.pop();
return; return;
case 'getDeviceInfo':
data = await _getDeviceInfoFromBridge();
case 'getNetworkStatus':
data = await _getNetworkStatusFromBridge();
case 'showToast':
_showToastFromBridge(context, payload);
data = true;
case 'setStatusBar':
_setStatusBarFromBridge(payload);
data = true;
default: default:
throw UnsupportedError('Unsupported AppShell action: $action'); throw UnsupportedError('Unsupported AppShell action: $action');
} }

View File

@ -8,6 +8,8 @@ environment:
flutter: '>=3.3.0' flutter: '>=3.3.0'
dependencies: dependencies:
connectivity_plus: ^7.0.0
device_info_plus: ^12.3.0
file_picker: ^10.3.10 file_picker: ^10.3.10
flutter: flutter:
sdk: flutter sdk: flutter

View File

@ -322,6 +322,41 @@
</div> </div>
</div> </div>
<!-- ══════════ 8.5 Phase 2: 新增能力 ══════════ -->
<div class="section-title">8.5 设备信息 getDeviceInfo</div>
<div class="card">
<div class="btn-row">
<button onclick="testGetDeviceInfo()">📱 获取设备信息</button>
</div>
<div class="preview-area" id="deviceInfoBridgePreview">点击按钮通过 Bridge 获取完整设备信息</div>
</div>
<div class="section-title">8.6 网络状态 getNetworkStatus</div>
<div class="card">
<div class="btn-row">
<button onclick="testGetNetworkStatus()">📡 获取网络状态</button>
</div>
<div class="preview-area" id="networkPreview">点击按钮获取当前网络连接类型</div>
</div>
<div class="section-title">8.7 原生 Toast showToast</div>
<div class="card">
<div class="btn-row">
<button onclick="testShowToast('short')">💬 短 Toast</button>
<button class="secondary" onclick="testShowToast('long')">💬 长 Toast</button>
</div>
</div>
<div class="section-title">8.8 状态栏控制 setStatusBar</div>
<div class="card">
<div class="btn-row">
<button onclick="testSetStatusBar('light')">☀️ 亮色图标</button>
<button class="secondary" onclick="testSetStatusBar('dark')">🌙 暗色图标</button>
<button class="warn" onclick="testSetStatusBar('accent')">🎨 主题色背景</button>
<button onclick="testSetStatusBar('reset')">🔄 重置</button>
</div>
</div>
<!-- ══════════ 9. 旧相机兼容层 ══════════ --> <!-- ══════════ 9. 旧相机兼容层 ══════════ -->
<div class="section-title">9. 旧相机兼容层 Legacy Camera</div> <div class="section-title">9. 旧相机兼容层 Legacy Camera</div>
<div class="card"> <div class="card">
@ -424,7 +459,7 @@ function checkBridge() {
log('err', 'Bridge', 'window.AppShell 未定义'); log('err', 'Bridge', 'window.AppShell 未定义');
return; return;
} }
const methods = ['pickImage','captureImage','pickFile','openExternal','requestPermissions','reloadPage','goBack','closeApp']; const methods = ['pickImage','captureImage','pickFile','openExternal','requestPermissions','reloadPage','goBack','closeApp','getDeviceInfo','getNetworkStatus','showToast','setStatusBar'];
const available = methods.filter(m => typeof shell[m] === 'function'); const available = methods.filter(m => typeof shell[m] === 'function');
const missing = methods.filter(m => typeof shell[m] !== 'function'); const missing = methods.filter(m => typeof shell[m] !== 'function');
@ -632,6 +667,73 @@ async function testCloseApp() {
} }
} }
// ═══════════════════════════════════════
// 8.58.8 Phase 2 新增能力
// ═══════════════════════════════════════
async function testGetDeviceInfo() {
if (!window.AppShell) { log('err', 'DeviceInfo', 'AppShell 不可用'); return; }
log('info', 'DeviceInfo', '调用 getDeviceInfo()…');
try {
const info = await AppShell.getDeviceInfo();
let html = '<b>设备信息 (Bridge):</b><br>';
const keys = Object.keys(info || {});
keys.forEach(k => {
html += `<b>${k}:</b> ${info[k]}<br>`;
});
setPreview('deviceInfoBridgePreview', html);
log('ok', 'DeviceInfo', info);
} catch (e) {
setPreview('deviceInfoBridgePreview', '❌ 错误: ' + e.message);
log('err', 'DeviceInfo', e.message);
}
}
async function testGetNetworkStatus() {
if (!window.AppShell) { log('err', 'Network', 'AppShell 不可用'); return; }
log('info', 'Network', '调用 getNetworkStatus()…');
try {
const status = await AppShell.getNetworkStatus();
const icon = status.connected ? '✅' : '❌';
let html = `${icon} <b>连接状态:</b> ${status.connected ? '已连接' : '未连接'}<br>`;
html += `<b>网络类型:</b> ${status.type}<br>`;
setPreview('networkPreview', html);
log('ok', 'Network', status);
} catch (e) {
setPreview('networkPreview', '❌ 错误: ' + e.message);
log('err', 'Network', e.message);
}
}
async function testShowToast(duration) {
if (!window.AppShell) { log('err', 'Toast', 'AppShell 不可用'); return; }
const msg = duration === 'long' ? '这是一条长时间的原生 Toast 提示 (3.5s)' : '原生 Toast 测试 ✅';
log('info', 'Toast', `调用 showToast('${msg}', {duration: '${duration}'})…`);
try {
await AppShell.showToast(msg, { duration });
log('ok', 'Toast', 'showToast 完成');
} catch (e) {
log('err', 'Toast', e.message);
}
}
async function testSetStatusBar(preset) {
if (!window.AppShell) { log('err', 'StatusBar', 'AppShell 不可用'); return; }
let options = {};
switch (preset) {
case 'light': options = { style: 'light' }; break;
case 'dark': options = { style: 'dark' }; break;
case 'accent': options = { style: 'light', color: '#3ED37B' }; break;
case 'reset': options = { style: 'dark', color: '#00000000' }; break;
}
log('info', 'StatusBar', `调用 setStatusBar(${JSON.stringify(options)})…`);
try {
await AppShell.setStatusBar(options);
log('ok', 'StatusBar', 'setStatusBar 完成, preset=' + preset);
} catch (e) {
log('err', 'StatusBar', e.message);
}
}
// ═══════════════════════════════════════ // ═══════════════════════════════════════
// 9. 旧相机兼容层 // 9. 旧相机兼容层
// ═══════════════════════════════════════ // ═══════════════════════════════════════