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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,895 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'package:sales_chat/providers/chat_provider.dart';
import 'package:sales_chat/providers/auth_provider.dart';
import 'package:sales_chat/models/group.dart';
import 'package:sales_chat/models/message.dart';
import 'package:sales_chat/widgets/user_avatar.dart' as custom;
import 'package:sales_chat/theme/app_theme.dart';
/// 聊天页面 —— 微信风格
class ChatPage extends StatefulWidget {
const ChatPage({super.key});
@override
State<ChatPage> createState() => _ChatPageState();
}
class _ChatPageState extends State<ChatPage> {
@override
Widget build(BuildContext context) {
return Column(
children: [
AppBar(
title: const Text('聊天'),
),
Expanded(
child: Consumer<ChatProvider>(
builder: (context, chatProvider, _) {
if (chatProvider.isLoading && chatProvider.groups.isEmpty) {
return const Center(child: CircularProgressIndicator());
}
if (chatProvider.groups.isEmpty) {
return _buildEmptyState();
}
if (chatProvider.currentGroup != null) {
return _buildChatView(chatProvider);
}
return _buildGroupList(chatProvider);
},
),
),
],
);
}
/// 空状态占位
Widget _buildEmptyState() {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.chat_bubble_outline,
size: 64,
color: AppTheme.textHint,
),
const SizedBox(height: 16),
Text(
'暂无群聊',
style: TextStyle(
fontSize: 16,
color: AppTheme.textSecondary,
),
),
const SizedBox(height: 8),
Text(
'请联系管理员将您加入群聊',
style: TextStyle(
fontSize: 14,
color: AppTheme.textHint,
),
),
],
),
);
}
/// 群组列表 —— 微信会话列表风格
/// 白底行 + 细分割线,无卡片阴影
Widget _buildGroupList(ChatProvider chatProvider) {
return Container(
color: AppTheme.cardBackground,
child: ListView.separated(
// 去掉顶部和底部多余间距
padding: EdgeInsets.zero,
itemCount: chatProvider.groups.length,
// 每行之间用细分割线
separatorBuilder: (_, __) => const Divider(
height: 0.5,
thickness: 0.5,
indent: 76, // 头像右侧开始
color: AppTheme.dividerColor,
),
itemBuilder: (context, index) {
final group = chatProvider.groups[index];
return _WeChatGroupRow(
group: group,
onTap: () => chatProvider.selectGroup(group),
);
},
),
);
}
/// 聊天视图 —— 聊天界面
Widget _buildChatView(ChatProvider chatProvider) {
final group = chatProvider.currentGroup!;
final authProvider = context.read<AuthProvider>();
return Column(
children: [
// 聊天顶栏
Container(
color: AppTheme.cardBackground,
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8),
child: Row(
children: [
IconButton(
icon: const Icon(Icons.arrow_back_ios, size: 20),
onPressed: () => chatProvider.deselectGroup(),
),
Expanded(
child: GestureDetector(
onTap: () => _showGroupInfo(context, group),
child: Column(
children: [
Text(
group.name,
style: const TextStyle(
fontSize: 17,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
),
Text(
'${group.memberCount}位成员',
style: const TextStyle(
fontSize: 11,
color: AppTheme.textHint,
),
),
],
),
),
),
IconButton(
icon: const Icon(Icons.more_horiz, size: 22),
onPressed: () => _showGroupInfo(context, group),
),
],
),
),
const Divider(height: 0.5, thickness: 0.5),
Expanded(
child: _ChatMessagesList(
messages: chatProvider.currentMessages,
currentUserId: authProvider.user?.id ?? '',
onLoadMore: () {
// 加载更多历史消息
},
),
),
_ChatInput(
onSend: (text) {
chatProvider.sendMessage(text);
},
),
],
);
}
/// 显示群信息底部弹窗
void _showGroupInfo(BuildContext context, Group group) {
showModalBottomSheet(
context: context,
backgroundColor: AppTheme.cardBackground,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(12)),
),
builder: (context) => _GroupInfoSheet(group: group),
);
}
}
// ================================================================
// 群组列表行 —— 微信会话列表风格
// ================================================================
class _WeChatGroupRow extends StatelessWidget {
final Group group;
final VoidCallback onTap;
const _WeChatGroupRow({
required this.group,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
// 点击水波纹颜色
splashColor: AppTheme.dividerColor,
highlightColor: AppTheme.timestampBg,
child: Container(
color: AppTheme.cardBackground,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
// 左侧群头像
custom.GroupAvatar(
groupName: group.name,
radius: 24,
),
const SizedBox(width: 12),
// 右侧群名 + 成员数
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
group.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
color: AppTheme.textPrimary,
),
),
const SizedBox(height: 4),
Text(
'${group.memberCount}位成员',
style: const TextStyle(
fontSize: 13,
color: AppTheme.textHint,
),
),
],
),
),
// 右侧箭头图标
Icon(
Icons.chevron_right,
color: AppTheme.textHint,
size: 20,
),
],
),
),
);
}
}
// ================================================================
// 消息列表
// ================================================================
class _ChatMessagesList extends StatelessWidget {
final List<Message> messages;
final String currentUserId;
final VoidCallback? onLoadMore;
const _ChatMessagesList({
required this.messages,
required this.currentUserId,
this.onLoadMore,
});
@override
Widget build(BuildContext context) {
if (messages.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.chat_bubble_outline,
size: 48,
color: AppTheme.textHint,
),
const SizedBox(height: 12),
Text(
'暂无消息',
style: TextStyle(color: AppTheme.textSecondary),
),
const SizedBox(height: 8),
Text(
'发送第一条消息开始聊天吧',
style: TextStyle(color: AppTheme.textHint, fontSize: 12),
),
],
),
);
}
return Container(
color: AppTheme.scaffoldBackground,
child: ListView.builder(
reverse: true,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
itemCount: messages.length,
itemBuilder: (context, index) {
final message = messages[index];
final isMe = message.senderId == currentUserId;
// 滚动到顶部时加载更多
if (index == messages.length - 1 && onLoadMore != null) {
onLoadMore!();
}
// 判断是否需要显示时间戳与下一条消息间隔超过5分钟
final bool showTimestamp;
if (index == messages.length - 1) {
// 最旧的消息,始终显示时间
showTimestamp = true;
} else {
final nextMessage = messages[index + 1];
if (message.createdAt != null && nextMessage.createdAt != null) {
final diff = message.createdAt!
.difference(nextMessage.createdAt!)
.inMinutes;
showTimestamp = diff.abs() >= 5;
} else {
showTimestamp = false;
}
}
return _MessageBubble(
message: message,
isMe: isMe,
showTimestamp: showTimestamp,
);
},
),
);
}
}
// ================================================================
// 消息气泡 —— 微信风格
// ================================================================
class _MessageBubble extends StatelessWidget {
final Message message;
final bool isMe;
final bool showTimestamp;
const _MessageBubble({
required this.message,
required this.isMe,
this.showTimestamp = false,
});
@override
Widget build(BuildContext context) {
// 撤回消息:居中灰色斜体
if (message.hasRecall) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Column(
children: [
if (showTimestamp && message.createdAt != null)
_buildTimestamp(),
const SizedBox(height: 4),
Center(
child: Text(
'消息已撤回',
style: TextStyle(
color: AppTheme.textHint,
fontSize: 12,
fontStyle: FontStyle.italic,
),
),
),
],
),
);
}
return Padding(
padding: const EdgeInsets.only(bottom: 10),
child: Column(
children: [
// 时间戳居中显示在消息上方
if (showTimestamp && message.createdAt != null)
_buildTimestamp(),
const SizedBox(height: 4),
// 消息行
GestureDetector(
onLongPress: () => _showMessageOptions(context),
child: Row(
mainAxisAlignment:
isMe ? MainAxisAlignment.end : MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 对方消息:左侧头像
if (!isMe) ...[
custom.UserAvatar(
displayName:
message.senderName.isNotEmpty ? message.senderName : 'U',
radius: 16,
),
const SizedBox(width: 8),
],
// 气泡内容
Flexible(
child: Column(
crossAxisAlignment:
isMe ? CrossAxisAlignment.end : CrossAxisAlignment.start,
children: [
// 对方消息:发送者名称(群聊显示)
if (!isMe && message.senderName.isNotEmpty)
Padding(
padding: const EdgeInsets.only(bottom: 2, left: 2),
child: Text(
message.senderName,
style: const TextStyle(
fontSize: 11,
color: AppTheme.textHint,
fontWeight: FontWeight.w400,
),
),
),
// 气泡
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 9),
decoration: BoxDecoration(
// 自己:微信绿气泡;对方:白色气泡
color: isMe
? AppTheme.bubbleSelf
: AppTheme.bubbleOther,
borderRadius: BorderRadius.only(
topLeft: const Radius.circular(12),
topRight: const Radius.circular(12),
// 微信风格:外角小圆角,内角大圆角
bottomLeft:
Radius.circular(isMe ? 12 : 4),
bottomRight:
Radius.circular(isMe ? 4 : 12),
),
// 无阴影,干净清爽
),
child: Text(
message.content,
style: TextStyle(
// 己方蓝色气泡用白字,对方灰色气泡用深色字
color: isMe ? Colors.white : AppTheme.textPrimary,
fontSize: 15,
height: 1.4,
),
),
),
],
),
),
// 自己消息:右侧留出间距(对称对方头像)
if (isMe) const SizedBox(width: 8),
],
),
),
],
),
);
}
/// 居中时间戳
Widget _buildTimestamp() {
return Padding(
padding: const EdgeInsets.only(top: 4, bottom: 2),
child: Center(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: AppTheme.timestampBg,
borderRadius: BorderRadius.circular(4),
),
child: Text(
_formatTime(message.createdAt!),
style: const TextStyle(
fontSize: 11,
color: AppTheme.timestampText,
),
),
),
),
);
}
/// 长按消息菜单
void _showMessageOptions(BuildContext context) {
final authProvider = context.read<AuthProvider>();
final chatProvider = context.read<ChatProvider>();
final isAdmin = authProvider.user?.isAdmin ?? false;
final canRecall = isMe && _isRecallable(message.createdAt);
showModalBottomSheet(
context: context,
backgroundColor: AppTheme.cardBackground,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(12)),
),
builder: (context) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// 顶部拖拽指示条
Container(
margin: const EdgeInsets.only(top: 8, bottom: 4),
width: 36,
height: 4,
decoration: BoxDecoration(
color: AppTheme.dragHandleColor,
borderRadius: BorderRadius.circular(2),
),
),
if (canRecall)
ListTile(
leading: Icon(Icons.undo, color: AppTheme.primaryColor),
title: const Text('撤回消息'),
subtitle: const Text('其他人将不再看到此消息'),
onTap: () {
Navigator.pop(context);
_handleRecall(context, chatProvider);
},
),
if (isAdmin && !canRecall)
ListTile(
leading: const Icon(Icons.delete, color: Colors.red),
title:
const Text('删除消息', style: TextStyle(color: Colors.red)),
subtitle: const Text('管理员删除消息'),
onTap: () {
Navigator.pop(context);
_handleDelete(context, chatProvider);
},
),
ListTile(
leading: Icon(Icons.copy, color: AppTheme.textSecondary),
title: const Text('复制'),
onTap: () {
Clipboard.setData(ClipboardData(text: message.content));
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('已复制到剪贴板')),
);
},
),
ListTile(
leading:
Icon(Icons.cancel_outlined, color: AppTheme.textHint),
title: const Text('取消'),
onTap: () => Navigator.pop(context),
),
],
),
),
);
}
/// 判断消息是否可撤回2分钟内
bool _isRecallable(DateTime? createdAt) {
if (createdAt == null) return false;
final now = DateTime.now();
final diff = now.difference(createdAt);
return diff.inMinutes < 2;
}
/// 撤回消息
Future<void> _handleRecall(
BuildContext context, ChatProvider chatProvider) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('撤回消息'),
content: const Text('确定要撤回此消息吗?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('取消'),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('撤回'),
),
],
),
);
if (confirmed == true) {
final success = await chatProvider.recallMessage(message.id);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(success ? '消息已撤回' : (chatProvider.error ?? '撤回失败')),
),
);
}
}
}
/// 删除消息(管理员)
Future<void> _handleDelete(
BuildContext context, ChatProvider chatProvider) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('删除消息'),
content: const Text('确定吗?此操作无法撤销。'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('取消'),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
style: TextButton.styleFrom(foregroundColor: Colors.red),
child: const Text('删除'),
),
],
),
);
if (confirmed == true) {
final success = await chatProvider.deleteMessage(message.id);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(success ? '消息已删除' : (chatProvider.error ?? '删除失败')),
),
);
}
}
}
/// 格式化时间
String _formatTime(DateTime time) {
final now = DateTime.now();
final today = DateTime(now.year, now.month, now.day);
final yesterday = today.subtract(const Duration(days: 1));
final messageDate = DateTime(time.year, time.month, time.day);
final hour = time.hour.toString().padLeft(2, '0');
final minute = time.minute.toString().padLeft(2, '0');
if (messageDate == today) {
return '$hour:$minute';
} else if (messageDate == yesterday) {
return '昨天 $hour:$minute';
} else if (now.year == time.year) {
return '${time.month}${time.day}$hour:$minute';
} else {
return '${time.year}${time.month}${time.day}$hour:$minute';
}
}
}
// ================================================================
// 聊天输入栏 —— 微信风格
// ================================================================
class _ChatInput extends StatefulWidget {
final Function(String) onSend;
const _ChatInput({required this.onSend});
@override
State<_ChatInput> createState() => _ChatInputState();
}
class _ChatInputState extends State<_ChatInput> {
final _controller = TextEditingController();
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _handleSend() {
final text = _controller.text.trim();
if (text.isNotEmpty) {
widget.onSend(text);
_controller.clear();
}
}
@override
Widget build(BuildContext context) {
return Container(
decoration: const BoxDecoration(
color: AppTheme.chatInputBg,
border: Border(
top: BorderSide(
color: AppTheme.dividerColor,
width: 0.5,
),
),
),
padding: EdgeInsets.only(
left: 12,
right: 12,
top: 8,
bottom: MediaQuery.of(context).padding.bottom + 8,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
// 输入框
Expanded(
child: Container(
constraints: const BoxConstraints(
minHeight: 36,
maxHeight: 120,
),
child: TextField(
controller: _controller,
maxLines: null,
style: const TextStyle(
fontSize: 15,
color: AppTheme.textPrimary,
),
decoration: InputDecoration(
hintText: '输入消息...',
hintStyle: const TextStyle(
color: AppTheme.textHint,
fontSize: 15,
),
filled: true,
fillColor: AppTheme.cardBackground,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(6),
borderSide: BorderSide.none,
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
isDense: true,
),
onSubmitted: (_) => _handleSend(),
),
),
),
const SizedBox(width: 8),
// 发送按钮:微信绿圆形
GestureDetector(
onTap: _handleSend,
child: Container(
width: 36,
height: 36,
decoration: const BoxDecoration(
color: AppTheme.primaryColor,
shape: BoxShape.circle,
),
child: const Icon(
Icons.send_rounded,
color: Colors.white,
size: 18,
),
),
),
],
),
);
}
}
// ================================================================
// 群信息底部弹窗 —— 微信风格,干净白底
// ================================================================
class _GroupInfoSheet extends StatelessWidget {
final Group group;
const _GroupInfoSheet({required this.group});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 20),
decoration: const BoxDecoration(
color: AppTheme.cardBackground,
borderRadius: BorderRadius.vertical(top: Radius.circular(12)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// 顶部拖拽指示条
Container(
width: 36,
height: 4,
margin: const EdgeInsets.only(bottom: 20),
decoration: BoxDecoration(
color: AppTheme.dragHandleColor,
borderRadius: BorderRadius.circular(2),
),
),
// 群头像
custom.GroupAvatar(
groupName: group.name,
radius: 40,
),
const SizedBox(height: 12),
// 群名称
Text(
group.name,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
),
const SizedBox(height: 4),
// 成员数
Text(
'${group.memberCount}位成员',
style: const TextStyle(
fontSize: 14,
color: AppTheme.textHint,
),
),
// 群描述
if (group.description != null &&
group.description!.isNotEmpty) ...[
const SizedBox(height: 12),
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppTheme.chatInputBg,
borderRadius: BorderRadius.circular(8),
),
child: Text(
group.description!,
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 14,
color: AppTheme.textSecondary,
),
),
),
],
// 频道列表
if (group.panels.isNotEmpty) ...[
const SizedBox(height: 16),
const Align(
alignment: Alignment.centerLeft,
child: Text(
'频道',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
),
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: group.panels
.where((p) => p.type == 0)
.map((p) => Container(
padding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: AppTheme.chatInputBg,
borderRadius: BorderRadius.circular(6),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.tag,
size: 14, color: AppTheme.textHint),
const SizedBox(width: 4),
Text(
p.name,
style: const TextStyle(
fontSize: 13,
color: AppTheme.textPrimary,
),
),
],
),
))
.toList(),
),
],
const SizedBox(height: 24),
],
),
);
}
}

View File

@@ -0,0 +1,405 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:sales_chat/providers/stats_provider.dart';
import 'package:sales_chat/widgets/user_avatar.dart';
import 'package:sales_chat/theme/app_theme.dart';
/// 看板页面 —— Twitter 风格
class DashboardPage extends StatefulWidget {
const DashboardPage({super.key});
@override
State<DashboardPage> createState() => _DashboardPageState();
}
class _DashboardPageState extends State<DashboardPage> {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) => _loadData());
}
Future<void> _loadData() async {
final statsProvider = context.read<StatsProvider>();
await statsProvider.loadMyStats();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('看板'),
actions: [
IconButton(
icon: const Icon(Icons.refresh),
onPressed: _loadData,
),
],
),
body: Consumer<StatsProvider>(
builder: (context, statsProvider, _) {
if (statsProvider.isLoading && statsProvider.myStats == null) {
return const Center(child: CircularProgressIndicator());
}
final stats = statsProvider.myStats;
return RefreshIndicator(
onRefresh: _loadData,
child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (stats != null) _buildStatsOverview(context, stats),
const SizedBox(height: 8),
_buildTodayStats(context, stats),
const SizedBox(height: 8),
_buildRankingSection(context, statsProvider),
],
),
),
);
},
),
);
}
/// 统计概览 —— 卡片区块2x2 网格
Widget _buildStatsOverview(BuildContext context, dynamic myStats) {
final total = myStats.total;
return Container(
width: double.infinity,
color: AppTheme.cardBackground,
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'我的统计',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
),
const SizedBox(height: 16),
GridView.count(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
crossAxisCount: 2,
mainAxisSpacing: 16,
crossAxisSpacing: 16,
childAspectRatio: 2.0,
children: [
_StatGridItem(
value: _formatNumber(total.totalInvites),
label: '总邀请数',
icon: Icons.card_giftcard_outlined,
color: AppTheme.primaryColor,
),
_StatGridItem(
value: _formatNumber(total.totalJoins),
label: '总加入数',
icon: Icons.person_add_outlined,
color: AppTheme.successColor,
),
_StatGridItem(
value: '+${myStats.today.invitesCreated}',
label: '今日邀请',
icon: Icons.add_circle_outline,
color: AppTheme.warningColor,
),
_StatGridItem(
value: '+${myStats.today.joins}',
label: '今日加入',
icon: Icons.people,
color: AppTheme.infoColor,
),
],
),
],
),
);
}
/// 今日统计 —— 独立卡片区块
Widget _buildTodayStats(BuildContext context, dynamic myStats) {
if (myStats == null) return const SizedBox.shrink();
final today = myStats.today;
return Container(
width: double.infinity,
color: AppTheme.cardBackground,
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'今日数据',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: _TodayStatItem(
value: '${today.invitesCreated}',
label: '新建邀请',
icon: Icons.add_circle_outline,
color: AppTheme.primaryColor,
),
),
Container(
width: 0.5,
height: 40,
color: AppTheme.dividerColor,
),
Expanded(
child: _TodayStatItem(
value: '${today.joins}',
label: '新加入',
icon: Icons.person_add,
color: AppTheme.successColor,
),
),
],
),
],
),
);
}
/// 排行榜区块
Widget _buildRankingSection(BuildContext context, StatsProvider provider) {
return Container(
color: AppTheme.cardBackground,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 标题行
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'排行榜',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
),
GestureDetector(
onTap: () => provider.loadRanking(),
child: const Text(
'加载排行',
style: TextStyle(
fontSize: 13,
color: AppTheme.primaryColor,
),
),
),
],
),
),
if (provider.ranking.isEmpty)
Padding(
padding: const EdgeInsets.all(24),
child: Center(
child: Text(
'暂无排行数据',
style: TextStyle(color: AppTheme.textSecondary, fontSize: 14),
),
),
)
else
..._buildRankingItems(provider),
],
),
);
}
/// 构建排行列表项(带分隔线)
List<Widget> _buildRankingItems(StatsProvider provider) {
final rankings = provider.ranking.take(10).toList();
final items = <Widget>[];
for (int i = 0; i < rankings.length; i++) {
final entry = rankings[i];
items.add(Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
// 排名序号
SizedBox(
width: 28,
child: Text(
'${entry.rank}',
style: TextStyle(
fontSize: 16,
fontWeight: i < 3 ? FontWeight.bold : FontWeight.w500,
color: i < 3
? [const Color(0xFFFFC300), const Color(0xFFC0C0C0), const Color(0xFFCD7F32)][i]
: AppTheme.textHint,
),
textAlign: TextAlign.center,
),
),
const SizedBox(width: 12),
// 头像
UserAvatar(
displayName: entry.displayName,
radius: 16,
),
const SizedBox(width: 12),
// 名字
Expanded(
child: Text(
entry.displayName,
style: const TextStyle(
fontSize: 15,
color: AppTheme.textPrimary,
),
),
),
// 加入数
Text(
'${entry.totalJoins} 加入',
style: const TextStyle(
fontSize: 13,
color: AppTheme.textSecondary,
),
),
],
),
));
if (i < rankings.length - 1) {
items.add(const Padding(
padding: EdgeInsets.only(left: 56),
child: Divider(height: 0.5, thickness: 0.5),
));
}
}
return items;
}
/// 格式化数字
String _formatNumber(int number) {
if (number >= 10000) {
return '${(number / 10000).toStringAsFixed(1)}w';
} else if (number >= 1000) {
return '${(number / 1000).toStringAsFixed(1)}k';
}
return number.toString();
}
}
/// 统计网格项 —— 大数字 + 小标签 + 图标
class _StatGridItem extends StatelessWidget {
final String value;
final String label;
final IconData icon;
final Color color;
const _StatGridItem({
required this.value,
required this.label,
required this.icon,
required this.color,
});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.06),
borderRadius: BorderRadius.circular(8),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Row(
children: [
Icon(icon, color: color, size: 18),
const SizedBox(width: 6),
Text(
value,
style: TextStyle(
fontSize: 22,
fontWeight: FontWeight.bold,
color: AppTheme.textPrimary,
),
),
],
),
const SizedBox(height: 4),
Text(
label,
style: const TextStyle(
fontSize: 12,
color: AppTheme.textSecondary,
),
),
],
),
);
}
}
/// 今日统计项
class _TodayStatItem extends StatelessWidget {
final String value;
final String label;
final IconData icon;
final Color color;
const _TodayStatItem({
required this.value,
required this.label,
required this.icon,
required this.color,
});
@override
Widget build(BuildContext context) {
return Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, color: color, size: 18),
const SizedBox(width: 6),
Text(
value,
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: AppTheme.textPrimary,
),
),
],
),
const SizedBox(height: 4),
Text(
label,
style: const TextStyle(
fontSize: 12,
color: AppTheme.textSecondary,
),
),
],
);
}
}

View File

@@ -0,0 +1,446 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:sales_chat/pages/chat_page.dart';
import 'package:sales_chat/pages/invite_page.dart';
import 'package:sales_chat/pages/dashboard_page.dart';
import 'package:sales_chat/pages/admin_page.dart';
import 'package:sales_chat/providers/auth_provider.dart';
import 'package:sales_chat/providers/chat_provider.dart';
import 'package:sales_chat/widgets/user_avatar.dart';
import 'package:sales_chat/theme/app_theme.dart';
/// 首页 —— 底部导航容器 + 销售工作台Twitter 风格)
class HomePage extends StatefulWidget {
const HomePage({super.key});
@override
State<HomePage> createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> {
int _currentIndex = 0;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) => _loadData());
}
Future<void> _loadData() async {
final chatProvider = context.read<ChatProvider>();
await chatProvider.loadGroups();
}
@override
Widget build(BuildContext context) {
final authProvider = context.watch<AuthProvider>();
final user = authProvider.user;
final pages = [
_buildWorkbenchPage(),
const ChatPage(),
const InvitePage(),
const DashboardPage(),
if (user?.isAdmin == true) const AdminPage(),
];
return Scaffold(
body: pages[_currentIndex],
bottomNavigationBar: BottomNavigationBar(
currentIndex: _currentIndex,
onTap: (index) {
setState(() {
_currentIndex = index;
});
},
items: [
const BottomNavigationBarItem(
icon: Icon(Icons.home_outlined),
activeIcon: Icon(Icons.home),
label: '工作台',
),
const BottomNavigationBarItem(
icon: Icon(Icons.chat_outlined),
activeIcon: Icon(Icons.chat),
label: '群聊',
),
const BottomNavigationBarItem(
icon: Icon(Icons.card_giftcard_outlined),
activeIcon: Icon(Icons.card_giftcard),
label: '邀请',
),
const BottomNavigationBarItem(
icon: Icon(Icons.bar_chart_outlined),
activeIcon: Icon(Icons.bar_chart),
label: '看板',
),
if (user?.isAdmin == true)
const BottomNavigationBarItem(
icon: Icon(Icons.admin_panel_settings_outlined),
activeIcon: Icon(Icons.admin_panel_settings),
label: '管理',
),
],
),
);
}
/// 构建工作台页面 —— Twitter 风格
Widget _buildWorkbenchPage() {
return Scaffold(
appBar: AppBar(
title: const Text('销售工作台'),
actions: [
IconButton(
icon: const Icon(Icons.notifications_outlined),
onPressed: () {
// TODO: 消息通知
},
),
PopupMenuButton<String>(
icon: const Icon(Icons.account_circle),
onSelected: (value) {
if (value == 'logout') {
_handleLogout();
}
},
itemBuilder: (context) => [
const PopupMenuItem(
value: 'profile',
child: ListTile(
leading: Icon(Icons.person_outline),
title: Text('个人信息'),
),
),
const PopupMenuItem(
value: 'logout',
child: ListTile(
leading: Icon(Icons.logout),
title: Text('退出登录'),
),
),
],
),
],
),
body: RefreshIndicator(
onRefresh: _loadData,
child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 欢迎区域 —— 卡片容器
_buildWelcomeSection(),
const SizedBox(height: 8),
// 快捷操作 —— 圆形图标按钮
_buildQuickActionsSection(),
const SizedBox(height: 8),
// 最近群聊 —— 卡片区块列表
_buildRecentGroupsSection(),
],
),
),
),
);
}
/// 欢迎区域 —— 简洁卡片,头像 + 问候语
Widget _buildWelcomeSection() {
return Consumer<AuthProvider>(
builder: (context, auth, _) {
return Container(
width: double.infinity,
color: AppTheme.cardBackground,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 20),
child: Row(
children: [
UserAvatar(
displayName: auth.user?.displayName ?? 'U',
radius: 24,
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'你好,${auth.user?.displayName ?? '用户'}',
style: const TextStyle(
fontSize: 17,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
),
const SizedBox(height: 4),
const Text(
'今天也要加油哦!',
style: TextStyle(
fontSize: 14,
color: AppTheme.textSecondary,
),
),
],
),
),
],
),
);
},
);
}
/// 快捷操作 —— 3个圆形图标按钮横排
Widget _buildQuickActionsSection() {
return Container(
width: double.infinity,
color: AppTheme.cardBackground,
padding: const EdgeInsets.symmetric(vertical: 20, horizontal: 16),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_QuickActionButton(
icon: Icons.add_circle_outline,
label: '创建邀请',
color: AppTheme.primaryColor,
onTap: () {
setState(() {
_currentIndex = 2;
});
},
),
_QuickActionButton(
icon: Icons.chat_outlined,
label: '进入群聊',
color: AppTheme.infoColor,
onTap: () {
setState(() {
_currentIndex = 1;
});
},
),
_QuickActionButton(
icon: Icons.bar_chart_outlined,
label: '查看报表',
color: AppTheme.warningColor,
onTap: () {
setState(() {
_currentIndex = 3;
});
},
),
],
),
);
}
/// 最近群聊 —— 卡片区块,扁平列表项
Widget _buildRecentGroupsSection() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 区块标题
Container(
width: double.infinity,
color: AppTheme.cardBackground,
padding: const EdgeInsets.fromLTRB(16, 16, 16, 0),
child: const Text(
'最近群聊',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
),
),
// 群聊列表
Consumer<ChatProvider>(
builder: (context, chat, _) {
if (chat.isLoading) {
return Container(
color: AppTheme.cardBackground,
padding: const EdgeInsets.all(24),
child: const Center(
child: CircularProgressIndicator(),
),
);
}
if (chat.groups.isEmpty) {
return Container(
color: AppTheme.cardBackground,
padding: const EdgeInsets.all(24),
child: Center(
child: Text(
'暂无群聊',
style: TextStyle(color: AppTheme.textSecondary, fontSize: 14),
),
),
);
}
final groups = chat.groups.take(5).toList();
return Container(
color: AppTheme.cardBackground,
child: Column(
children: [
for (int i = 0; i < groups.length; i++) ...[
_GroupListTile(
group: groups[i],
onTap: () {
chat.selectGroup(groups[i]);
setState(() {
_currentIndex = 1;
});
},
),
if (i < groups.length - 1)
const Padding(
padding: EdgeInsets.only(left: 62),
child: Divider(height: 0.5, thickness: 0.5),
),
],
],
),
);
},
),
],
);
}
/// 退出登录确认
Future<void> _handleLogout() async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('退出登录'),
content: const Text('确定要退出登录吗?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('取消'),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: Text('退出', style: TextStyle(color: AppTheme.errorColor)),
),
],
),
);
if (confirmed == true && mounted) {
final authProvider = context.read<AuthProvider>();
await authProvider.logout();
if (mounted) {
Navigator.of(context).pushReplacementNamed('/login');
}
}
}
}
/// 快捷操作按钮 —— 圆形图标 + 文字标签(卡片背景色)
class _QuickActionButton extends StatelessWidget {
final IconData icon;
final String label;
final Color color;
final VoidCallback onTap;
const _QuickActionButton({
required this.icon,
required this.label,
required this.color,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
behavior: HitTestBehavior.opaque,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 50,
height: 50,
decoration: BoxDecoration(
color: color.withValues(alpha: 0.1),
shape: BoxShape.circle,
),
child: Icon(icon, color: color, size: 26),
),
const SizedBox(height: 8),
Text(
label,
style: const TextStyle(
fontSize: 12,
color: AppTheme.textSecondary,
),
),
],
),
);
}
}
/// 群聊列表项 —— 卡片背景行,头像 + 群名 + 成员数 + 箭头
class _GroupListTile extends StatelessWidget {
final dynamic group;
final VoidCallback onTap;
const _GroupListTile({
required this.group,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
GroupAvatar(
groupName: group.name,
radius: 22,
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
group.name,
style: const TextStyle(
fontSize: 16,
color: AppTheme.textPrimary,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 3),
Text(
'${group.memberCount} 成员',
style: const TextStyle(
fontSize: 13,
color: AppTheme.textHint,
),
),
],
),
),
const Icon(
Icons.chevron_right,
color: AppTheme.textHint,
size: 20,
),
],
),
),
);
}
}

View File

@@ -0,0 +1,668 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:qr_flutter/qr_flutter.dart';
import 'package:sales_chat/providers/invite_provider.dart';
import 'package:sales_chat/providers/chat_provider.dart';
import 'package:sales_chat/models/invite.dart';
import 'package:sales_chat/theme/app_theme.dart';
import 'package:sales_chat/services/api_service.dart';
import 'package:intl/intl.dart';
/// 邀请页面 —— Twitter 风格
class InvitePage extends StatefulWidget {
const InvitePage({super.key});
@override
State<InvitePage> createState() => _InvitePageState();
}
class _InvitePageState extends State<InvitePage> {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) => _loadData());
}
Future<void> _loadData() async {
final inviteProvider = context.read<InviteProvider>();
final chatProvider = context.read<ChatProvider>();
// 先确保群组数据已加载
await chatProvider.loadGroups();
await Future.wait([
inviteProvider.loadInvites(),
inviteProvider.loadAvailableGroups(chatProvider.groups),
]);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('邀请'),
actions: [
IconButton(
icon: const Icon(Icons.refresh),
onPressed: _loadData,
),
],
),
body: Consumer<InviteProvider>(
builder: (context, inviteProvider, _) {
if (inviteProvider.isLoading && inviteProvider.invites.isEmpty) {
return const Center(child: CircularProgressIndicator());
}
return SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 创建邀请按钮 —— 顶部醒目主色按钮
_buildCreateInviteButton(),
const SizedBox(height: 8),
// 我的邀请列表
_buildInviteListSection(inviteProvider),
],
),
);
},
),
);
}
/// 创建邀请按钮 —— 醒目的主色按钮,非卡片样式
Widget _buildCreateInviteButton() {
return Container(
width: double.infinity,
color: AppTheme.cardBackground,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
child: ElevatedButton.icon(
onPressed: () => _showCreateInviteDialog(),
icon: const Icon(Icons.add, size: 20),
label: const Text('创建新邀请'),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.primaryColor,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
elevation: 0,
),
),
);
}
/// 邀请列表区块
Widget _buildInviteListSection(InviteProvider inviteProvider) {
return Container(
color: AppTheme.cardBackground,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 区块标题
const Padding(
padding: EdgeInsets.fromLTRB(16, 16, 16, 0),
child: Text(
'我的邀请',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
),
),
if (inviteProvider.invites.isEmpty)
_buildEmptyState()
else
..._buildInviteListItems(inviteProvider.invites),
],
),
);
}
/// 构建邀请列表项(带分隔线)
List<Widget> _buildInviteListItems(List invites) {
final items = <Widget>[];
for (int i = 0; i < invites.length; i++) {
final invite = invites[i];
items.add(_InviteListTile(
invite: invite,
onTap: () => _showInviteDetail(invite),
onDeactivate: () => _deactivateInvite(invite.code),
));
if (i < invites.length - 1) {
items.add(const Padding(
padding: EdgeInsets.only(left: 16),
child: Divider(height: 0.5, thickness: 0.5),
));
}
}
return items;
}
/// 空状态
Widget _buildEmptyState() {
return Padding(
padding: const EdgeInsets.all(32),
child: Center(
child: Column(
children: [
Icon(
Icons.card_giftcard_outlined,
size: 48,
color: AppTheme.textHint,
),
const SizedBox(height: 16),
Text(
'暂无邀请',
style: TextStyle(color: AppTheme.textSecondary, fontSize: 14),
),
const SizedBox(height: 8),
Text(
'点击上方按钮创建您的第一个邀请',
style: TextStyle(color: AppTheme.textHint, fontSize: 12),
),
],
),
),
);
}
/// 创建邀请对话框
Future<void> _showCreateInviteDialog() async {
final chatProvider = context.read<ChatProvider>();
var groups = chatProvider.groups;
// 如果没有群组,先创建一个
if (groups.isEmpty) {
final created = await _showCreateGroupDialog();
if (!created) return;
groups = chatProvider.groups;
if (groups.isEmpty) return;
}
String? selectedGroupId = groups.first.id;
int expiryDays = 7;
await showDialog(
context: context,
builder: (context) => StatefulBuilder(
builder: (context, setState) {
return AlertDialog(
title: const Text('创建邀请'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 群组选择
DropdownButtonFormField<String>(
value: selectedGroupId,
decoration: const InputDecoration(
labelText: '选择群组',
border: OutlineInputBorder(),
),
items: groups.map((g) {
return DropdownMenuItem(
value: g.id,
child: Text(g.name),
);
}).toList(),
onChanged: (value) {
setState(() {
selectedGroupId = value;
});
},
),
const SizedBox(height: 16),
// 有效期
Text('有效期(天)', style: Theme.of(context).textTheme.bodySmall),
Slider(
value: expiryDays.toDouble(),
min: 1,
max: 30,
divisions: 29,
label: '$expiryDays',
onChanged: (value) {
setState(() {
expiryDays = value.round();
});
},
),
Text(
'$expiryDays',
style: TextStyle(color: AppTheme.textSecondary),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('取消'),
),
ElevatedButton(
onPressed: () async {
final inviteProvider = context.read<InviteProvider>();
Navigator.pop(context);
final invite = await inviteProvider.createInvite(
groupId: selectedGroupId!,
expiresInDays: expiryDays,
);
if (invite != null && mounted) {
_showInviteDetail(invite);
}
},
child: const Text('创建'),
),
],
);
},
),
);
}
/// 显示邀请详情底部弹窗
void _showInviteDetail(Invite invite) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: AppTheme.cardBackground,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(12)),
),
builder: (context) => _InviteDetailSheet(invite: invite),
);
}
/// 停用邀请确认
Future<void> _deactivateInvite(String code) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('停用邀请'),
content: const Text('确定吗?此邀请将不再可用。'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('取消'),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
style: TextButton.styleFrom(foregroundColor: AppTheme.errorColor),
child: const Text('停用'),
),
],
),
);
if (confirmed == true && mounted) {
final inviteProvider = context.read<InviteProvider>();
await inviteProvider.deactivateInvite(code);
}
}
/// 创建群组对话框(没有群组时自动弹出)
Future<bool> _showCreateGroupDialog() async {
final name = await showDialog<String>(
context: context,
builder: (context) {
final controller = TextEditingController();
return AlertDialog(
title: const Text('创建群组'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('您还没有群组,需要先创建一个群组才能生成邀请。'),
const SizedBox(height: 16),
TextField(
controller: controller,
autofocus: true,
decoration: const InputDecoration(
labelText: '群组名称',
hintText: '例如:客户交流群',
),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('取消'),
),
ElevatedButton(
onPressed: () => Navigator.pop(context, controller.text.trim()),
child: const Text('创建'),
),
],
);
},
);
if (name == null || name.isEmpty) return false;
try {
final apiService = context.read<ApiService>();
await apiService.createGroup(name);
await context.read<ChatProvider>().loadGroups();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('群组已创建')),
);
}
return true;
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('创建失败:$e')),
);
}
return false;
}
}
}
/// 邀请列表行 —— 卡片背景行,邀请码 + 状态徽章 + 统计
class _InviteListTile extends StatelessWidget {
final Invite invite;
final VoidCallback onTap;
final VoidCallback onDeactivate;
const _InviteListTile({
required this.invite,
required this.onTap,
required this.onDeactivate,
});
@override
Widget build(BuildContext context) {
final dateFormat = DateFormat('yyyy-MM-dd HH:mm');
return InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
child: Row(
children: [
// 左侧:邀请码 + 统计
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 第一行:邀请码 + 状态
Row(
children: [
// 邀请码(等宽字体)
Text(
invite.code,
style: const TextStyle(
fontFamily: 'monospace',
fontSize: 15,
fontWeight: FontWeight.w500,
color: AppTheme.textPrimary,
),
),
const SizedBox(width: 8),
// 状态徽章
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: invite.isValid
? AppTheme.successColor.withValues(alpha: 0.1)
: AppTheme.errorColor.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(4),
),
child: Text(
invite.isValid ? '有效' : '已失效',
style: TextStyle(
fontSize: 11,
color: invite.isValid ? AppTheme.successColor : AppTheme.errorColor,
fontWeight: FontWeight.w500,
),
),
),
],
),
const SizedBox(height: 6),
// 第二行:点击数 + 加入数
Row(
children: [
Icon(Icons.touch_app_outlined, size: 14, color: AppTheme.textHint),
const SizedBox(width: 3),
Text(
'${invite.clickCount} 点击',
style: const TextStyle(color: AppTheme.textHint, fontSize: 12),
),
const SizedBox(width: 12),
Icon(Icons.person_add_outlined, size: 14, color: AppTheme.textHint),
const SizedBox(width: 3),
Text(
'${invite.joinCount} 加入',
style: const TextStyle(color: AppTheme.textHint, fontSize: 12),
),
if (invite.expiresAt != null) ...[
const SizedBox(width: 12),
Icon(Icons.schedule, size: 14, color: AppTheme.textHint),
const SizedBox(width: 3),
Text(
dateFormat.format(invite.expiresAt!),
style: const TextStyle(color: AppTheme.textHint, fontSize: 12),
),
],
],
),
],
),
),
// 右侧:删除按钮或箭头
if (!invite.isValid)
GestureDetector(
onTap: onDeactivate,
child: const Padding(
padding: EdgeInsets.all(4),
child: Icon(Icons.delete_outline, size: 20, color: AppTheme.textHint),
),
)
else
const Icon(Icons.chevron_right, color: AppTheme.textHint, size: 20),
],
),
),
);
}
}
/// 邀请详情底部弹窗 —— Twitter 风格
class _InviteDetailSheet extends StatelessWidget {
final Invite invite;
const _InviteDetailSheet({required this.invite});
@override
Widget build(BuildContext context) {
final dateFormat = DateFormat('yyyy-MM-dd HH:mm');
final inviteUrl = invite.link ?? 'http://localhost:3000/join/${invite.code}';
return Container(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// 顶部把手
Container(
width: 36,
height: 4,
decoration: BoxDecoration(
color: AppTheme.dragHandleColor,
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(height: 16),
const Text(
'邀请详情',
style: TextStyle(
fontSize: 17,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
),
const SizedBox(height: 24),
// 二维码
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppTheme.cardBackground,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppTheme.dividerColor),
),
child: QrImageView(
data: inviteUrl,
size: 200,
backgroundColor: AppTheme.cardBackground,
),
),
const SizedBox(height: 20),
// 统计数据
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_SheetStatItem(label: '点击数', value: '${invite.clickCount}'),
Container(
width: 0.5,
height: 30,
color: AppTheme.dividerColor,
),
_SheetStatItem(label: '加入数', value: '${invite.joinCount}'),
Container(
width: 0.5,
height: 30,
color: AppTheme.dividerColor,
),
_SheetStatItem(
label: '创建时间',
value: invite.createdAt != null
? dateFormat.format(invite.createdAt!)
: '',
),
],
),
const SizedBox(height: 20),
// 邀请链接
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: AppTheme.chatInputBg,
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Expanded(
child: Text(
inviteUrl,
style: const TextStyle(fontSize: 13, color: AppTheme.textSecondary),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
GestureDetector(
onTap: () {
// 复制到剪贴板
},
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: AppTheme.primaryColor.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(4),
),
child: const Text(
'复制',
style: TextStyle(
fontSize: 12,
color: AppTheme.primaryColor,
fontWeight: FontWeight.w500,
),
),
),
),
],
),
),
const SizedBox(height: 20),
// 操作按钮
Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: () {
// 分享
},
icon: const Icon(Icons.share, size: 18),
label: const Text('分享'),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
),
),
),
const SizedBox(width: 12),
Expanded(
child: ElevatedButton.icon(
onPressed: () {
// 保存二维码
},
icon: const Icon(Icons.download, size: 18),
label: const Text('保存二维码'),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
),
),
),
],
),
],
),
);
}
}
/// 底部弹窗统计项
class _SheetStatItem extends StatelessWidget {
final String label;
final String value;
const _SheetStatItem({required this.label, required this.value});
@override
Widget build(BuildContext context) {
return Column(
children: [
Text(
value,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
),
const SizedBox(height: 4),
Text(
label,
style: const TextStyle(
fontSize: 12,
color: AppTheme.textHint,
),
),
],
);
}
}

View File

@@ -0,0 +1,290 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:sales_chat/providers/auth_provider.dart';
import 'package:sales_chat/theme/app_theme.dart';
/// 登录页面 —— 微信风格,简约现代
class LoginPage extends StatefulWidget {
const LoginPage({super.key});
@override
State<LoginPage> createState() => _LoginPageState();
}
class _LoginPageState extends State<LoginPage> {
final _formKey = GlobalKey<FormState>();
final _accountController = TextEditingController();
final _passwordController = TextEditingController();
bool _obscurePassword = true;
@override
void dispose() {
_accountController.dispose();
_passwordController.dispose();
super.dispose();
}
/// 处理登录请求
Future<void> _handleLogin() async {
if (!_formKey.currentState!.validate()) return;
final authProvider = context.read<AuthProvider>();
final account = _accountController.text.trim();
// 判断输入是邮箱还是用户名
final isEmail = account.contains('@');
final success = await authProvider.login(
email: isEmail ? account : null,
username: isEmail ? null : account,
password: _passwordController.text,
);
if (success && mounted) {
Navigator.of(context).pushReplacementNamed('/home');
}
}
/// 访客登录弹窗
Future<void> _handleGuestLogin() async {
final nickname = await showDialog<String>(
context: context,
builder: (context) {
final controller = TextEditingController();
return AlertDialog(
title: const Text('访客登录'),
content: TextField(
controller: controller,
decoration: const InputDecoration(
labelText: '昵称',
hintText: '请输入昵称',
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('取消'),
),
ElevatedButton(
onPressed: () => Navigator.pop(context, controller.text.trim()),
child: const Text('登录'),
),
],
);
},
);
if (nickname != null && nickname.isNotEmpty && mounted) {
final authProvider = context.read<AuthProvider>();
final success = await authProvider.loginAsGuest(nickname: nickname);
if (success && mounted) {
Navigator.of(context).pushReplacementNamed('/home');
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppTheme.scaffoldBackground,
body: SafeArea(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 24),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 400),
child: Column(
children: [
// 顶部品牌区域
const SizedBox(height: 48),
Text(
'销售聊天',
style: Theme.of(context).textTheme.headlineLarge?.copyWith(
fontWeight: FontWeight.bold,
color: AppTheme.textPrimary,
fontSize: 28,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 40),
// 白色卡片容器 —— 表单区域
Container(
padding: const EdgeInsets.symmetric(
horizontal: 24,
vertical: 28,
),
decoration: BoxDecoration(
color: AppTheme.cardBackground,
borderRadius: BorderRadius.circular(12),
),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// 账号输入框(邮箱或用户名)
TextFormField(
controller: _accountController,
keyboardType: TextInputType.emailAddress,
decoration: const InputDecoration(
labelText: '邮箱 / 用户名',
hintText: '请输入邮箱或用户名',
prefixIcon: Icon(Icons.person_outline),
),
validator: (value) {
if (value == null || value.isEmpty) {
return '请输入邮箱或用户名';
}
return null;
},
),
const SizedBox(height: 14),
// 密码输入框
TextFormField(
controller: _passwordController,
obscureText: _obscurePassword,
decoration: InputDecoration(
labelText: '密码',
hintText: '请输入密码',
prefixIcon: const Icon(Icons.lock_outlined),
suffixIcon: IconButton(
icon: Icon(
_obscurePassword
? Icons.visibility_off
: Icons.visibility,
),
onPressed: () {
setState(() {
_obscurePassword = !_obscurePassword;
});
},
),
),
validator: (value) {
if (value == null || value.isEmpty) {
return '请输入密码';
}
if (value.length < 6) {
return '密码至少需要6个字符';
}
return null;
},
onFieldSubmitted: (_) => _handleLogin(),
),
const SizedBox(height: 24),
// 登录按钮
Consumer<AuthProvider>(
builder: (context, auth, _) {
return ElevatedButton(
onPressed: auth.isLoading ? null : _handleLogin,
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.primaryColor,
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
child: auth.isLoading
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(
Colors.white),
),
)
: const Text(
'登录',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
);
},
),
],
),
),
),
// 错误信息 —— 卡片下方,红色提示文字
Consumer<AuthProvider>(
builder: (context, auth, _) {
if (auth.error != null) {
return Padding(
padding: const EdgeInsets.only(top: 12),
child: Text(
auth.error!,
style: TextStyle(
color: AppTheme.errorColor,
fontSize: 13,
),
textAlign: TextAlign.center,
),
);
}
return const SizedBox.shrink();
},
),
const SizedBox(height: 20),
// 访客登录 —— 文字按钮,低调
Consumer<AuthProvider>(
builder: (context, auth, _) {
return TextButton(
onPressed: auth.isLoading ? null : _handleGuestLogin,
style: TextButton.styleFrom(
foregroundColor: AppTheme.textSecondary,
),
child: const Text(
'访客登录',
style: TextStyle(fontSize: 14),
),
);
},
),
const SizedBox(height: 8),
// 注册链接
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'没有账号?',
style: TextStyle(
color: AppTheme.textSecondary,
fontSize: 14,
),
),
TextButton(
onPressed: () {
Navigator.of(context).pushReplacementNamed('/register');
},
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 4),
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
child: const Text(
'注册',
style: TextStyle(fontSize: 14),
),
),
],
),
],
),
),
),
),
),
);
}
}

View File

@@ -0,0 +1,333 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:sales_chat/providers/auth_provider.dart';
import 'package:sales_chat/services/api_service.dart';
import 'package:sales_chat/theme/app_theme.dart';
/// 注册页面 —— 微信风格,简约现代
class RegisterPage extends StatefulWidget {
const RegisterPage({super.key});
@override
State<RegisterPage> createState() => _RegisterPageState();
}
class _RegisterPageState extends State<RegisterPage> {
final _formKey = GlobalKey<FormState>();
final _usernameController = TextEditingController();
final _emailController = TextEditingController();
final _nicknameController = TextEditingController();
final _passwordController = TextEditingController();
final _confirmPasswordController = TextEditingController();
final _inviteCodeController = TextEditingController();
bool _obscurePassword = true;
bool _obscureConfirmPassword = true;
@override
void dispose() {
_usernameController.dispose();
_emailController.dispose();
_nicknameController.dispose();
_passwordController.dispose();
_confirmPasswordController.dispose();
_inviteCodeController.dispose();
super.dispose();
}
/// 处理注册请求
Future<void> _handleRegister() async {
if (!_formKey.currentState!.validate()) return;
final authProvider = context.read<AuthProvider>();
final apiService = context.read<ApiService>();
final inviteCode = _inviteCodeController.text.trim();
final success = await authProvider.register(
username: _usernameController.text.trim(),
email: _emailController.text.trim(),
nickname: _nicknameController.text.trim().isNotEmpty
? _nicknameController.text.trim()
: null,
password: _passwordController.text,
);
if (success && mounted) {
// 如果有邀请码,注册后自动加入群组
if (inviteCode.isNotEmpty) {
try {
await apiService.joinInvite(inviteCode);
} catch (e) {
// 加入群组失败不影响注册流程,静默处理
debugPrint('邀请码加入失败: $e');
}
}
Navigator.of(context).pushReplacementNamed('/home');
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppTheme.scaffoldBackground,
body: SafeArea(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 24),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 400),
child: Column(
children: [
// 顶部标题区域
const SizedBox(height: 32),
Text(
'注册',
style: Theme.of(context).textTheme.headlineLarge?.copyWith(
fontWeight: FontWeight.bold,
color: AppTheme.textPrimary,
fontSize: 28,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 28),
// 白色卡片容器 —— 表单区域
Container(
padding: const EdgeInsets.symmetric(
horizontal: 24,
vertical: 24,
),
decoration: BoxDecoration(
color: AppTheme.cardBackground,
borderRadius: BorderRadius.circular(12),
),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// 用户名输入框
TextFormField(
controller: _usernameController,
decoration: const InputDecoration(
labelText: '用户名',
hintText: '3-20位字母/数字/下划线',
prefixIcon: Icon(Icons.person_outline),
),
validator: (value) {
if (value == null || value.isEmpty) {
return '请输入用户名';
}
if (!RegExp(r'^[a-zA-Z0-9_]{3,20}$').hasMatch(value)) {
return '用户名3-20位字母/数字/下划线';
}
return null;
},
),
const SizedBox(height: 14),
// 邮箱输入框
TextFormField(
controller: _emailController,
keyboardType: TextInputType.emailAddress,
decoration: const InputDecoration(
labelText: '邮箱',
hintText: '请输入邮箱',
prefixIcon: Icon(Icons.email_outlined),
),
validator: (value) {
if (value == null || value.isEmpty) {
return '请输入邮箱';
}
if (!value.contains('@')) {
return '请输入有效的邮箱地址';
}
return null;
},
),
const SizedBox(height: 14),
// 昵称输入框(可选)
TextFormField(
controller: _nicknameController,
decoration: const InputDecoration(
labelText: '昵称(可选)',
hintText: '显示名称',
prefixIcon: Icon(Icons.badge_outlined),
),
),
const SizedBox(height: 14),
// 密码输入框
TextFormField(
controller: _passwordController,
obscureText: _obscurePassword,
decoration: InputDecoration(
labelText: '密码',
hintText: '至少6个字符',
prefixIcon: const Icon(Icons.lock_outlined),
suffixIcon: IconButton(
icon: Icon(
_obscurePassword
? Icons.visibility_off
: Icons.visibility,
),
onPressed: () {
setState(() {
_obscurePassword = !_obscurePassword;
});
},
),
),
validator: (value) {
if (value == null || value.isEmpty) {
return '请输入密码';
}
if (value.length < 6) {
return '密码至少需要6个字符';
}
return null;
},
),
const SizedBox(height: 14),
// 确认密码输入框
TextFormField(
controller: _confirmPasswordController,
obscureText: _obscureConfirmPassword,
decoration: InputDecoration(
labelText: '确认密码',
hintText: '再次输入密码',
prefixIcon: const Icon(Icons.lock_outlined),
suffixIcon: IconButton(
icon: Icon(
_obscureConfirmPassword
? Icons.visibility_off
: Icons.visibility,
),
onPressed: () {
setState(() {
_obscureConfirmPassword =
!_obscureConfirmPassword;
});
},
),
),
validator: (value) {
if (value == null || value.isEmpty) {
return '请确认密码';
}
if (value != _passwordController.text) {
return '两次密码不一致';
}
return null;
},
),
const SizedBox(height: 14),
// 邀请码输入框(可选)
TextFormField(
controller: _inviteCodeController,
decoration: const InputDecoration(
labelText: '邀请码(可选)',
hintText: '输入邀请码加入群组',
prefixIcon: Icon(Icons.card_giftcard_outlined),
),
),
const SizedBox(height: 24),
// 注册按钮
Consumer<AuthProvider>(
builder: (context, auth, _) {
return ElevatedButton(
onPressed:
auth.isLoading ? null : _handleRegister,
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.primaryColor,
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
child: auth.isLoading
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(
Colors.white),
),
)
: const Text(
'注册',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
);
},
),
],
),
),
),
// 错误信息 —— 卡片下方,红色提示文字
Consumer<AuthProvider>(
builder: (context, auth, _) {
if (auth.error != null) {
return Padding(
padding: const EdgeInsets.only(top: 12),
child: Text(
auth.error!,
style: TextStyle(
color: AppTheme.errorColor,
fontSize: 13,
),
textAlign: TextAlign.center,
),
);
}
return const SizedBox.shrink();
},
),
const SizedBox(height: 20),
// 返回登录链接
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'已有账号?',
style: TextStyle(
color: AppTheme.textSecondary,
fontSize: 14,
),
),
TextButton(
onPressed: () {
Navigator.of(context).pushReplacementNamed('/login');
},
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 4),
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
child: const Text(
'登录',
style: TextStyle(fontSize: 14),
),
),
],
),
],
),
),
),
),
),
);
}
}