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,58 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:sales_chat/providers/auth_provider.dart';
import 'package:sales_chat/providers/chat_provider.dart';
import 'package:sales_chat/providers/invite_provider.dart';
import 'package:sales_chat/providers/stats_provider.dart';
import 'package:sales_chat/theme/app_theme.dart';
import 'package:sales_chat/pages/login_page.dart';
import 'package:sales_chat/pages/register_page.dart';
import 'package:sales_chat/pages/home_page.dart';
import 'package:sales_chat/services/api_service.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
final apiService = ApiService();
runApp(MyApp(apiService: apiService));
}
class MyApp extends StatelessWidget {
final ApiService apiService;
const MyApp({
super.key,
required this.apiService,
});
@override
Widget build(BuildContext context) {
return MultiProvider(
providers: [
Provider<ApiService>.value(value: apiService),
ChangeNotifierProvider(create: (_) => AuthProvider(apiService)),
ChangeNotifierProvider(create: (_) => ChatProvider(apiService)),
ChangeNotifierProvider(create: (_) => InviteProvider(apiService)),
ChangeNotifierProvider(create: (_) => StatsProvider(apiService)),
],
child: Consumer<AuthProvider>(
builder: (context, auth, _) {
return MaterialApp(
title: 'Sales Chat',
debugShowCheckedModeBanner: false,
theme: AppTheme.lightTheme,
darkTheme: AppTheme.darkTheme,
themeMode: ThemeMode.light,
home: auth.isLoggedIn ? const HomePage() : const LoginPage(),
routes: {
'/login': (context) => const LoginPage(),
'/register': (context) => const RegisterPage(),
'/home': (context) => const HomePage(),
},
);
},
),
);
}
}

View File

@@ -0,0 +1,181 @@
class Group {
final String id;
final String name;
final String? avatar;
final String? description;
final String? owner;
final List<GroupMember> members;
final List<GroupPanel> panels;
final DateTime? createdAt;
final DateTime? updatedAt;
Group({
required this.id,
required this.name,
this.avatar,
this.description,
this.owner,
this.members = const [],
this.panels = const [],
this.createdAt,
this.updatedAt,
});
factory Group.fromJson(Map<String, dynamic> json) {
return Group(
id: (json['_id'] ?? json['id'] ?? '').toString(),
name: json['name'] ?? '',
avatar: json['avatar'],
description: json['description'],
owner: json['owner']?.toString(),
members: (json['members'] as List?)
?.map((e) => GroupMember.fromJson(e))
.toList() ??
[],
panels: (json['panels'] as List?)
?.map((e) => GroupPanel.fromJson(e))
.toList() ??
[],
createdAt: json['createdAt'] != null
? DateTime.parse(json['createdAt'].toString())
: null,
updatedAt: json['updatedAt'] != null
? DateTime.parse(json['updatedAt'].toString())
: null,
);
}
Map<String, dynamic> toJson() {
return {
'_id': id,
'name': name,
'avatar': avatar,
'description': description,
'owner': owner,
'members': members.map((e) => e.toJson()).toList(),
'panels': panels.map((e) => e.toJson()).toList(),
'createdAt': createdAt?.toIso8601String(),
'updatedAt': updatedAt?.toIso8601String(),
};
}
/// 群组成员数量
int get memberCount => members.length;
/// 获取第一个文本面板(频道)用于聊天
GroupPanel? get firstTextPanel {
try {
return panels.firstWhere((p) => p.type == 0);
} catch (_) {
return panels.isNotEmpty ? panels.first : null;
}
}
}
class GroupMember {
final String? userId;
final List<String>? roles;
final DateTime? muteUntil;
GroupMember({
this.userId,
this.roles,
this.muteUntil,
});
factory GroupMember.fromJson(Map<String, dynamic> json) {
return GroupMember(
userId: json['userId']?.toString(),
roles: (json['roles'] as List?)?.map((e) => e.toString()).toList(),
muteUntil: json['muteUntil'] != null
? DateTime.parse(json['muteUntil'].toString())
: null,
);
}
Map<String, dynamic> toJson() {
return {
'userId': userId,
'roles': roles,
'muteUntil': muteUntil?.toIso8601String(),
};
}
}
/// 群组面板类型:
/// 0 = 文本频道
/// 1 = 面板组(文件夹)
/// 2 = 插件面板
class GroupPanel {
final String id;
final String name;
final String? parentId;
final int type;
final String? provider;
final String? pluginPanelName;
final Map<String, dynamic>? meta;
GroupPanel({
required this.id,
required this.name,
this.parentId,
this.type = 0,
this.provider,
this.pluginPanelName,
this.meta,
});
factory GroupPanel.fromJson(Map<String, dynamic> json) {
return GroupPanel(
id: (json['id'] ?? '').toString(),
name: json['name'] ?? '',
parentId: json['parentId']?.toString(),
type: json['type'] ?? 0,
provider: json['provider'],
pluginPanelName: json['pluginPanelName'],
meta: json['meta'],
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'name': name,
'parentId': parentId,
'type': type,
'provider': provider,
'pluginPanelName': pluginPanelName,
'meta': meta,
};
}
}
/// getGroupBasicInfo 返回的基本群组信息
class GroupBasicInfo {
final String groupId;
final String name;
final String? avatar;
final String? description;
final int memberCount;
final String? backgroundImage;
GroupBasicInfo({
required this.groupId,
required this.name,
this.avatar,
this.description,
this.memberCount = 0,
this.backgroundImage,
});
factory GroupBasicInfo.fromJson(Map<String, dynamic> json) {
return GroupBasicInfo(
groupId: (json['groupId'] ?? '').toString(),
name: json['name'] ?? '',
avatar: json['avatar'],
description: json['description'],
memberCount: json['memberCount'] ?? 0,
backgroundImage: json['backgroundImage'],
);
}
}

View File

@@ -0,0 +1,166 @@
class Invite {
final String id;
final String code;
final String salesId;
final String groupId;
final String? link;
final String? qrCodeUrl;
final DateTime? createdAt;
final DateTime? expiresAt;
final int clickCount;
final int scanCount;
final int joinCount;
final String status;
Invite({
required this.id,
required this.code,
required this.salesId,
required this.groupId,
this.link,
this.qrCodeUrl,
this.createdAt,
this.expiresAt,
this.clickCount = 0,
this.scanCount = 0,
this.joinCount = 0,
this.status = 'active',
});
factory Invite.fromJson(Map<String, dynamic> json) {
return Invite(
id: (json['_id'] ?? json['id'] ?? '').toString(),
code: json['code'] ?? '',
salesId: (json['salesId'] ?? '').toString(),
groupId: (json['groupId'] ?? '').toString(),
link: json['link'],
qrCodeUrl: json['qrCodeUrl'],
createdAt: json['createdAt'] != null
? DateTime.parse(json['createdAt'].toString())
: null,
expiresAt: json['expiresAt'] != null
? DateTime.parse(json['expiresAt'].toString())
: null,
clickCount: json['clickCount'] ?? 0,
scanCount: json['scanCount'] ?? 0,
joinCount: json['joinCount'] ?? 0,
status: json['status'] ?? 'active',
);
}
Map<String, dynamic> toJson() {
return {
'_id': id,
'code': code,
'salesId': salesId,
'groupId': groupId,
'link': link,
'qrCodeUrl': qrCodeUrl,
'createdAt': createdAt?.toIso8601String(),
'expiresAt': expiresAt?.toIso8601String(),
'clickCount': clickCount,
'scanCount': scanCount,
'joinCount': joinCount,
'status': status,
};
}
bool get isExpired =>
expiresAt != null && DateTime.now().isAfter(expiresAt!);
bool get isActive => status == 'active' && !isExpired;
bool get isValid => isActive;
}
/// 单个邀请的统计数据,由 invite.getStats 返回
class InviteStats {
final String code;
final String? link;
final DateTime? createdAt;
final DateTime? expiresAt;
final InviteStatsDetail stats;
final List<AccessLog> recentAccess;
InviteStats({
required this.code,
this.link,
this.createdAt,
this.expiresAt,
required this.stats,
this.recentAccess = const [],
});
factory InviteStats.fromJson(Map<String, dynamic> json) {
return InviteStats(
code: json['code'] ?? '',
link: json['link'],
createdAt: json['createdAt'] != null
? DateTime.parse(json['createdAt'].toString())
: null,
expiresAt: json['expiresAt'] != null
? DateTime.parse(json['expiresAt'].toString())
: null,
stats: InviteStatsDetail.fromJson(json['stats'] ?? {}),
recentAccess: (json['recentAccess'] as List?)
?.map((e) => AccessLog.fromJson(e))
.toList() ??
[],
);
}
}
class InviteStatsDetail {
final int clicks;
final int scans;
final int joins;
final String conversionRate;
InviteStatsDetail({
this.clicks = 0,
this.scans = 0,
this.joins = 0,
this.conversionRate = '0',
});
factory InviteStatsDetail.fromJson(Map<String, dynamic> json) {
return InviteStatsDetail(
clicks: json['clicks'] ?? 0,
scans: json['scans'] ?? 0,
joins: json['joins'] ?? 0,
conversionRate: json['conversionRate']?.toString() ?? '0',
);
}
double get conversionRateDouble => double.tryParse(conversionRate) ?? 0.0;
}
/// 来自后端的访问日志条目
class AccessLog {
final String? id;
final String inviteCode;
final String? visitorId;
final String accessType;
final DateTime? timestamp;
final String? ipAddress;
AccessLog({
this.id,
required this.inviteCode,
this.visitorId,
required this.accessType,
this.timestamp,
this.ipAddress,
});
factory AccessLog.fromJson(Map<String, dynamic> json) {
return AccessLog(
id: (json['_id'] ?? json['id'])?.toString(),
inviteCode: json['inviteCode'] ?? '',
visitorId: json['visitorId']?.toString(),
accessType: json['accessType'] ?? '',
timestamp: json['timestamp'] != null
? DateTime.parse(json['timestamp'].toString())
: null,
ipAddress: json['ipAddress'],
);
}
}

View File

@@ -0,0 +1,81 @@
class Message {
final String id;
final String content;
final String? author;
final String? groupId;
final String converseId;
final bool hasRecall;
final Map<String, dynamic>? meta;
final DateTime? createdAt;
final DateTime? updatedAt;
Message({
required this.id,
required this.content,
this.author,
this.groupId,
required this.converseId,
this.hasRecall = false,
this.meta,
this.createdAt,
this.updatedAt,
});
factory Message.fromJson(Map<String, dynamic> json) {
return Message(
id: (json['_id'] ?? json['id'] ?? '').toString(),
content: json['content'] ?? '',
author: json['author']?.toString(),
groupId: json['groupId']?.toString(),
converseId: (json['converseId'] ?? '').toString(),
hasRecall: json['hasRecall'] ?? false,
meta: json['meta'],
createdAt: json['createdAt'] != null
? DateTime.parse(json['createdAt'].toString())
: null,
updatedAt: json['updatedAt'] != null
? DateTime.parse(json['updatedAt'].toString())
: null,
);
}
Map<String, dynamic> toJson() {
return {
'_id': id,
'content': content,
'author': author,
'groupId': groupId,
'converseId': converseId,
'hasRecall': hasRecall,
'meta': meta,
'createdAt': createdAt?.toIso8601String(),
'updatedAt': updatedAt?.toIso8601String(),
};
}
/// Determine message type from meta
MessageType get type {
if (hasRecall) return MessageType.recalled;
final metaType = meta?['type'];
if (metaType == 'image' || metaType == 'file') {
return MessageType.file;
}
if (metaType == 'system') {
return MessageType.system;
}
return MessageType.text;
}
/// Convenience alias for author (sender ID)
String get senderId => author ?? '';
/// For display purposes - get sender name from meta if available
String get senderName => meta?['nickname']?.toString() ?? '';
}
enum MessageType {
text,
file,
system,
recalled,
}

View File

@@ -0,0 +1,5 @@
export 'user.dart';
export 'message.dart';
export 'group.dart';
export 'invite.dart';
export 'stats.dart';

View File

@@ -0,0 +1,154 @@
/// My stats returned by stats.getMyStats
class MyStats {
final TotalStats total;
final TodayStats today;
final List<InviteSummary> recentInvites;
MyStats({
required this.total,
required this.today,
this.recentInvites = const [],
});
factory MyStats.fromJson(Map<String, dynamic> json) {
return MyStats(
total: TotalStats.fromJson(json['total'] ?? {}),
today: TodayStats.fromJson(json['today'] ?? {}),
recentInvites: (json['recentInvites'] as List?)
?.map((e) => InviteSummary.fromJson(e))
.toList() ??
[],
);
}
}
class TotalStats {
final int totalInvites;
final int totalJoins;
final int totalConversions;
final double totalRevenue;
TotalStats({
this.totalInvites = 0,
this.totalJoins = 0,
this.totalConversions = 0,
this.totalRevenue = 0,
});
factory TotalStats.fromJson(Map<String, dynamic> json) {
return TotalStats(
totalInvites: json['totalInvites'] ?? 0,
totalJoins: json['totalJoins'] ?? 0,
totalConversions: json['totalConversions'] ?? 0,
totalRevenue: (json['totalRevenue'] ?? 0).toDouble(),
);
}
}
class TodayStats {
final int invitesCreated;
final int joins;
final int conversions;
final double revenue;
TodayStats({
this.invitesCreated = 0,
this.joins = 0,
this.conversions = 0,
this.revenue = 0,
});
factory TodayStats.fromJson(Map<String, dynamic> json) {
return TodayStats(
invitesCreated: json['invitesCreated'] ?? 0,
joins: json['joins'] ?? 0,
conversions: json['conversions'] ?? 0,
revenue: (json['revenue'] ?? 0).toDouble(),
);
}
}
class InviteSummary {
final String? id;
final String? code;
final String? groupId;
final String? link;
final int? clickCount;
final int? joinCount;
final DateTime? createdAt;
InviteSummary({
this.id,
this.code,
this.groupId,
this.link,
this.clickCount,
this.joinCount,
this.createdAt,
});
factory InviteSummary.fromJson(Map<String, dynamic> json) {
return InviteSummary(
id: (json['_id'] ?? json['id'])?.toString(),
code: json['code'],
groupId: json['groupId']?.toString(),
link: json['link'],
clickCount: json['clickCount'],
joinCount: json['joinCount'],
createdAt: json['createdAt'] != null
? DateTime.parse(json['createdAt'].toString())
: null,
);
}
}
/// Ranking entry returned by stats.getRanking
class RankingEntry {
final int rank;
final String salesId;
final int totalJoins;
final int totalConversions;
final double totalRevenue;
final Map<String, dynamic>? user;
RankingEntry({
this.rank = 0,
required this.salesId,
this.totalJoins = 0,
this.totalConversions = 0,
this.totalRevenue = 0,
this.user,
});
factory RankingEntry.fromJson(Map<String, dynamic> json) {
return RankingEntry(
rank: json['rank'] ?? 0,
salesId: (json['_id'] ?? json['salesId'] ?? '').toString(),
totalJoins: json['totalJoins'] ?? 0,
totalConversions: json['totalConversions'] ?? 0,
totalRevenue: (json['totalRevenue'] ?? 0).toDouble(),
user: json['user'],
);
}
String get displayName =>
user?['nickname']?.toString() ?? user?['email']?.toString() ?? salesId;
}
/// Platform stats returned by stats.getPlatformStats
class PlatformStats {
final Map<String, dynamic> platform;
final Map<String, dynamic> today;
PlatformStats({
this.platform = const {},
this.today = const {},
});
factory PlatformStats.fromJson(Map<String, dynamic> json) {
return PlatformStats(
platform: json['platform'] ?? {},
today: json['today'] ?? {},
);
}
}

View File

@@ -0,0 +1,118 @@
class User {
final String id;
final String? email;
final String? username;
final String? nickname;
final String? avatar;
final String? discriminator;
final bool? temporary;
final String? type;
final bool? emailVerified;
final bool? banned;
final Map<String, dynamic>? extra;
final DateTime? createdAt;
User({
required this.id,
this.email,
this.username,
this.nickname,
this.avatar,
this.discriminator,
this.temporary,
this.type,
this.emailVerified,
this.banned,
this.extra,
this.createdAt,
});
factory User.fromJson(Map<String, dynamic> json) {
return User(
id: (json['_id'] ?? json['id'] ?? '').toString(),
email: json['email'],
username: json['username'],
nickname: json['nickname'],
avatar: json['avatar'],
discriminator: json['discriminator']?.toString(),
temporary: json['temporary'],
type: json['type'],
emailVerified: json['emailVerified'],
banned: json['banned'],
extra: json['extra'],
createdAt: json['createdAt'] != null
? DateTime.parse(json['createdAt'].toString())
: null,
);
}
Map<String, dynamic> toJson() {
return {
'_id': id,
'email': email,
'username': username,
'nickname': nickname,
'avatar': avatar,
'discriminator': discriminator,
'temporary': temporary,
'type': type,
'emailVerified': emailVerified,
'banned': banned,
'extra': extra,
'createdAt': createdAt?.toIso8601String(),
};
}
/// Display name: nickname first, then username, then email prefix
String get displayName {
if (nickname != null && nickname!.isNotEmpty) return nickname!;
if (username != null && username!.isNotEmpty) return username!;
if (email != null && email!.isNotEmpty) return email!.split('@').first;
return 'User';
}
/// Check if user is a guest/temporary user
bool get isGuest => temporary == true;
/// Role checks - these may come from extra fields or custom logic
/// In Tailchat, roles are not built-in; they are managed by the plugin
bool get isAdmin {
final role = extra?['role'];
return role == 'admin' || role == 'super_admin';
}
bool get isSuperAdmin {
final role = extra?['role'];
return role == 'super_admin';
}
bool get isSales {
final role = extra?['role'];
return role == 'sales' || role == null;
}
}
/// Tailchat login/register response wraps the user info with a token.
/// The backend returns the user object directly with a `token` field appended.
/// The gateway wraps all responses as `{ code, data }`.
class AuthResponse {
final User user;
final String token;
AuthResponse({required this.user, required this.token});
/// Parse from the gateway response.
/// Gateway returns: { "code": 200, "data": { ...userFields..., "token": "jwt..." } }
factory AuthResponse.fromJson(Map<String, dynamic> json) {
// The gateway wraps response in { code, data }
final data = json['data'] ?? json;
final token = data['token'] ?? '';
final user = User.fromJson(data);
return AuthResponse(
user: user,
token: token,
);
}
}

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),
),
),
],
),
],
),
),
),
),
),
);
}
}

View File

@@ -0,0 +1,164 @@
import 'package:flutter/material.dart';
import 'package:sales_chat/models/user.dart';
import 'package:sales_chat/services/api_service.dart';
class AuthProvider with ChangeNotifier {
final ApiService _apiService;
User? _user;
bool _isLoading = false;
String? _error;
AuthProvider(this._apiService);
User? get user => _user;
bool get isLoading => _isLoading;
String? get error => _error;
bool get isLoggedIn => _user != null;
Future<bool> get isAuthenticated => _apiService.isAuthenticated;
/// 使用邮箱/用户名和密码登录。
/// 至少提供其中一个 [email] 或 [username]。
Future<bool> login({
String? email,
String? username,
required String password,
}) async {
_isLoading = true;
_error = null;
notifyListeners();
try {
final response = await _apiService.login(
email: email,
username: username,
password: password,
);
_user = response.user;
_isLoading = false;
notifyListeners();
return true;
} catch (e) {
_error = _parseError(e);
_isLoading = false;
notifyListeners();
return false;
}
}
/// 以访客身份登录(临时用户)。
Future<bool> loginAsGuest({required String nickname}) async {
_isLoading = true;
_error = null;
notifyListeners();
try {
final response = await _apiService.createTemporaryUser(nickname: nickname);
_user = response.user;
_isLoading = false;
notifyListeners();
return true;
} catch (e) {
_error = _parseError(e);
_isLoading = false;
notifyListeners();
return false;
}
}
/// 注册新用户。
Future<bool> register({
String? username,
String? email,
String? nickname,
required String password,
}) async {
_isLoading = true;
_error = null;
notifyListeners();
try {
final response = await _apiService.register(
username: username,
email: email,
nickname: nickname,
password: password,
);
_user = response.user;
_isLoading = false;
notifyListeners();
return true;
} catch (e) {
_error = _parseRegisterError(e);
_isLoading = false;
notifyListeners();
return false;
}
}
Future<void> logout() async {
_isLoading = true;
notifyListeners();
try {
await _apiService.logout();
} finally {
_user = null;
_isLoading = false;
notifyListeners();
}
}
Future<void> loadUser() async {
if (!await _apiService.isAuthenticated) return;
_isLoading = true;
notifyListeners();
try {
_user = await _apiService.getCurrentUser();
} catch (e) {
_user = null;
} finally {
_isLoading = false;
notifyListeners();
}
}
void clearError() {
_error = null;
notifyListeners();
}
String _parseError(dynamic e) {
final errStr = e.toString();
if (errStr.contains('401')) {
return 'Email/username or password incorrect';
} else if (errStr.contains('422') || errStr.contains('password')) {
return 'Email/username or password incorrect';
} else if (errStr.contains('timeout')) {
return 'Network timeout, please retry';
} else if (errStr.contains('SocketException')) {
return 'Network connection failed';
} else if (errStr.contains('banned')) {
return 'Account has been banned';
}
return 'Login failed, please retry';
}
String _parseRegisterError(dynamic e) {
final errorStr = e.toString();
if (errorStr.contains('exist')) {
return 'Username or email already exists';
} else if (errorStr.contains('empty')) {
return 'Username or email is required';
} else if (errorStr.contains('timeout')) {
return 'Network timeout, please retry';
} else if (errorStr.contains('SocketException')) {
return 'Network connection failed';
} else if (errorStr.contains('not allowed')) {
return 'Server does not allow new user registration';
}
return 'Registration failed, please retry';
}
}

View File

@@ -0,0 +1,197 @@
import 'package:flutter/material.dart';
import 'package:sales_chat/models/group.dart';
import 'package:sales_chat/models/message.dart';
import 'package:sales_chat/services/api_service.dart';
class ChatProvider with ChangeNotifier {
final ApiService _apiService;
List<Group> _groups = [];
Map<String, List<Message>> _messagesByConverse = {};
Group? _currentGroup;
bool _isLoading = false;
bool _isLoadingMessages = false;
String? _error;
ChatProvider(this._apiService);
List<Group> get groups => _groups;
Group? get currentGroup => _currentGroup;
bool get isLoading => _isLoading;
bool get isLoadingMessages => _isLoadingMessages;
String? get error => _error;
List<Message> get currentMessages {
if (_currentGroup == null) return [];
// 使用第一个文本面板的 ID 作为 converseId
final converseId = _currentGroup!.firstTextPanel?.id ?? _currentGroup!.id;
return _messagesByConverse[converseId] ?? [];
}
/// 获取群组的 converseId第一个文本面板或以群组 ID 作为备选)
String _getConverseIdForGroup(Group group) {
return group.firstTextPanel?.id ?? group.id;
}
Future<void> loadGroups() async {
_isLoading = true;
_error = null;
notifyListeners();
try {
_groups = await _apiService.getGroups();
_isLoading = false;
notifyListeners();
} catch (e) {
_error = 'Failed to load groups';
_isLoading = false;
notifyListeners();
}
}
void selectGroup(Group group) {
_currentGroup = group;
notifyListeners();
final converseId = _getConverseIdForGroup(group);
if (_messagesByConverse[converseId] == null) {
loadMessages(converseId);
}
}
void deselectGroup() {
_currentGroup = null;
notifyListeners();
}
Future<void> loadMessages(String converseId, {bool refresh = false}) async {
if (_isLoadingMessages) return;
_isLoadingMessages = true;
notifyListeners();
try {
final messages = await _apiService.getMessages(
converseId,
limit: 50,
before: refresh ? null : (_messagesByConverse[converseId]?.first.id),
);
if (refresh) {
_messagesByConverse[converseId] = messages;
} else {
_messagesByConverse[converseId] = [
...messages,
...(_messagesByConverse[converseId] ?? [])
];
}
_isLoadingMessages = false;
notifyListeners();
} catch (e) {
_error = 'Failed to load messages';
_isLoadingMessages = false;
notifyListeners();
}
}
Future<void> sendMessage(String content) async {
if (_currentGroup == null || content.isEmpty) return;
final converseId = _getConverseIdForGroup(_currentGroup!);
final groupId = _currentGroup!.id;
try {
final message = await _apiService.sendMessage(
converseId,
content,
groupId: groupId,
);
_messagesByConverse[converseId] = [
...(_messagesByConverse[converseId] ?? []),
message,
];
notifyListeners();
} catch (e) {
_error = 'Failed to send message';
notifyListeners();
}
}
/// 撤回消息
Future<bool> recallMessage(String messageId) async {
try {
await _apiService.recallMessage(messageId);
// 更新本地消息列表
if (_currentGroup != null) {
final converseId = _getConverseIdForGroup(_currentGroup!);
final messages = _messagesByConverse[converseId];
if (messages != null) {
final idx = messages.indexWhere((m) => m.id == messageId);
if (idx != -1) {
// 标记为已撤回而非删除
final old = messages[idx];
messages[idx] = Message(
id: old.id,
content: 'Message recalled',
author: old.author,
groupId: old.groupId,
converseId: old.converseId,
hasRecall: true,
meta: old.meta,
createdAt: old.createdAt,
updatedAt: old.updatedAt,
);
notifyListeners();
}
}
}
return true;
} catch (e) {
_error = 'Failed to recall message: $e';
notifyListeners();
return false;
}
}
/// 删除消息(管理员)
Future<bool> deleteMessage(String messageId) async {
try {
await _apiService.deleteMessage(messageId);
// 从本地消息列表中移除
if (_currentGroup != null) {
final converseId = _getConverseIdForGroup(_currentGroup!);
final messages = _messagesByConverse[converseId];
if (messages != null) {
messages.removeWhere((m) => m.id == messageId);
notifyListeners();
}
}
return true;
} catch (e) {
_error = 'Failed to delete message: $e';
notifyListeners();
return false;
}
}
/// 添加通过 socket.io 接收的消息
void addMessage(Message message) {
final converseId = message.converseId;
_messagesByConverse[converseId] = [
...(_messagesByConverse[converseId] ?? []),
message,
];
notifyListeners();
}
void clearError() {
_error = null;
notifyListeners();
}
}

View File

@@ -0,0 +1,135 @@
import 'package:flutter/material.dart';
import 'package:sales_chat/models/group.dart';
import 'package:sales_chat/models/invite.dart';
import 'package:sales_chat/services/api_service.dart';
class InviteProvider with ChangeNotifier {
final ApiService _apiService;
List<Invite> _invites = [];
Invite? _selectedInvite;
InviteStats? _selectedInviteStats;
List<Group> _availableGroups = [];
bool _isLoading = false;
bool _isCreating = false;
String? _error;
InviteProvider(this._apiService);
List<Invite> get invites => _invites;
Invite? get selectedInvite => _selectedInvite;
InviteStats? get selectedInviteStats => _selectedInviteStats;
List<Group> get availableGroups => _availableGroups;
bool get isLoading => _isLoading;
bool get isCreating => _isCreating;
String? get error => _error;
Future<void> loadInvites() async {
_isLoading = true;
_error = null;
notifyListeners();
try {
_invites = await _apiService.getMyInvites();
_isLoading = false;
notifyListeners();
} catch (e) {
_error = 'Failed to load invite list';
_isLoading = false;
notifyListeners();
}
}
Future<void> loadAvailableGroups(List<Group> groups) async {
_availableGroups = groups;
notifyListeners();
}
Future<Invite?> createInvite({
required String groupId,
int? expiresInDays,
}) async {
_isCreating = true;
_error = null;
notifyListeners();
try {
final invite = await _apiService.createInvite(
groupId: groupId,
expiresInDays: expiresInDays,
);
_invites.insert(0, invite);
_isCreating = false;
notifyListeners();
return invite;
} catch (e) {
_error = 'Failed to create invite';
_isCreating = false;
notifyListeners();
return null;
}
}
Future<void> selectInvite(Invite invite) async {
_selectedInvite = invite;
notifyListeners();
await loadInviteStats(invite.code);
}
Future<void> loadInviteStats(String code) async {
try {
_selectedInviteStats = await _apiService.getInviteStats(code);
notifyListeners();
} catch (e) {
// Handle error silently
}
}
Future<void> deactivateInvite(String code) async {
try {
await _apiService.deactivateInvite(code);
final index = _invites.indexWhere((i) => i.code == code);
if (index != -1) {
final invite = _invites[index];
_invites[index] = Invite(
id: invite.id,
code: invite.code,
salesId: invite.salesId,
groupId: invite.groupId,
link: invite.link,
qrCodeUrl: invite.qrCodeUrl,
createdAt: invite.createdAt,
expiresAt: invite.expiresAt,
clickCount: invite.clickCount,
scanCount: invite.scanCount,
joinCount: invite.joinCount,
status: 'inactive',
);
}
if (_selectedInvite?.code == code) {
_selectedInvite = null;
_selectedInviteStats = null;
}
notifyListeners();
} catch (e) {
_error = 'Failed to deactivate invite';
notifyListeners();
}
}
void clearError() {
_error = null;
notifyListeners();
}
void clearSelection() {
_selectedInvite = null;
_selectedInviteStats = null;
notifyListeners();
}
}

View File

@@ -0,0 +1,4 @@
export 'auth_provider.dart';
export 'chat_provider.dart';
export 'invite_provider.dart';
export 'stats_provider.dart';

View File

@@ -0,0 +1,56 @@
import 'package:flutter/material.dart';
import 'package:sales_chat/models/stats.dart';
import 'package:sales_chat/services/api_service.dart';
class StatsProvider with ChangeNotifier {
final ApiService _apiService;
MyStats? _myStats;
List<RankingEntry> _ranking = [];
bool _isLoading = false;
String? _error;
StatsProvider(this._apiService);
MyStats? get myStats => _myStats;
List<RankingEntry> get ranking => _ranking;
bool get isLoading => _isLoading;
String? get error => _error;
/// Load the current user's stats
Future<void> loadMyStats() async {
_isLoading = true;
_error = null;
notifyListeners();
try {
_myStats = await _apiService.getMyStats();
_isLoading = false;
notifyListeners();
} catch (e) {
_error = 'Failed to load stats';
_isLoading = false;
notifyListeners();
}
}
/// Load the ranking list
Future<void> loadRanking({String period = 'monthly'}) async {
try {
_ranking = await _apiService.getRanking(period: period);
notifyListeners();
} catch (e) {
// Handle silently
}
}
void clearError() {
_error = null;
notifyListeners();
}
Future<void> refresh() async {
await loadMyStats();
await loadRanking();
}
}

View File

@@ -0,0 +1,443 @@
import 'dart:convert';
import 'package:dio/dio.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:sales_chat/models/user.dart';
import 'package:sales_chat/models/group.dart';
import 'package:sales_chat/models/message.dart';
import 'package:sales_chat/models/invite.dart';
import 'package:sales_chat/models/stats.dart';
/// 与 Tailchat 后端通信的 API 服务。
///
/// 关键约定:
/// - 所有 API 路由都在 `/api/`(网关路径)下。
/// - Tailchat 核心用户路由:`/api/user/<action>`
/// - 插件路由:`/api/plugin:com.msgbyte.saleschat/<action>`(冒号用于分隔 Moleculer 服务名段)
/// - 认证使用 `X-Token` 请求头(非 Authorization: Bearer
/// - 网关响应格式为 `{ "code": <int>, "data": <payload> }`。
class ApiService {
late final Dio _dio;
final FlutterSecureStorage _storage;
static const String _baseUrl = 'http://localhost:3000/api';
static const String _tokenKey = 'auth_token';
static const String _userKey = 'user_data';
ApiService() : _storage = const FlutterSecureStorage(
aOptions: AndroidOptions(encryptedSharedPreferences: true),
iOptions: IOSOptions(
accessibility: KeychainAccessibility.first_unlock_this_device,
),
) {
_dio = Dio(BaseOptions(
baseUrl: _baseUrl,
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 30),
headers: {
'Content-Type': 'application/json',
},
));
_dio.interceptors.add(InterceptorsWrapper(
onRequest: (options, handler) async {
// Tailchat 使用 X-Token 请求头进行认证
final token = await _storage.read(key: _tokenKey);
if (token != null) {
options.headers['X-Token'] = token;
}
return handler.next(options);
},
onError: (error, handler) async {
if (error.response?.statusCode == 401) {
await _storage.delete(key: _tokenKey);
await _storage.delete(key: _userKey);
}
return handler.next(error);
},
));
}
Future<String?> get token => _storage.read(key: _tokenKey);
Future<bool> get isAuthenticated async =>
await _storage.read(key: _tokenKey) != null;
/// 解包网关响应:{ "code": 200, "data": ... }
dynamic _unwrapResponse(Response response) {
final body = response.data;
if (body is Map<String, dynamic> && body.containsKey('data')) {
return body['data'];
}
return body;
}
// ============================================================
// 认证 API - Tailchat 核心用户服务
// ============================================================
/// 使用邮箱或用户名 + 密码登录。
/// 后端接口POST /api/user/login {email|username, password}
/// 响应(已解包):{ ...userFields, token: "jwt..." }
Future<AuthResponse> login({
String? email,
String? username,
required String password,
}) async {
final data = <String, dynamic>{
'password': password,
};
if (email != null && email.isNotEmpty) {
data['email'] = email;
} else if (username != null && username.isNotEmpty) {
data['username'] = username;
}
final response = await _dio.post('/user/login', data: data);
final unwrapped = _unwrapResponse(response);
// 登录响应是用户对象附加了 `token`
final authResponse = AuthResponse.fromJson({
'data': unwrapped,
});
await _storage.write(key: _tokenKey, value: authResponse.token);
await _storage.write(
key: _userKey, value: jsonEncode(authResponse.user.toJson()));
return authResponse;
}
/// 注册新用户。
/// 后端接口POST /api/user/register {username, email, nickname, password}
/// 响应(已解包):{ ...userFields, token: "jwt..." }
Future<AuthResponse> register({
String? username,
String? email,
String? nickname,
required String password,
}) async {
final data = <String, dynamic>{
'password': password,
};
if (username != null && username.isNotEmpty) data['username'] = username;
if (email != null && email.isNotEmpty) data['email'] = email;
if (nickname != null && nickname.isNotEmpty) data['nickname'] = nickname;
final response = await _dio.post('/user/register', data: data);
final unwrapped = _unwrapResponse(response);
final authResponse = AuthResponse.fromJson({
'data': unwrapped,
});
await _storage.write(key: _tokenKey, value: authResponse.token);
await _storage.write(
key: _userKey, value: jsonEncode(authResponse.user.toJson()));
return authResponse;
}
/// 创建临时(访客)用户。
/// 后端接口POST /api/user/createTemporaryUser {nickname}
Future<AuthResponse> createTemporaryUser({required String nickname}) async {
final response = await _dio.post('/user/createTemporaryUser', data: {
'nickname': nickname,
});
final unwrapped = _unwrapResponse(response);
final authResponse = AuthResponse.fromJson({
'data': unwrapped,
});
await _storage.write(key: _tokenKey, value: authResponse.token);
await _storage.write(
key: _userKey, value: jsonEncode(authResponse.user.toJson()));
return authResponse;
}
/// 登出(仅本地 - JWT 是无状态的)
Future<void> logout() async {
await _storage.delete(key: _tokenKey);
await _storage.delete(key: _userKey);
}
/// 从本地缓存获取当前用户
Future<User?> getCurrentUser() async {
final userData = await _storage.read(key: _userKey);
if (userData == null) return null;
try {
return User.fromJson(jsonDecode(userData));
} catch (e) {
return null;
}
}
// ============================================================
// 群组 API - Tailchat 核心群组服务
// ============================================================
/// 获取当前用户加入的群组。
/// 后端接口GET /api/group/getUserGroups
Future<List<Group>> getGroups() async {
final response = await _dio.get('/group/getUserGroups');
final data = _unwrapResponse(response);
return (data as List).map((e) => Group.fromJson(e)).toList();
}
/// 获取单个群组的基本信息。
/// 后端接口GET /api/group/getGroupBasicInfo {groupId}
Future<GroupBasicInfo> getGroupBasicInfo(String groupId) async {
final response =
await _dio.get('/group/getGroupBasicInfo', queryParameters: {
'groupId': groupId,
});
final data = _unwrapResponse(response);
return GroupBasicInfo.fromJson(data);
}
/// 获取完整群组信息。
/// 后端接口GET /api/group/getGroupInfo {groupId}
Future<Group> getGroupInfo(String groupId) async {
final response = await _dio.get('/group/getGroupInfo', queryParameters: {
'groupId': groupId,
});
final data = _unwrapResponse(response);
return Group.fromJson(data);
}
/// 创建新群组。
/// 后端接口POST /api/group/createGroup {name, panels}
Future<Group> createGroup(String name, {String? description}) async {
// Tailchat 创建群组时至少需要一个文本面板
final response = await _dio.post('/group/createGroup', data: {
'name': name,
'panels': [
{
'id': 'default',
'name': 'General',
'type': 0, // GroupPanelType.TEXT文本面板类型
}
],
});
final data = _unwrapResponse(response);
return Group.fromJson(data);
}
// ============================================================
// 消息 API - Tailchat 核心 chat.message 服务
// ============================================================
/// 获取会话消息。
/// 后端接口GET /api/chat.message/fetchConverseMessage {converseId, startId?}
Future<List<Message>> getMessages(String converseId,
{int limit = 50, String? before}) async {
final response = await _dio.get(
'/chat.message/fetchConverseMessage',
queryParameters: {
'converseId': converseId,
if (before != null) 'startId': before,
});
final data = _unwrapResponse(response);
return (data as List).map((e) => Message.fromJson(e)).toList();
}
/// 发送消息。
/// 后端接口POST /api/chat.message/sendMessage {converseId, content, groupId?, meta?}
Future<Message> sendMessage(String converseId, String content,
{String? groupId}) async {
final response =
await _dio.post('/chat.message/sendMessage', data: {
'converseId': converseId,
'content': content,
if (groupId != null) 'groupId': groupId,
});
final data = _unwrapResponse(response);
return Message.fromJson(data);
}
/// 撤回消息2 分钟内)。
/// 后端接口POST /api/chat.message/recallMessage {messageId}
Future<void> recallMessage(String messageId) async {
await _dio.post('/chat.message/recallMessage', data: {
'messageId': messageId,
});
}
/// 删除消息(管理员)。
/// 后端接口POST /api/chat.message/deleteMessage {messageId}
Future<void> deleteMessage(String messageId) async {
await _dio.post('/chat.message/deleteMessage', data: {
'messageId': messageId,
});
}
// ============================================================
// 插件 API - plugin:com.msgbyte.saleschat
// Tailchat 网关路由:/api/plugin:<pluginName>/<action>
// ============================================================
/// 创建邀请。
Future<Invite> createInvite({
required String groupId,
int? expiresInDays,
}) async {
final data = <String, dynamic>{
'groupId': groupId,
};
if (expiresInDays != null) {
data['expiresIn'] = expiresInDays;
}
final response = await _dio.post(
'/plugin:com.msgbyte.saleschat/inviteCreate',
data: data);
final unwrapped = _unwrapResponse(response);
return Invite.fromJson(unwrapped);
}
/// 获取我的邀请。
Future<List<Invite>> getMyInvites() async {
final response = await _dio
.get('/plugin:com.msgbyte.saleschat/inviteGetMyInvites');
final data = _unwrapResponse(response);
return (data as List).map((e) => Invite.fromJson(e)).toList();
}
/// 通过邀请码获取邀请(公开,无需认证)。
Future<Invite> getInviteByCode(String code) async {
final response = await _dio.get(
'/plugin:com.msgbyte.saleschat/inviteGetByCode',
queryParameters: {'code': code});
final data = _unwrapResponse(response);
return Invite.fromJson(data);
}
/// 获取邀请统计。
Future<InviteStats> getInviteStats(String code) async {
final response = await _dio.get(
'/plugin:com.msgbyte.saleschat/inviteGetStats',
queryParameters: {'code': code});
final data = _unwrapResponse(response);
return InviteStats.fromJson(data);
}
/// 停用邀请。
Future<void> deactivateInvite(String code) async {
await _dio.post(
'/plugin:com.msgbyte.saleschat/inviteDeactivate',
data: {'code': code});
}
/// 通过邀请码加入。
Future<Map<String, dynamic>> joinInvite(String code) async {
final response = await _dio.post(
'/plugin:com.msgbyte.saleschat/inviteJoin',
data: {'code': code});
return _unwrapResponse(response);
}
/// 获取我的统计数据。
Future<MyStats> getMyStats() async {
final response =
await _dio.get('/plugin:com.msgbyte.saleschat/statsGetMyStats');
final data = _unwrapResponse(response);
return MyStats.fromJson(data);
}
/// 获取排行榜。
Future<List<RankingEntry>> getRanking({String period = 'monthly'}) async {
final response = await _dio.get(
'/plugin:com.msgbyte.saleschat/statsGetRanking',
queryParameters: {'period': period});
final data = _unwrapResponse(response);
return (data as List).map((e) => RankingEntry.fromJson(e)).toList();
}
/// 获取趋势数据。
Future<List<Map<String, dynamic>>> getTrend({
required String salesId,
String period = 'daily',
}) async {
final response = await _dio.get(
'/plugin:com.msgbyte.saleschat/statsGetTrend',
queryParameters: {
'salesId': salesId,
'period': period,
});
final data = _unwrapResponse(response);
return (data as List).cast<Map<String, dynamic>>();
}
/// 获取团队统计(仅管理员)。
Future<List<Map<String, dynamic>>> getTeamStats() async {
final response = await _dio
.get('/plugin:com.msgbyte.saleschat/statsGetTeamStats');
final data = _unwrapResponse(response);
return (data as List).cast<Map<String, dynamic>>();
}
/// 获取平台统计(仅超级管理员)。
Future<PlatformStats> getPlatformStats() async {
final response = await _dio
.get('/plugin:com.msgbyte.saleschat/statsGetPlatformStats');
final data = _unwrapResponse(response);
return PlatformStats.fromJson(data);
}
/// 获取用户列表(仅管理员)。
Future<List<Map<String, dynamic>>> getUsers() async {
final response = await _dio
.get('/plugin:com.msgbyte.saleschat/adminGetUserList');
final data = _unwrapResponse(response);
return (data as List).cast<Map<String, dynamic>>();
}
/// 更新用户角色(仅超级管理员)。
Future<void> updateUserRole(String userId, String role) async {
await _dio.post(
'/plugin:com.msgbyte.saleschat/adminUpdateUserRole',
data: {'userId': userId, 'role': role});
}
/// 删除用户(仅超级管理员)。
Future<void> deleteUser(String userId,
{String type = 'soft', String reason = ''}) async {
await _dio.post(
'/plugin:com.msgbyte.saleschat/adminDeleteUser',
data: {
'userId': userId,
'type': type,
if (reason.isNotEmpty) 'reason': reason,
});
}
/// 获取群组列表(仅管理员)。
Future<List<Map<String, dynamic>>> getAdminGroups() async {
final response = await _dio
.get('/plugin:com.msgbyte.saleschat/adminGetGroupList');
final data = _unwrapResponse(response);
return (data as List).cast<Map<String, dynamic>>();
}
/// 获取群组统计(仅管理员)。
Future<Map<String, dynamic>> getGroupStats(String groupId) async {
final response = await _dio.get(
'/plugin:com.msgbyte.saleschat/adminGetGroupStats',
queryParameters: {'groupId': groupId});
return _unwrapResponse(response);
}
/// 踢出群组成员(仅管理员)。
Future<void> kickGroupMember(String groupId, String userId,
{String? reason}) async {
await _dio.post(
'/plugin:com.msgbyte.saleschat/adminKickUser',
data: {
'groupId': groupId,
'userId': userId,
if (reason != null) 'reason': reason,
});
}
}

View File

@@ -0,0 +1,323 @@
import 'package:flutter/material.dart';
/// Twitter 风格现代主题 —— 简洁、专业、值得信赖
///
/// 设计原则:
/// - 主色Twitter Blue #1DA1F2
/// - 背景:纯净白 + 浅灰蓝
/// - 无阴影,细线边框分隔
/// - 文字:清晰的三级灰度
class AppTheme {
// 主色系 —— Twitter Blue
static const Color primaryColor = Color(0xFF1DA1F2);
static const Color primaryColorDark = Color(0xFF1A91DA);
static const Color primaryColorLight = Color(0xFFE8F5FE);
// 背景
static const Color scaffoldBackground = Color(0xFFF5F8FA);
static const Color scaffoldBackgroundDark = Color(0xFF000000);
// 语义色
static const Color accentColor = Color(0xFF1DA1F2);
static const Color errorColor = Color(0xFFE0245E);
static const Color successColor = Color(0xFF17BF63);
static const Color warningColor = Color(0xFFFFAD1F);
static const Color infoColor = Color(0xFF1DA1F2);
// 文字层级
static const Color textPrimary = Color(0xFF14171A);
static const Color textSecondary = Color(0xFF657786);
static const Color textHint = Color(0xFFAAB8C2);
static const Color textDisabled = Color(0xFFCCD6DD);
// 分隔线 / 边框
static const Color dividerColor = Color(0xFFE1E8ED);
// 卡片/面板
static const Color cardBackground = Colors.white;
static const Color cardBackgroundDark = Color(0xFF16202A);
// 聊天气泡
static const Color bubbleSelf = Color(0xFF1DA1F2); // 蓝色气泡(己方)
static const Color bubbleOther = Color(0xFFEFF3F6); // 浅灰气泡(对方)
static const Color chatInputBg = Color(0xFFF5F8FA);
// 辅助色
static const Color dragHandleColor = Color(0xFFCCD6DD);
static const Color timestampBg = Color(0xFFE8ECF0);
static const Color timestampText = Color(0xFF657786);
// ============================================================
// 浅色主题(默认)
// ============================================================
static ThemeData get lightTheme {
return ThemeData(
useMaterial3: true,
brightness: Brightness.light,
colorScheme: ColorScheme.light(
primary: primaryColor,
onPrimary: Colors.white,
secondary: primaryColor,
onSecondary: Colors.white,
surface: Colors.white,
onSurface: textPrimary,
error: errorColor,
onError: Colors.white,
outline: dividerColor,
outlineVariant: Color(0xFFE1E8ED),
),
scaffoldBackgroundColor: scaffoldBackground,
// AppBar —— 白底,底部分隔线
appBarTheme: const AppBarTheme(
backgroundColor: Colors.white,
foregroundColor: textPrimary,
elevation: 0,
centerTitle: false,
titleTextStyle: TextStyle(
color: textPrimary,
fontSize: 20,
fontWeight: FontWeight.w800,
),
surfaceTintColor: Colors.transparent,
shadowColor: Colors.transparent,
),
// 卡片 —— 白底、细边框、无阴影
cardTheme: CardThemeData(
color: cardBackground,
elevation: 0,
margin: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: const BorderSide(color: dividerColor, width: 0.5),
),
),
// 按钮
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: primaryColor,
foregroundColor: Colors.white,
disabledBackgroundColor: const Color(0xFFAADAF5),
disabledForegroundColor: Colors.white70,
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
),
textStyle: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
),
elevation: 0,
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
foregroundColor: primaryColor,
side: const BorderSide(color: primaryColor, width: 1.5),
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
),
),
),
textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom(
foregroundColor: primaryColor,
textStyle: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600),
),
),
// 输入框
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: const Color(0xFFF5F8FA),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(color: dividerColor, width: 1),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(color: dividerColor, width: 1),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(color: primaryColor, width: 2),
),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(color: errorColor, width: 1),
),
contentPadding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
hintStyle: const TextStyle(color: textHint, fontSize: 15),
labelStyle: const TextStyle(color: textSecondary, fontSize: 14),
),
// 底部导航
bottomNavigationBarTheme: const BottomNavigationBarThemeData(
backgroundColor: Colors.white,
selectedItemColor: primaryColor,
unselectedItemColor: textHint,
type: BottomNavigationBarType.fixed,
elevation: 0,
selectedLabelStyle: TextStyle(fontSize: 11, fontWeight: FontWeight.w600),
unselectedLabelStyle: TextStyle(fontSize: 11),
),
// 分隔线
dividerTheme: const DividerThemeData(
color: dividerColor,
thickness: 0.5,
space: 0.5,
),
// 对话框
dialogTheme: DialogThemeData(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
titleTextStyle: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w800,
color: textPrimary,
),
),
// SnackBar
snackBarTheme: SnackBarThemeData(
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
// FAB
floatingActionButtonTheme: const FloatingActionButtonThemeData(
backgroundColor: primaryColor,
foregroundColor: Colors.white,
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(16)),
),
),
// TabBar
tabBarTheme: TabBarThemeData(
labelColor: primaryColor,
unselectedLabelColor: textSecondary,
indicatorColor: primaryColor,
indicatorSize: TabBarIndicatorSize.label,
labelStyle: const TextStyle(fontSize: 15, fontWeight: FontWeight.w700),
unselectedLabelStyle: const TextStyle(fontSize: 15),
),
// Chip
chipTheme: ChipThemeData(
backgroundColor: const Color(0xFFF5F8FA),
selectedColor: primaryColorLight,
labelStyle: const TextStyle(fontSize: 13, color: textPrimary),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
side: BorderSide.none,
),
// List Tile
listTileTheme: const ListTileThemeData(
contentPadding: EdgeInsets.symmetric(horizontal: 16),
minVerticalPadding: 12,
),
// 页面过渡
pageTransitionsTheme: const PageTransitionsTheme(
builders: {
TargetPlatform.android: CupertinoPageTransitionsBuilder(),
TargetPlatform.iOS: CupertinoPageTransitionsBuilder(),
},
),
);
}
// ============================================================
// 深色主题
// ============================================================
static ThemeData get darkTheme {
const darkSurface = Color(0xFF192734);
const darkBg = Color(0xFF000000);
return ThemeData(
useMaterial3: true,
brightness: Brightness.dark,
colorScheme: ColorScheme.dark(
primary: primaryColor,
onPrimary: Colors.white,
secondary: primaryColor,
surface: darkSurface,
onSurface: Colors.white,
error: errorColor,
),
scaffoldBackgroundColor: darkBg,
appBarTheme: const AppBarTheme(
backgroundColor: darkSurface,
foregroundColor: Colors.white,
elevation: 0,
centerTitle: false,
titleTextStyle: TextStyle(
color: Colors.white,
fontSize: 20,
fontWeight: FontWeight.w800,
),
surfaceTintColor: Colors.transparent,
),
cardTheme: CardThemeData(
color: darkSurface,
elevation: 0,
margin: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: const BorderSide(color: Color(0xFF38444D), width: 0.5),
),
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: primaryColor,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
),
elevation: 0,
),
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: const Color(0xFF192734),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(color: Color(0xFF38444D), width: 1),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(color: Color(0xFF38444D), width: 1),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(color: primaryColor, width: 2),
),
contentPadding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
),
bottomNavigationBarTheme: const BottomNavigationBarThemeData(
backgroundColor: darkSurface,
selectedItemColor: primaryColor,
unselectedItemColor: Color(0xFF657786),
type: BottomNavigationBarType.fixed,
elevation: 0,
),
dividerTheme: const DividerThemeData(
color: Color(0xFF38444D),
thickness: 0.5,
),
dialogTheme: DialogThemeData(
backgroundColor: darkSurface,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
),
tabBarTheme: const TabBarThemeData(
labelColor: primaryColor,
unselectedLabelColor: Color(0xFF657786),
indicatorColor: primaryColor,
),
);
}
}

View File

@@ -0,0 +1,139 @@
import 'package:intl/intl.dart';
class DateTimeUtils {
static final DateFormat _dateFormat = DateFormat('yyyy-MM-dd');
static final DateFormat _timeFormat = DateFormat('HH:mm');
static final DateFormat _dateTimeFormat = DateFormat('yyyy-MM-dd HH:mm');
static final DateFormat _monthFormat = DateFormat('yyyy-MM');
static String formatDate(DateTime date) {
return _dateFormat.format(date);
}
static String formatTime(DateTime date) {
return _timeFormat.format(date);
}
static String formatDateTime(DateTime date) {
return _dateTimeFormat.format(date);
}
static String formatMonth(DateTime date) {
return _monthFormat.format(date);
}
static String formatRelative(DateTime date) {
final now = DateTime.now();
final diff = now.difference(date);
if (diff.inMinutes < 1) {
return '刚刚';
} else if (diff.inMinutes < 60) {
return '${diff.inMinutes}分钟前';
} else if (diff.inHours < 24) {
return '${diff.inHours}小时前';
} else if (diff.inDays < 7) {
return '${diff.inDays}天前';
} else if (diff.inDays < 30) {
return '${(diff.inDays / 7).floor()}周前';
} else if (diff.inDays < 365) {
return '${(diff.inDays / 30).floor()}个月前';
} else {
return formatDate(date);
}
}
static String formatSmart(DateTime date) {
final now = DateTime.now();
final today = DateTime(now.year, now.month, now.day);
final yesterday = today.subtract(const Duration(days: 1));
final dateDay = DateTime(date.year, date.month, date.day);
if (dateDay == today) {
return '今天 ${formatTime(date)}';
} else if (dateDay == yesterday) {
return '昨天 ${formatTime(date)}';
} else if (now.difference(date).inDays < 7) {
return formatRelative(date);
} else {
return formatDateTime(date);
}
}
}
class NumberUtils {
static String formatCompact(int number) {
if (number >= 100000000) {
return '${(number / 100000000).toStringAsFixed(1)}亿';
} else if (number >= 10000) {
return '${(number / 10000).toStringAsFixed(1)}';
} else if (number >= 1000) {
return '${(number / 1000).toStringAsFixed(1)}k';
}
return number.toString();
}
static String formatPercent(double value, {int decimals = 1}) {
return '${(value * 100).toStringAsFixed(decimals)}%';
}
static String formatCurrency(double value, {String symbol = '¥'}) {
return '$symbol${value.toStringAsFixed(2)}';
}
}
class Validators {
static String? required(String? value, [String? fieldName]) {
if (value == null || value.trim().isEmpty) {
return '请输入${fieldName ?? '内容'}';
}
return null;
}
static String? phone(String? value) {
if (value == null || value.isEmpty) {
return '请输入手机号';
}
if (!RegExp(r'^1[3-9]\d{9}$').hasMatch(value)) {
return '请输入有效的手机号';
}
return null;
}
static String? email(String? value) {
if (value == null || value.isEmpty) {
return '请输入邮箱';
}
if (!RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$').hasMatch(value)) {
return '请输入有效的邮箱';
}
return null;
}
static String? password(String? value) {
if (value == null || value.isEmpty) {
return '请输入密码';
}
if (value.length < 6) {
return '密码至少6位';
}
if (value.length > 20) {
return '密码最多20位';
}
return null;
}
static String? minLength(String? value, int length, [String? fieldName]) {
if (value == null || value.length < length) {
return '${fieldName ?? '内容'}至少$length个字符';
}
return null;
}
static String? maxLength(String? value, int length, [String? fieldName]) {
if (value != null && value.length > length) {
return '${fieldName ?? '内容'}最多$length个字符';
}
return null;
}
}

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';