feat(flutter): add Feishu OAuth binding UI — mirrors DingTalk flow
- AgentInstance model: add feishuUserId field - Instance card: show 飞书 binding badge (blue #3370FF) alongside DingTalk badge - Context menu: add 绑定飞书 / 重新绑定飞书 / 解绑飞书 options - _FeishuBindSheet: full OAuth-first binding sheet with polling, code fallback, countdown timer, success/expired/error states — same UX pattern as DingTalk Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
bbcf1d742d
commit
83ed55ce1c
|
|
@ -27,6 +27,7 @@ class AgentInstance {
|
|||
final String status; // deploying | running | stopped | error | removed
|
||||
final String? errorMessage;
|
||||
final String? dingTalkUserId;
|
||||
final String? feishuUserId;
|
||||
final DateTime createdAt;
|
||||
|
||||
const AgentInstance({
|
||||
|
|
@ -39,6 +40,7 @@ class AgentInstance {
|
|||
required this.status,
|
||||
this.errorMessage,
|
||||
this.dingTalkUserId,
|
||||
this.feishuUserId,
|
||||
required this.createdAt,
|
||||
});
|
||||
|
||||
|
|
@ -52,6 +54,7 @@ class AgentInstance {
|
|||
status: j['status'] as String? ?? 'unknown',
|
||||
errorMessage: j['errorMessage'] as String?,
|
||||
dingTalkUserId: j['dingTalkUserId'] as String?,
|
||||
feishuUserId: j['feishuUserId'] as String?,
|
||||
createdAt: DateTime.tryParse(j['createdAt'] as String? ?? '') ?? DateTime.now(),
|
||||
);
|
||||
}
|
||||
|
|
@ -379,6 +382,34 @@ class _InstanceCard extends StatelessWidget {
|
|||
),
|
||||
],
|
||||
const Divider(height: 1),
|
||||
// Feishu binding
|
||||
ListTile(
|
||||
leading: Icon(
|
||||
Icons.flight_takeoff_outlined,
|
||||
color: instance.feishuUserId != null
|
||||
? const Color(0xFF3370FF)
|
||||
: AppColors.textMuted,
|
||||
),
|
||||
title: Text(
|
||||
instance.feishuUserId != null ? '重新绑定飞书' : '绑定飞书',
|
||||
),
|
||||
subtitle: instance.feishuUserId != null
|
||||
? const Text('已绑定,点击可解绑或重新绑定', style: TextStyle(fontSize: 11))
|
||||
: null,
|
||||
onTap: () {
|
||||
Navigator.pop(ctx);
|
||||
_showFeishuBindSheet(context);
|
||||
},
|
||||
),
|
||||
if (instance.feishuUserId != null) ...[
|
||||
const Divider(height: 1),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.link_off, color: AppColors.textMuted),
|
||||
title: const Text('解绑飞书'),
|
||||
onTap: () { Navigator.pop(ctx); _handleFeishuUnbind(context); },
|
||||
),
|
||||
],
|
||||
const Divider(height: 1),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.person_remove_outlined, color: AppColors.error),
|
||||
title: Text(AppLocalizations.of(ctx).dismissButton, style: const TextStyle(color: AppColors.error)),
|
||||
|
|
@ -400,6 +431,52 @@ class _InstanceCard extends StatelessWidget {
|
|||
);
|
||||
}
|
||||
|
||||
void _showFeishuBindSheet(BuildContext context) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (ctx) => _FeishuBindSheet(instance: instance, onBound: onRefresh),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _handleFeishuUnbind(BuildContext context) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
title: const Text('解绑飞书'),
|
||||
content: Text('确定要解除「${instance.name}」与飞书的绑定吗?'),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('取消')),
|
||||
FilledButton(
|
||||
style: FilledButton.styleFrom(backgroundColor: AppColors.error),
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
child: const Text('解绑'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed != true || !context.mounted) return;
|
||||
try {
|
||||
final container = ProviderScope.containerOf(context);
|
||||
final dio = container.read(dioClientProvider);
|
||||
await dio.post('${ApiEndpoints.agent}/channels/feishu/unbind/${instance.id}');
|
||||
onRefresh?.call();
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('飞书已解绑')),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('解绑失败:${ErrorHandler.friendlyMessage(e)}'), backgroundColor: AppColors.error),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handleUnbind(BuildContext context) async {
|
||||
// Find Dio via context (we use a builder trick below)
|
||||
final confirmed = await showDialog<bool>(
|
||||
|
|
@ -544,23 +621,46 @@ class _InstanceCard extends StatelessWidget {
|
|||
),
|
||||
),
|
||||
|
||||
// DingTalk binding indicator
|
||||
if (instance.dingTalkUserId != null) ...[
|
||||
// Channel binding indicators
|
||||
if (instance.dingTalkUserId != null || instance.feishuUserId != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF1A73E8).withOpacity(0.08),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.chat_bubble_outline, size: 13, color: Color(0xFF1A73E8)),
|
||||
SizedBox(width: 5),
|
||||
Text('已绑定钉钉', style: TextStyle(fontSize: 11, color: Color(0xFF1A73E8), fontWeight: FontWeight.w500)),
|
||||
],
|
||||
),
|
||||
Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 6,
|
||||
children: [
|
||||
if (instance.dingTalkUserId != null)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF1A73E8).withOpacity(0.08),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.chat_bubble_outline, size: 13, color: Color(0xFF1A73E8)),
|
||||
SizedBox(width: 5),
|
||||
Text('已绑定钉钉', style: TextStyle(fontSize: 11, color: Color(0xFF1A73E8), fontWeight: FontWeight.w500)),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (instance.feishuUserId != null)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF3370FF).withOpacity(0.08),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.flight_takeoff_outlined, size: 13, color: Color(0xFF3370FF)),
|
||||
SizedBox(width: 5),
|
||||
Text('已绑定飞书', style: TextStyle(fontSize: 11, color: Color(0xFF3370FF), fontWeight: FontWeight.w500)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
|
||||
|
|
@ -1109,6 +1209,361 @@ class _DingTalkBindSheetState extends State<_DingTalkBindSheet> {
|
|||
]);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Feishu bind bottom sheet — OAuth-first (one-tap Authorize)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class _FeishuBindSheet extends StatefulWidget {
|
||||
final AgentInstance instance;
|
||||
final VoidCallback? onBound;
|
||||
|
||||
const _FeishuBindSheet({required this.instance, this.onBound});
|
||||
|
||||
@override
|
||||
State<_FeishuBindSheet> createState() => _FeishuBindSheetState();
|
||||
}
|
||||
|
||||
class _FeishuBindSheetState extends State<_FeishuBindSheet> {
|
||||
_BindPhase _phase = _BindPhase.idle;
|
||||
String? _errorMsg;
|
||||
Timer? _pollTimer;
|
||||
|
||||
String _code = '';
|
||||
DateTime? _expiresAt;
|
||||
Timer? _countdownTimer;
|
||||
int _secondsLeft = 0;
|
||||
|
||||
static const _feishuBlue = Color(0xFF3370FF);
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_pollTimer?.cancel();
|
||||
_countdownTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _startOAuth() async {
|
||||
setState(() { _phase = _BindPhase.loadingOAuth; _errorMsg = null; });
|
||||
try {
|
||||
final dio = ProviderScope.containerOf(context).read(dioClientProvider);
|
||||
final res = await dio.get(
|
||||
'${ApiEndpoints.agent}/channels/feishu/oauth/init',
|
||||
queryParameters: {'instanceId': widget.instance.id},
|
||||
);
|
||||
final oauthUrl = (res.data as Map<String, dynamic>)['oauthUrl'] as String;
|
||||
final uri = Uri.parse(oauthUrl);
|
||||
try {
|
||||
final session = await AudioSession.instance;
|
||||
await session.configure(const AudioSessionConfiguration(
|
||||
avAudioSessionCategory: AVAudioSessionCategory.playback,
|
||||
avAudioSessionCategoryOptions: AVAudioSessionCategoryOptions.mixWithOthers,
|
||||
avAudioSessionMode: AVAudioSessionMode.defaultMode,
|
||||
androidAudioAttributes: AndroidAudioAttributes(
|
||||
contentType: AndroidAudioContentType.music,
|
||||
usage: AndroidAudioUsage.media,
|
||||
),
|
||||
androidAudioFocusGainType: AndroidAudioFocusGainType.gainTransientMayDuck,
|
||||
));
|
||||
await session.setActive(true);
|
||||
} catch (_) {}
|
||||
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
setState(() { _phase = _BindPhase.waitingOAuth; });
|
||||
_startPolling();
|
||||
} catch (e) {
|
||||
setState(() { _phase = _BindPhase.error; _errorMsg = ErrorHandler.friendlyMessage(e); });
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _fetchCode() async {
|
||||
setState(() { _phase = _BindPhase.loadingCode; _errorMsg = null; });
|
||||
try {
|
||||
final dio = ProviderScope.containerOf(context).read(dioClientProvider);
|
||||
final res = await dio.post(
|
||||
'${ApiEndpoints.agent}/channels/feishu/bind/${widget.instance.id}',
|
||||
);
|
||||
final data = res.data as Map<String, dynamic>;
|
||||
final expiresAt = DateTime.fromMillisecondsSinceEpoch(data['expiresAt'] as int);
|
||||
setState(() {
|
||||
_code = data['code'] as String;
|
||||
_expiresAt = expiresAt;
|
||||
_secondsLeft = expiresAt.difference(DateTime.now()).inSeconds.clamp(0, 900);
|
||||
_phase = _BindPhase.waitingCode;
|
||||
});
|
||||
_startCountdown();
|
||||
_startPolling();
|
||||
} catch (e) {
|
||||
setState(() { _phase = _BindPhase.error; _errorMsg = ErrorHandler.friendlyMessage(e); });
|
||||
}
|
||||
}
|
||||
|
||||
void _startCountdown() {
|
||||
_countdownTimer?.cancel();
|
||||
_countdownTimer = Timer.periodic(const Duration(seconds: 1), (_) {
|
||||
if (!mounted) return;
|
||||
final left = _expiresAt!.difference(DateTime.now()).inSeconds;
|
||||
if (left <= 0) {
|
||||
_countdownTimer?.cancel();
|
||||
_pollTimer?.cancel();
|
||||
setState(() { _phase = _BindPhase.expired; });
|
||||
} else {
|
||||
setState(() { _secondsLeft = left; });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _startPolling() {
|
||||
_pollTimer?.cancel();
|
||||
_pollTimer = Timer.periodic(const Duration(seconds: 2), (_) async {
|
||||
if (!mounted) return;
|
||||
try {
|
||||
final dio = ProviderScope.containerOf(context).read(dioClientProvider);
|
||||
final res = await dio.get(
|
||||
'${ApiEndpoints.agent}/channels/feishu/status/${widget.instance.id}',
|
||||
);
|
||||
final bound = (res.data as Map<String, dynamic>)['bound'] as bool? ?? false;
|
||||
if (bound && mounted) {
|
||||
_pollTimer?.cancel();
|
||||
_countdownTimer?.cancel();
|
||||
try { await (await AudioSession.instance).setActive(false); } catch (_) {}
|
||||
setState(() { _phase = _BindPhase.success; });
|
||||
widget.onBound?.call();
|
||||
}
|
||||
} catch (_) {}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
decoration: const BoxDecoration(
|
||||
color: Color(0xFF1A1D2E),
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
|
||||
),
|
||||
padding: const EdgeInsets.fromLTRB(24, 12, 24, 32),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 36, height: 4,
|
||||
margin: const EdgeInsets.only(bottom: 20),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.textMuted.withOpacity(0.3),
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.flight_takeoff_outlined, color: _feishuBlue, size: 22),
|
||||
const SizedBox(width: 10),
|
||||
const Text('绑定飞书', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: AppColors.textPrimary)),
|
||||
const Spacer(),
|
||||
Text('「${widget.instance.name}」', style: const TextStyle(fontSize: 12, color: AppColors.textMuted)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
_buildPhaseContent(),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPhaseContent() => switch (_phase) {
|
||||
_BindPhase.idle => _buildIdle(),
|
||||
_BindPhase.loadingOAuth => _buildSpinner('正在获取授权地址…'),
|
||||
_BindPhase.waitingOAuth => _buildWaitingOAuth(),
|
||||
_BindPhase.loadingCode => _buildSpinner('正在获取验证码…'),
|
||||
_BindPhase.waitingCode => _buildWaitingCode(),
|
||||
_BindPhase.success => _buildSuccess(),
|
||||
_BindPhase.expired => _buildExpired(),
|
||||
_BindPhase.error => _buildError(),
|
||||
};
|
||||
|
||||
Widget _buildIdle() {
|
||||
return Column(
|
||||
children: [
|
||||
Container(
|
||||
width: 72, height: 72,
|
||||
decoration: BoxDecoration(
|
||||
color: _feishuBlue.withOpacity(0.12),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(Icons.flight_takeoff_outlined, color: _feishuBlue, size: 36),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text('一键连接飞书', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: AppColors.textPrimary)),
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
'点击下方按钮,飞书自动打开并请求授权\n点一下"同意"即完成绑定',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(fontSize: 13, color: AppColors.textMuted, height: 1.6),
|
||||
),
|
||||
const SizedBox(height: 28),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton.icon(
|
||||
icon: const Icon(Icons.open_in_new, size: 18),
|
||||
label: const Text('用飞书授权绑定', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
|
||||
onPressed: _startOAuth,
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: _feishuBlue,
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextButton(
|
||||
onPressed: _fetchCode,
|
||||
child: const Text('没有自动打开?使用验证码手动绑定', style: TextStyle(fontSize: 12, color: AppColors.textMuted)),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildWaitingOAuth() {
|
||||
return Column(
|
||||
children: [
|
||||
const SizedBox(height: 12),
|
||||
const SizedBox(width: 48, height: 48, child: CircularProgressIndicator(strokeWidth: 3, color: _feishuBlue)),
|
||||
const SizedBox(height: 20),
|
||||
const Text('飞书已打开', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: AppColors.textPrimary)),
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
'请在飞书中点击"同意授权"\n授权完成后此页面会自动跳转',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(fontSize: 13, color: AppColors.textMuted, height: 1.6),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton(
|
||||
onPressed: _startOAuth,
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: _feishuBlue,
|
||||
side: const BorderSide(color: _feishuBlue),
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
|
||||
),
|
||||
child: const Text('重新打开飞书'),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
TextButton(
|
||||
onPressed: _fetchCode,
|
||||
child: const Text('切换到验证码方式', style: TextStyle(fontSize: 12, color: AppColors.textMuted)),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildWaitingCode() {
|
||||
final mm = (_secondsLeft ~/ 60).toString().padLeft(2, '0');
|
||||
final ss = (_secondsLeft % 60).toString().padLeft(2, '0');
|
||||
return Column(
|
||||
children: [
|
||||
const Text('在飞书中找到 iAgent 机器人,发送以下验证码:',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(fontSize: 13, color: AppColors.textSecondary)),
|
||||
const SizedBox(height: 16),
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
Clipboard.setData(ClipboardData(text: _code));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('已复制'), duration: Duration(seconds: 1)));
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: _feishuBlue.withOpacity(0.12),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: _feishuBlue.withOpacity(0.4)),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(_code, style: const TextStyle(fontSize: 36, fontWeight: FontWeight.bold,
|
||||
color: _feishuBlue, letterSpacing: 6, fontFamily: 'monospace')),
|
||||
const SizedBox(width: 12),
|
||||
const Icon(Icons.copy_outlined, size: 16, color: _feishuBlue),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text('有效期 $mm:$ss',
|
||||
style: TextStyle(fontSize: 12, color: _secondsLeft < 60 ? AppColors.error : AppColors.textMuted)),
|
||||
const SizedBox(height: 16),
|
||||
const Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
SizedBox(width: 12, height: 12, child: CircularProgressIndicator(strokeWidth: 1.5, color: _feishuBlue)),
|
||||
SizedBox(width: 8),
|
||||
Text('等待绑定完成…', style: TextStyle(fontSize: 12, color: AppColors.textMuted)),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSpinner(String label) => Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 32),
|
||||
child: Column(children: [
|
||||
const CircularProgressIndicator(color: _feishuBlue),
|
||||
const SizedBox(height: 16),
|
||||
Text(label, style: const TextStyle(fontSize: 13, color: AppColors.textMuted)),
|
||||
]),
|
||||
);
|
||||
|
||||
Widget _buildSuccess() => Column(children: [
|
||||
const Icon(Icons.check_circle_rounded, color: Color(0xFF22C55E), size: 64),
|
||||
const SizedBox(height: 16),
|
||||
const Text('绑定成功!', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: AppColors.textPrimary)),
|
||||
const SizedBox(height: 8),
|
||||
Text('「${widget.instance.name}」已与飞书绑定\n现在可以直接在飞书中与它对话了',
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(fontSize: 13, color: AppColors.textMuted, height: 1.6)),
|
||||
const SizedBox(height: 24),
|
||||
SizedBox(width: double.infinity,
|
||||
child: FilledButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF22C55E),
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10))),
|
||||
child: const Text('完成'),
|
||||
)),
|
||||
]);
|
||||
|
||||
Widget _buildExpired() => Column(children: [
|
||||
const Icon(Icons.timer_off_outlined, color: AppColors.textMuted, size: 48),
|
||||
const SizedBox(height: 12),
|
||||
const Text('验证码已过期', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: AppColors.textPrimary)),
|
||||
const SizedBox(height: 20),
|
||||
SizedBox(width: double.infinity,
|
||||
child: FilledButton(
|
||||
onPressed: () => setState(() { _phase = _BindPhase.idle; }),
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10))),
|
||||
child: const Text('重新开始'),
|
||||
)),
|
||||
]);
|
||||
|
||||
Widget _buildError() => Column(children: [
|
||||
const Icon(Icons.error_outline, color: AppColors.error, size: 48),
|
||||
const SizedBox(height: 12),
|
||||
Text(_errorMsg ?? '操作失败',
|
||||
style: const TextStyle(fontSize: 13, color: AppColors.error), textAlign: TextAlign.center),
|
||||
const SizedBox(height: 20),
|
||||
SizedBox(width: double.infinity,
|
||||
child: OutlinedButton(
|
||||
onPressed: () => setState(() { _phase = _BindPhase.idle; }),
|
||||
child: const Text('重试'),
|
||||
)),
|
||||
]);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dismiss confirm dialog
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Reference in New Issue