refactor: 移除前端 Mock API,新增共享组件,优化认证流程
- 删除 mock_api_client、mock_calendar_service、mock_history_service - 新增 fixed_length_code_input、link_button、message_composer 共享组件 - 优化登录/注册/密码重置页面使用新组件 - 简化 injection.dart 移除 mock 分支 - 更新 env.dart 配置(BACKEND_URL 替换 API_URL) - 后端 agentscope 工具和测试更新 - 重构 AGENTS.md 文档结构 - 新增 deploy/ 目录和 protocol 文档
This commit is contained in:
@@ -0,0 +1,187 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../../core/theme/design_tokens.dart';
|
||||
|
||||
typedef CodeValueChanged = void Function(String value);
|
||||
|
||||
class FixedLengthCodeInput extends StatefulWidget {
|
||||
final TextEditingController controller;
|
||||
final int length;
|
||||
final CodeValueChanged? onChanged;
|
||||
final TextInputType keyboardType;
|
||||
final Iterable<String>? allowedCharacters;
|
||||
final bool uppercase;
|
||||
final String semanticLabel;
|
||||
|
||||
const FixedLengthCodeInput({
|
||||
required this.controller,
|
||||
required this.length,
|
||||
required this.semanticLabel,
|
||||
super.key,
|
||||
this.onChanged,
|
||||
this.keyboardType = TextInputType.text,
|
||||
this.allowedCharacters,
|
||||
this.uppercase = false,
|
||||
});
|
||||
|
||||
@override
|
||||
State<FixedLengthCodeInput> createState() => _FixedLengthCodeInputState();
|
||||
}
|
||||
|
||||
class _FixedLengthCodeInputState extends State<FixedLengthCodeInput> {
|
||||
late final FocusNode _focusNode;
|
||||
bool _isFocused = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_focusNode = FocusNode();
|
||||
_focusNode.addListener(_onFocusChanged);
|
||||
widget.controller.addListener(_onControllerChanged);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant FixedLengthCodeInput oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.controller != widget.controller) {
|
||||
oldWidget.controller.removeListener(_onControllerChanged);
|
||||
widget.controller.addListener(_onControllerChanged);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.controller.removeListener(_onControllerChanged);
|
||||
_focusNode.removeListener(_onFocusChanged);
|
||||
_focusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onFocusChanged() {
|
||||
if (_isFocused != _focusNode.hasFocus) {
|
||||
_isFocused = _focusNode.hasFocus;
|
||||
}
|
||||
}
|
||||
|
||||
void _onControllerChanged() {
|
||||
if (mounted) {
|
||||
setState(() {});
|
||||
}
|
||||
}
|
||||
|
||||
void _handleRawChanged(String rawValue) {
|
||||
final normalized = _normalize(rawValue);
|
||||
if (normalized != widget.controller.text) {
|
||||
widget.controller.value = TextEditingValue(
|
||||
text: normalized,
|
||||
selection: TextSelection.collapsed(offset: normalized.length),
|
||||
);
|
||||
}
|
||||
widget.onChanged?.call(normalized);
|
||||
}
|
||||
|
||||
String _normalize(String value) {
|
||||
var output = widget.uppercase ? value.toUpperCase() : value;
|
||||
|
||||
if (widget.allowedCharacters != null) {
|
||||
final allow = widget.allowedCharacters!.toSet();
|
||||
output = output.split('').where(allow.contains).join();
|
||||
}
|
||||
|
||||
if (output.length > widget.length) {
|
||||
output = output.substring(0, widget.length);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final chars = widget.controller.text.split('');
|
||||
final slotHeight = AppSpacing.xl * 2;
|
||||
final slotSpacing = AppSpacing.sm;
|
||||
|
||||
return Semantics(
|
||||
label: widget.semanticLabel,
|
||||
child: GestureDetector(
|
||||
onTap: () => _focusNode.requestFocus(),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: SizedBox(
|
||||
height: slotHeight,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
Opacity(
|
||||
opacity: 0,
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
height: slotHeight,
|
||||
child: TextField(
|
||||
controller: widget.controller,
|
||||
focusNode: _focusNode,
|
||||
keyboardType: widget.keyboardType,
|
||||
inputFormatters: [
|
||||
LengthLimitingTextInputFormatter(widget.length),
|
||||
],
|
||||
onChanged: _handleRawChanged,
|
||||
autofillHints: const [AutofillHints.oneTimeCode],
|
||||
),
|
||||
),
|
||||
),
|
||||
IgnorePointer(
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
for (var index = 0; index < widget.length; index++) ...[
|
||||
Expanded(
|
||||
child: _buildCodeCell(
|
||||
index: index,
|
||||
chars: chars,
|
||||
slotHeight: slotHeight,
|
||||
),
|
||||
),
|
||||
if (index != widget.length - 1)
|
||||
SizedBox(width: slotSpacing),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCodeCell({
|
||||
required int index,
|
||||
required List<String> chars,
|
||||
required double slotHeight,
|
||||
}) {
|
||||
final hasChar = index < chars.length;
|
||||
final isActive =
|
||||
(chars.length == index && _isFocused) ||
|
||||
(chars.length >= widget.length && index == widget.length - 1);
|
||||
|
||||
return Container(
|
||||
height: slotHeight,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.white,
|
||||
borderRadius: BorderRadius.circular(AppRadius.sm),
|
||||
border: Border.all(
|
||||
color: isActive ? AppColors.primary : AppColors.slate300,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
hasChar ? chars[index] : '',
|
||||
style: const TextStyle(
|
||||
fontSize: AppSpacing.xl,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.slate900,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/theme/design_tokens.dart';
|
||||
|
||||
class LinkButton extends StatelessWidget {
|
||||
const LinkButton({
|
||||
super.key,
|
||||
required this.text,
|
||||
required this.onTap,
|
||||
this.enabled = true,
|
||||
this.textAlign = TextAlign.center,
|
||||
});
|
||||
|
||||
final String text;
|
||||
final VoidCallback? onTap;
|
||||
final bool enabled;
|
||||
final TextAlign textAlign;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
height: 44,
|
||||
child: TextButton(
|
||||
onPressed: enabled ? onTap : null,
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: enabled ? AppColors.slate500 : AppColors.slate300,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.md,
|
||||
vertical: AppSpacing.sm,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(AppRadius.sm),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
text,
|
||||
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500),
|
||||
textAlign: textAlign,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
|
||||
import '../../core/theme/design_tokens.dart';
|
||||
|
||||
enum MessageComposerMode { text, holdToSpeak }
|
||||
|
||||
enum MessageComposerProcess { idle, recording, transcribing }
|
||||
|
||||
const messageComposerContainerKey = ValueKey('message_composer_container');
|
||||
const messageComposerPlusButtonKey = ValueKey('message_composer_plus_button');
|
||||
const messageComposerRightButtonKey = ValueKey('message_composer_right_button');
|
||||
const messageComposerHoldAreaKey = ValueKey('message_composer_hold_area');
|
||||
const messageComposerRecordingHintKey = ValueKey(
|
||||
'message_composer_recording_hint',
|
||||
);
|
||||
const _holdActivateDurationMs = 120;
|
||||
|
||||
class MessageComposer extends StatelessWidget {
|
||||
const MessageComposer({
|
||||
super.key,
|
||||
required this.mode,
|
||||
required this.process,
|
||||
required this.hasMessage,
|
||||
required this.isWaitingAgent,
|
||||
required this.iconSize,
|
||||
required this.composerMinHeight,
|
||||
required this.onTapPlus,
|
||||
required this.onTapRightAction,
|
||||
required this.onHoldToSpeakStart,
|
||||
required this.onHoldToSpeakEnd,
|
||||
required this.onHoldToSpeakMoveUpdate,
|
||||
required this.onHoldToSpeakCancel,
|
||||
required this.textInputChild,
|
||||
required this.recordingAnimation,
|
||||
this.holdToSpeakText = '按住说话',
|
||||
this.recordingText = '松手发送',
|
||||
this.transcribingText = '语音识别中...',
|
||||
this.recordingHintText = '松开发送,上滑取消',
|
||||
this.showRecordingInlineFeedback = true,
|
||||
});
|
||||
|
||||
final MessageComposerMode mode;
|
||||
final MessageComposerProcess process;
|
||||
final bool hasMessage;
|
||||
final bool isWaitingAgent;
|
||||
final double iconSize;
|
||||
final double composerMinHeight;
|
||||
final VoidCallback onTapPlus;
|
||||
final VoidCallback onTapRightAction;
|
||||
final VoidCallback onHoldToSpeakStart;
|
||||
final VoidCallback onHoldToSpeakEnd;
|
||||
final ValueChanged<LongPressMoveUpdateDetails> onHoldToSpeakMoveUpdate;
|
||||
final VoidCallback onHoldToSpeakCancel;
|
||||
final Widget textInputChild;
|
||||
final Widget recordingAnimation;
|
||||
final String holdToSpeakText;
|
||||
final String recordingText;
|
||||
final String transcribingText;
|
||||
final String recordingHintText;
|
||||
final bool showRecordingInlineFeedback;
|
||||
|
||||
bool get _isHoldMode => mode == MessageComposerMode.holdToSpeak;
|
||||
|
||||
bool get _isRecording => process == MessageComposerProcess.recording;
|
||||
|
||||
bool get _isTranscribing => process == MessageComposerProcess.transcribing;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
key: messageComposerContainerKey,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.md,
|
||||
vertical: AppSpacing.xs,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.white,
|
||||
borderRadius: BorderRadius.circular(AppRadius.full),
|
||||
border: Border.all(color: AppColors.slate200),
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: AppColors.slate200,
|
||||
blurRadius: AppRadius.lg,
|
||||
offset: Offset(AppSpacing.none, AppSpacing.xs),
|
||||
),
|
||||
BoxShadow(
|
||||
color: AppColors.white,
|
||||
blurRadius: AppRadius.md,
|
||||
offset: Offset(AppSpacing.none, -AppSpacing.xs),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
IgnorePointer(
|
||||
ignoring: _isRecording && _isHoldMode,
|
||||
child: Opacity(
|
||||
opacity: _isRecording && _isHoldMode ? AppSpacing.none : 1,
|
||||
child: IconButton(
|
||||
key: messageComposerPlusButtonKey,
|
||||
visualDensity: VisualDensity.compact,
|
||||
onPressed: onTapPlus,
|
||||
icon: Icon(
|
||||
LucideIcons.plus,
|
||||
size: iconSize,
|
||||
color: AppColors.slate500,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
Expanded(child: _buildCenterArea()),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
IconButton(
|
||||
key: messageComposerRightButtonKey,
|
||||
visualDensity: VisualDensity.compact,
|
||||
onPressed: onTapRightAction,
|
||||
icon: _isTranscribing
|
||||
? const SizedBox(
|
||||
width: AppSpacing.lg,
|
||||
height: AppSpacing.lg,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: AppSpacing.xs / 2,
|
||||
color: AppColors.blue600,
|
||||
),
|
||||
)
|
||||
: Icon(
|
||||
_resolveRightIcon(),
|
||||
size: iconSize,
|
||||
color: _resolveRightIconColor(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCenterArea() {
|
||||
return SizedBox(
|
||||
height: composerMinHeight,
|
||||
child: AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 180),
|
||||
switchInCurve: Curves.easeOut,
|
||||
switchOutCurve: Curves.easeOut,
|
||||
child: _isHoldMode
|
||||
? _buildHoldToSpeakArea(key: const ValueKey('hold_mode'))
|
||||
: _buildTextInputArea(key: const ValueKey('text_mode')),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTextInputArea({required Key key}) {
|
||||
return SizedBox(key: key, height: composerMinHeight, child: textInputChild);
|
||||
}
|
||||
|
||||
Widget _buildHoldToSpeakArea({required Key key}) {
|
||||
return RawGestureDetector(
|
||||
key: messageComposerHoldAreaKey,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
gestures: {
|
||||
LongPressGestureRecognizer:
|
||||
GestureRecognizerFactoryWithHandlers<LongPressGestureRecognizer>(
|
||||
() => LongPressGestureRecognizer(
|
||||
duration: const Duration(milliseconds: _holdActivateDurationMs),
|
||||
),
|
||||
(instance) {
|
||||
instance.onLongPressStart = (_) => onHoldToSpeakStart();
|
||||
instance.onLongPressEnd = (_) => onHoldToSpeakEnd();
|
||||
instance.onLongPressMoveUpdate = onHoldToSpeakMoveUpdate;
|
||||
instance.onLongPressCancel = onHoldToSpeakCancel;
|
||||
},
|
||||
),
|
||||
},
|
||||
child: Container(
|
||||
key: key,
|
||||
width: double.infinity,
|
||||
height: composerMinHeight,
|
||||
alignment: Alignment.center,
|
||||
child: _buildHoldToSpeakContent(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHoldToSpeakContent() {
|
||||
if (_isRecording) {
|
||||
if (!showRecordingInlineFeedback) {
|
||||
return Align(
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
recordingText,
|
||||
style: const TextStyle(color: AppColors.slate700),
|
||||
),
|
||||
);
|
||||
}
|
||||
return Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
recordingAnimation,
|
||||
const SizedBox(height: AppSpacing.xs),
|
||||
Text(
|
||||
recordingHintText,
|
||||
key: messageComposerRecordingHintKey,
|
||||
style: const TextStyle(color: AppColors.slate500),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
if (_isTranscribing) {
|
||||
return Align(
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
transcribingText,
|
||||
style: const TextStyle(color: AppColors.slate500),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Align(
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
holdToSpeakText,
|
||||
style: const TextStyle(color: AppColors.slate500),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
IconData _resolveRightIcon() {
|
||||
if (isWaitingAgent) {
|
||||
return LucideIcons.square;
|
||||
}
|
||||
if (hasMessage) {
|
||||
return LucideIcons.send;
|
||||
}
|
||||
return _isHoldMode ? LucideIcons.keyboard : LucideIcons.mic;
|
||||
}
|
||||
|
||||
Color _resolveRightIconColor() {
|
||||
if (isWaitingAgent || hasMessage) {
|
||||
return AppColors.blue600;
|
||||
}
|
||||
return AppColors.slate500;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/theme/design_tokens.dart';
|
||||
|
||||
class PageHeader extends StatelessWidget {
|
||||
final Widget? leading;
|
||||
final Widget? trailing;
|
||||
@@ -32,20 +34,23 @@ class BackButton extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onPressed ?? () => Navigator.of(context).pop(),
|
||||
child: Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF8FAFF),
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
border: Border.all(color: const Color(0xFFDEE7F6)),
|
||||
return SizedBox(
|
||||
width: AppSpacing.xxl * 2,
|
||||
height: AppSpacing.xxl * 2,
|
||||
child: TextButton(
|
||||
onPressed: onPressed ?? () => Navigator.of(context).pop(),
|
||||
style: TextButton.styleFrom(
|
||||
padding: const EdgeInsets.all(AppSpacing.none),
|
||||
backgroundColor: AppColors.surfaceTertiary,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(AppRadius.full),
|
||||
side: const BorderSide(color: AppColors.borderTertiary),
|
||||
),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.chevron_left,
|
||||
size: 18,
|
||||
color: Color(0xFF334155),
|
||||
size: AppSpacing.lg + AppSpacing.xs,
|
||||
color: AppColors.slate700,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user