This commit is contained in:
2026-04-25 16:36:34 +08:00
commit db90e7579b
1876 changed files with 189777 additions and 0 deletions

View File

@@ -0,0 +1,65 @@
import 'package:flutter/material.dart';
import 'package:sales_chat/theme/app_theme.dart';
class CustomButton extends StatelessWidget {
final String text;
final VoidCallback? onPressed;
final bool isLoading;
final bool isOutlined;
final IconData? icon;
final Color? color;
const CustomButton({
super.key,
required this.text,
this.onPressed,
this.isLoading = false,
this.isOutlined = false,
this.icon,
this.color,
});
@override
Widget build(BuildContext context) {
final buttonColor = color ?? AppTheme.primaryColor;
if (isOutlined) {
return OutlinedButton.icon(
onPressed: isLoading ? null : onPressed,
icon: isLoading
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: (icon != null ? Icon(icon, size: 18) : const SizedBox.shrink()),
label: Text(text),
style: OutlinedButton.styleFrom(
foregroundColor: buttonColor,
side: BorderSide(color: buttonColor),
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
),
);
}
return ElevatedButton.icon(
onPressed: isLoading ? null : onPressed,
icon: isLoading
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
),
)
: (icon != null ? Icon(icon, size: 18) : const SizedBox.shrink()),
label: Text(text),
style: ElevatedButton.styleFrom(
backgroundColor: buttonColor,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
),
);
}
}

View File

@@ -0,0 +1,78 @@
import 'package:flutter/material.dart';
import 'package:sales_chat/theme/app_theme.dart';
class CustomInput extends StatefulWidget {
final String label;
final String? hint;
final TextEditingController? controller;
final bool obscureText;
final TextInputType? keyboardType;
final IconData? prefixIcon;
final IconData? suffixIcon;
final VoidCallback? onSuffixTap;
final String? Function(String?)? validator;
final void Function(String)? onSubmitted;
final bool enabled;
const CustomInput({
super.key,
required this.label,
this.hint,
this.controller,
this.obscureText = false,
this.keyboardType,
this.prefixIcon,
this.suffixIcon,
this.onSuffixTap,
this.validator,
this.onSubmitted,
this.enabled = true,
});
@override
State<CustomInput> createState() => _CustomInputState();
}
class _CustomInputState extends State<CustomInput> {
late bool _obscureText;
@override
void initState() {
super.initState();
_obscureText = widget.obscureText;
}
@override
Widget build(BuildContext context) {
return TextFormField(
controller: widget.controller,
obscureText: _obscureText,
keyboardType: widget.keyboardType,
validator: widget.validator,
onFieldSubmitted: widget.onSubmitted,
enabled: widget.enabled,
decoration: InputDecoration(
labelText: widget.label,
hintText: widget.hint,
prefixIcon: widget.prefixIcon != null ? Icon(widget.prefixIcon) : null,
suffixIcon: widget.suffixIcon != null
? IconButton(
icon: Icon(widget.suffixIcon),
onPressed: widget.onSuffixTap ?? (widget.obscureText ? _toggleObscure : null),
)
: (widget.obscureText
? IconButton(
icon: Icon(_obscureText ? Icons.visibility_off : Icons.visibility),
onPressed: _toggleObscure,
)
: null),
),
);
}
void _toggleObscure() {
setState(() {
_obscureText = !_obscureText;
});
}
}

View File

@@ -0,0 +1,87 @@
import 'package:flutter/material.dart';
import 'package:sales_chat/theme/app_theme.dart';
class LoadingWidget extends StatelessWidget {
final String? message;
final double size;
const LoadingWidget({
super.key,
this.message,
this.size = 40,
});
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: size,
height: size,
child: CircularProgressIndicator(
strokeWidth: 3,
valueColor: AlwaysStoppedAnimation<Color>(AppTheme.primaryColor),
),
),
if (message != null) ...[
const SizedBox(height: 16),
Text(
message!,
style: TextStyle(
color: AppTheme.textSecondary,
fontSize: 14,
),
),
],
],
),
);
}
}
class LoadingOverlay extends StatelessWidget {
final bool isLoading;
final Widget child;
final String? message;
const LoadingOverlay({
super.key,
required this.isLoading,
required this.child,
this.message,
});
@override
Widget build(BuildContext context) {
return Stack(
children: [
child,
if (isLoading)
Container(
color: Colors.black.withOpacity(0.3),
child: Center(
child: Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const CircularProgressIndicator(),
if (message != null) ...[
const SizedBox(height: 16),
Text(message!),
],
],
),
),
),
),
],
);
}
}

View File

@@ -0,0 +1,95 @@
import 'package:flutter/material.dart';
import 'package:sales_chat/theme/app_theme.dart';
/// 指标卡片组件 —— 微信风格简洁设计
/// 白底无阴影,图标配浅色圆角背景,趋势标签绿色小胶囊
class MetricCard extends StatelessWidget {
final String title;
final String value;
final IconData icon;
final Color color;
final String? trend;
const MetricCard({
super.key,
required this.title,
required this.value,
required this.icon,
required this.color,
this.trend,
});
@override
Widget build(BuildContext context) {
return Container(
// 白底、无阴影、大圆角
decoration: BoxDecoration(
color: AppTheme.cardBackground,
borderRadius: BorderRadius.circular(12),
),
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
// 顶部行:图标 + 趋势标签
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
// 图标:小圆角方块背景,颜色 10% 透明度
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Icon(icon, color: color, size: 20),
),
// 趋势标签:绿色文字 + 浅绿背景,胶囊形
if (trend != null)
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: AppTheme.successColor.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(10),
),
child: Text(
trend!,
style: const TextStyle(
fontSize: 11,
color: AppTheme.successColor,
fontWeight: FontWeight.w500,
),
),
),
],
),
// 数值 + 标题
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
value,
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: AppTheme.textPrimary,
height: 1.2,
),
),
const SizedBox(height: 4),
Text(
title,
style: const TextStyle(
fontSize: 12,
color: AppTheme.textHint,
height: 1.2,
),
),
],
),
],
),
);
}
}

View File

@@ -0,0 +1,178 @@
import 'package:flutter/material.dart';
/// 现代风格用户头像
/// - 无头像时:渐变背景 + 白色首字母
/// - 有头像时:显示网络图片
/// - 同一用户始终生成相同的渐变色
class UserAvatar extends StatelessWidget {
final String? avatarUrl;
final String displayName;
final double radius;
final VoidCallback? onTap;
const UserAvatar({
super.key,
this.avatarUrl,
required this.displayName,
this.radius = 20,
this.onTap,
});
/// 获取首字母
String get _initials {
if (displayName.isEmpty) return '?';
final name = displayName.trim();
if (name.isEmpty) return '?';
// 中文名取第一个字
if (RegExp(r'[\u4e00-\u9fa5]').hasMatch(name)) {
return name.substring(0, 1);
}
// 英文名取首字母大写
return name.substring(0, 1).toUpperCase();
}
/// 现代渐变色方案 —— 每组两个颜色形成渐变
static const List<List<Color>> _gradients = [
[Color(0xFF667EEA), Color(0xFF764BA2)], // 靛蓝 → 紫
[Color(0xFF1DA1F2), Color(0xFF0070E0)], // Twitter 蓝
[Color(0xFFF093FB), Color(0xFFF5576C)], // 粉 → 玫红
[Color(0xFF4FACFE), Color(0xFF00F2FE)], // 天蓝 → 青
[Color(0xFF43E97B), Color(0xFF38F9D7)], // 绿 → 青
[Color(0xFFFA709A), Color(0xFFFEE140)], // 粉 → 金
[Color(0xFFA18CD1), Color(0xFFFBC2EB)], // 紫 → 粉
[Color(0xFFFDCB6E), Color(0xFFF0932B)], // 金 → 橙
[Color(0xFF6C5CE7), Color(0xFF0984E3)], // 紫 → 蓝
[Color(0xFFFD79A8), Color(0xFF6C5CE7)], // 粉 → 紫
];
/// 基于用户名哈希选择渐变索引(同一用户名始终相同)
int get _gradientIndex {
final hash = displayName.hashCode.abs();
return hash % _gradients.length;
}
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Container(
width: radius * 2,
height: radius * 2,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: avatarUrl != null && avatarUrl!.isNotEmpty
? null
: LinearGradient(
colors: _gradients[_gradientIndex],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
image: avatarUrl != null && avatarUrl!.isNotEmpty
? DecorationImage(
image: NetworkImage(avatarUrl!),
fit: BoxFit.cover,
)
: null,
),
child: avatarUrl == null || avatarUrl!.isEmpty
? Center(
child: Text(
_initials,
style: TextStyle(
fontSize: radius * 0.85,
fontWeight: FontWeight.w600,
color: Colors.white,
height: 1.0,
),
),
)
: null,
),
);
}
}
/// 现代风格群组头像
/// - 使用群名首字符 + 渐变背景
class GroupAvatar extends StatelessWidget {
final String? avatarUrl;
final String groupName;
final double radius;
final VoidCallback? onTap;
const GroupAvatar({
super.key,
this.avatarUrl,
required this.groupName,
this.radius = 20,
this.onTap,
});
String get _initials {
if (groupName.isEmpty) return '#';
final name = groupName.trim();
if (name.isEmpty) return '#';
if (RegExp(r'[\u4e00-\u9fa5]').hasMatch(name)) {
return name.substring(0, 1);
}
return name.substring(0, 1).toUpperCase();
}
static const List<List<Color>> _gradients = [
[Color(0xFF667EEA), Color(0xFF764BA2)],
[Color(0xFF1DA1F2), Color(0xFF0070E0)],
[Color(0xFFF093FB), Color(0xFFF5576C)],
[Color(0xFF4FACFE), Color(0xFF00F2FE)],
[Color(0xFF43E97B), Color(0xFF38F9D7)],
[Color(0xFFFA709A), Color(0xFFFEE140)],
[Color(0xFFA18CD1), Color(0xFFFBC2EB)],
[Color(0xFFFDCB6E), Color(0xFFF0932B)],
[Color(0xFF6C5CE7), Color(0xFF0984E3)],
[Color(0xFFFD79A8), Color(0xFF6C5CE7)],
];
int get _gradientIndex {
final hash = groupName.hashCode.abs();
return hash % _gradients.length;
}
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Container(
width: radius * 2,
height: radius * 2,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: avatarUrl != null && avatarUrl!.isNotEmpty
? null
: LinearGradient(
colors: _gradients[_gradientIndex],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
image: avatarUrl != null && avatarUrl!.isNotEmpty
? DecorationImage(
image: NetworkImage(avatarUrl!),
fit: BoxFit.cover,
)
: null,
),
child: avatarUrl == null || avatarUrl!.isEmpty
? Center(
child: Text(
_initials,
style: TextStyle(
fontSize: radius * 0.85,
fontWeight: FontWeight.w600,
color: Colors.white,
height: 1.0,
),
),
)
: null,
),
);
}
}

View File

@@ -0,0 +1,4 @@
export 'metric_card.dart';
export 'custom_button.dart';
export 'custom_input.dart';
export 'loading_widget.dart';