From a75c868bca0d7a2c1aedbbde14769122ef229568 Mon Sep 17 00:00:00 2001 From: qzl Date: Mon, 16 Mar 2026 16:11:16 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=20AppLoadingIndicato?= =?UTF-8?q?r=20=E5=92=8C=20AppPressable=20=E7=BB=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 AppLoadingIndicator 加载指示器组件 - 新增 AppPressable 按压反馈组件 - 新增 AppSheetInputField 输入框组件 - 更新 AppButton 和其他共享组件 --- apps/lib/shared/widgets/app_button.dart | 23 ++-- .../shared/widgets/app_loading_indicator.dart | 107 ++++++++++++++++++ apps/lib/shared/widgets/app_pressable.dart | 52 +++++++++ .../shared/widgets/app_sheet_input_field.dart | 106 +++++++++++++++++ apps/lib/shared/widgets/message_composer.dart | 14 +-- apps/lib/shared/widgets/page_header.dart | 96 +++++++++++++--- 6 files changed, 360 insertions(+), 38 deletions(-) create mode 100644 apps/lib/shared/widgets/app_loading_indicator.dart create mode 100644 apps/lib/shared/widgets/app_pressable.dart create mode 100644 apps/lib/shared/widgets/app_sheet_input_field.dart diff --git a/apps/lib/shared/widgets/app_button.dart b/apps/lib/shared/widgets/app_button.dart index 40aea53..5ac74c0 100644 --- a/apps/lib/shared/widgets/app_button.dart +++ b/apps/lib/shared/widgets/app_button.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import '../../core/theme/design_tokens.dart'; +import 'app_loading_indicator.dart'; class AppButton extends StatelessWidget { const AppButton({ @@ -47,13 +48,10 @@ class AppButton extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: AppSpacing.xl), ), child: isLoading - ? SizedBox( - width: 18, - height: 18, - child: CircularProgressIndicator( - strokeWidth: 2.2, - color: AppColors.authSecondaryButtonText, - ), + ? const AppLoadingIndicator( + variant: AppLoadingVariant.button, + color: AppColors.authSecondaryButtonText, + trackColor: AppColors.authSecondaryButtonBorder, ) : Text( text, @@ -115,13 +113,10 @@ class AppButton extends StatelessWidget { ), ), child: isLoading - ? const SizedBox( - width: 18, - height: 18, - child: CircularProgressIndicator( - strokeWidth: 2.2, - color: AppColors.authPrimaryButtonText, - ), + ? const AppLoadingIndicator( + variant: AppLoadingVariant.button, + color: AppColors.authPrimaryButtonText, + trackColor: AppColors.blue400, ) : Text( text, diff --git a/apps/lib/shared/widgets/app_loading_indicator.dart b/apps/lib/shared/widgets/app_loading_indicator.dart new file mode 100644 index 0000000..c88440c --- /dev/null +++ b/apps/lib/shared/widgets/app_loading_indicator.dart @@ -0,0 +1,107 @@ +import 'package:flutter/material.dart'; + +import '../../core/theme/design_tokens.dart'; + +enum AppLoadingVariant { surface, inline, button } + +class AppLoadingIndicator extends StatelessWidget { + const AppLoadingIndicator({ + super.key, + this.variant = AppLoadingVariant.surface, + this.size, + this.strokeWidth, + this.color, + this.trackColor, + this.withContainer, + }); + + final AppLoadingVariant variant; + final double? size; + final double? strokeWidth; + final Color? color; + final Color? trackColor; + final bool? withContainer; + + double get _resolvedSize { + return size ?? + switch (variant) { + AppLoadingVariant.surface => 22, + AppLoadingVariant.inline => 16, + AppLoadingVariant.button => 18, + }; + } + + double get _resolvedStrokeWidth { + return strokeWidth ?? + switch (variant) { + AppLoadingVariant.surface => 2.2, + AppLoadingVariant.inline => 2, + AppLoadingVariant.button => 2.2, + }; + } + + Color get _resolvedColor { + return color ?? + switch (variant) { + AppLoadingVariant.surface => AppColors.blue500, + AppLoadingVariant.inline => AppColors.slate500, + AppLoadingVariant.button => AppColors.white, + }; + } + + Color get _resolvedTrackColor { + return trackColor ?? + switch (variant) { + AppLoadingVariant.surface => AppColors.blue100, + AppLoadingVariant.inline => AppColors.slate200, + AppLoadingVariant.button => AppColors.blue300, + }; + } + + bool get _resolvedWithContainer { + return withContainer ?? + switch (variant) { + AppLoadingVariant.surface => true, + AppLoadingVariant.inline => false, + AppLoadingVariant.button => false, + }; + } + + Widget _buildSpinner() { + return SizedBox( + width: _resolvedSize, + height: _resolvedSize, + child: CircularProgressIndicator( + strokeWidth: _resolvedStrokeWidth, + color: _resolvedColor, + backgroundColor: _resolvedTrackColor, + ), + ); + } + + @override + Widget build(BuildContext context) { + if (!_resolvedWithContainer) { + return _buildSpinner(); + } + + return Container( + width: _resolvedSize + AppSpacing.md, + height: _resolvedSize + AppSpacing.md, + padding: const EdgeInsets.all(AppSpacing.xs), + decoration: BoxDecoration( + color: AppColors.white, + borderRadius: BorderRadius.circular(AppRadius.full), + border: Border.all(color: AppColors.borderSecondary), + boxShadow: [ + BoxShadow( + color: AppColors.slate200.withValues(alpha: 0.55), + blurRadius: AppRadius.md, + offset: const Offset(0, AppSpacing.xs), + ), + ], + ), + child: _buildSpinner(), + ); + } +} diff --git a/apps/lib/shared/widgets/app_pressable.dart b/apps/lib/shared/widgets/app_pressable.dart new file mode 100644 index 0000000..2709178 --- /dev/null +++ b/apps/lib/shared/widgets/app_pressable.dart @@ -0,0 +1,52 @@ +import 'package:flutter/material.dart'; + +import '../../core/theme/design_tokens.dart'; + +class AppPressable extends StatefulWidget { + const AppPressable({ + super.key, + required this.child, + this.onTap, + this.borderRadius, + this.pressedScale = 0.97, + }); + + final Widget child; + final VoidCallback? onTap; + final BorderRadius? borderRadius; + final double pressedScale; + + @override + State createState() => _AppPressableState(); +} + +class _AppPressableState extends State { + bool _isPressed = false; + + @override + Widget build(BuildContext context) { + return AnimatedScale( + scale: _isPressed ? widget.pressedScale : 1, + duration: const Duration(milliseconds: 110), + curve: Curves.easeOut, + child: Material( + color: Colors.transparent, + child: InkWell( + borderRadius: widget.borderRadius, + onTap: widget.onTap, + onHighlightChanged: (pressed) { + if (_isPressed == pressed) { + return; + } + setState(() { + _isPressed = pressed; + }); + }, + splashColor: AppColors.blue100.withValues(alpha: 0.32), + highlightColor: AppColors.blue50.withValues(alpha: 0.28), + child: widget.child, + ), + ), + ); + } +} diff --git a/apps/lib/shared/widgets/app_sheet_input_field.dart b/apps/lib/shared/widgets/app_sheet_input_field.dart new file mode 100644 index 0000000..af8b114 --- /dev/null +++ b/apps/lib/shared/widgets/app_sheet_input_field.dart @@ -0,0 +1,106 @@ +import 'package:flutter/material.dart'; + +import '../../core/theme/design_tokens.dart'; + +class AppSheetInputField extends StatefulWidget { + const AppSheetInputField({ + super.key, + required this.controller, + required this.label, + required this.hint, + this.maxLines = 1, + this.autofocus = false, + }); + + final TextEditingController controller; + final String label; + final String hint; + final int maxLines; + final bool autofocus; + + @override + State createState() => _AppSheetInputFieldState(); +} + +class _AppSheetInputFieldState extends State { + final FocusNode _focusNode = FocusNode(); + bool _isFocused = false; + + @override + void initState() { + super.initState(); + _focusNode.addListener(_onFocusChange); + } + + @override + void dispose() { + _focusNode.removeListener(_onFocusChange); + _focusNode.dispose(); + super.dispose(); + } + + void _onFocusChange() { + if (_isFocused == _focusNode.hasFocus) { + return; + } + setState(() { + _isFocused = _focusNode.hasFocus; + }); + } + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.label, + style: const TextStyle( + fontFamily: 'Inter', + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppColors.slate700, + ), + ), + const SizedBox(height: AppSpacing.sm), + AnimatedContainer( + duration: const Duration(milliseconds: 140), + curve: Curves.easeOut, + decoration: BoxDecoration( + color: AppColors.slate50, + borderRadius: BorderRadius.circular(AppRadius.md), + border: Border.all( + color: _isFocused + ? AppColors.borderQuaternary + : AppColors.borderSecondary, + ), + boxShadow: _isFocused + ? [ + BoxShadow( + color: AppColors.blue200.withValues(alpha: 0.35), + blurRadius: AppRadius.sm, + offset: const Offset(0, AppSpacing.xs / 2), + ), + ] + : const [], + ), + child: TextField( + controller: widget.controller, + focusNode: _focusNode, + autofocus: widget.autofocus, + maxLines: widget.maxLines, + decoration: InputDecoration( + hintText: widget.hint, + hintStyle: const TextStyle(color: AppColors.slate400), + border: InputBorder.none, + contentPadding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.md, + ), + ), + ), + ), + ], + ); + } +} diff --git a/apps/lib/shared/widgets/message_composer.dart b/apps/lib/shared/widgets/message_composer.dart index 08eaae2..f477c47 100644 --- a/apps/lib/shared/widgets/message_composer.dart +++ b/apps/lib/shared/widgets/message_composer.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:lucide_icons/lucide_icons.dart'; import '../../core/theme/design_tokens.dart'; +import 'app_loading_indicator.dart'; enum MessageComposerMode { text, holdToSpeak } @@ -125,13 +126,12 @@ class MessageComposer extends StatelessWidget { visualDensity: VisualDensity.compact, onPressed: onTapRightAction, icon: _isTranscribing - ? const SizedBox( - width: AppSpacing.lg, - height: AppSpacing.lg, - child: CircularProgressIndicator( - strokeWidth: AppSpacing.xs / 2, - color: AppColors.blue600, - ), + ? const AppLoadingIndicator( + variant: AppLoadingVariant.inline, + size: AppSpacing.lg, + strokeWidth: AppSpacing.xs / 2, + color: AppColors.blue600, + trackColor: AppColors.blue100, ) : Icon( _resolveRightIcon(), diff --git a/apps/lib/shared/widgets/page_header.dart b/apps/lib/shared/widgets/page_header.dart index 2933316..b2c7183 100644 --- a/apps/lib/shared/widgets/page_header.dart +++ b/apps/lib/shared/widgets/page_header.dart @@ -17,6 +17,7 @@ class PageHeader extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: 20), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.center, children: [ leading ?? const SizedBox.shrink(), trailing ?? const SizedBox.shrink(), @@ -27,30 +28,91 @@ class PageHeader extends StatelessWidget { } } -class BackButton extends StatelessWidget { +class BackButton extends StatefulWidget { final VoidCallback? onPressed; const BackButton({super.key, this.onPressed}); + @override + State createState() => _BackButtonState(); +} + +class _BackButtonState extends State { + bool _isPressed = false; + @override Widget build(BuildContext context) { - 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), - ), + final background = _isPressed ? AppColors.surfaceInfo : AppColors.white; + final borderColor = _isPressed + ? AppColors.borderQuaternary + : AppColors.borderTertiary; + + return AnimatedScale( + scale: _isPressed ? 0.96 : 1, + duration: const Duration(milliseconds: 110), + curve: Curves.easeOut, + child: AnimatedContainer( + duration: const Duration(milliseconds: 110), + decoration: BoxDecoration( + color: background, + borderRadius: BorderRadius.circular(AppRadius.full), + border: Border.all(color: borderColor), + boxShadow: _isPressed + ? const [] + : [ + const BoxShadow( + color: AppColors.white, + blurRadius: AppRadius.sm, + offset: Offset(0, -1), + ), + BoxShadow( + color: AppColors.slate200.withValues(alpha: 0.42), + blurRadius: AppRadius.md, + offset: const Offset(0, AppSpacing.xs), + ), + ], ), - child: const Icon( - Icons.chevron_left, - size: AppSpacing.lg + AppSpacing.xs, - color: AppColors.slate700, + child: SizedBox( + width: AppSpacing.xl * 2, + height: AppSpacing.xl * 2, + child: Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(AppRadius.full), + onTap: widget.onPressed ?? () => Navigator.of(context).pop(), + onTapDown: (_) { + if (_isPressed) { + return; + } + setState(() { + _isPressed = true; + }); + }, + onTapCancel: () { + if (!_isPressed) { + return; + } + setState(() { + _isPressed = false; + }); + }, + onTapUp: (_) { + if (!_isPressed) { + return; + } + setState(() { + _isPressed = false; + }); + }, + child: Center( + child: Icon( + Icons.chevron_left, + size: AppSpacing.lg + AppSpacing.xs, + color: _isPressed ? AppColors.blue600 : AppColors.slate700, + ), + ), + ), + ), ), ), );