feat: 添加好友功能并集成 LiteLLM 代理服务

- 新增好友搜索、添加、好友列表功能
- 集成 LiteLLM 代理服务及多模型定价配置
- 更新 iOS CocoaPods 配置
- 更新 .gitignore 和环境变量配置
This commit is contained in:
zl-q
2026-03-11 09:14:51 +08:00
parent 487405aa5b
commit e55e445906
28 changed files with 1226 additions and 181 deletions
+112
View File
@@ -7,6 +7,7 @@
objects = {
/* Begin PBXBuildFile section */
09639293B873EB105430001E /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1ACBA06AC19BA16E8A19E32E /* Pods_Runner.framework */; };
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
@@ -14,6 +15,7 @@
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
AF132A70E83FBE1B638C6F9F /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 90FE62ECAC858C9D6D8F555A /* Pods_RunnerTests.framework */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -42,12 +44,16 @@
/* Begin PBXFileReference section */
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
1ACBA06AC19BA16E8A19E32E /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
2D3506D6151CBE9FA501D0C6 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
4B05AE96C27BBD0C62C10BF8 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
90FE62ECAC858C9D6D8F555A /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; };
@@ -55,6 +61,10 @@
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
990B7AE4995B1DA3D21F475A /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = "<group>"; };
9B0AA8E2CDF83E1EE2B9D8B0 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
DCDE481F29A6AC188DDBFB70 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = "<group>"; };
E891B130134FBA205ED3C2E4 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@@ -62,6 +72,15 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
09639293B873EB105430001E /* Pods_Runner.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
98F37246806251D350F291F4 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
AF132A70E83FBE1B638C6F9F /* Pods_RunnerTests.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -76,6 +95,29 @@
path = RunnerTests;
sourceTree = "<group>";
};
4F9D784F5CA1FA42C9C1C04D /* Frameworks */ = {
isa = PBXGroup;
children = (
1ACBA06AC19BA16E8A19E32E /* Pods_Runner.framework */,
90FE62ECAC858C9D6D8F555A /* Pods_RunnerTests.framework */,
);
name = Frameworks;
sourceTree = "<group>";
};
72B87A4C26233A1630FEF9DB /* Pods */ = {
isa = PBXGroup;
children = (
9B0AA8E2CDF83E1EE2B9D8B0 /* Pods-Runner.debug.xcconfig */,
2D3506D6151CBE9FA501D0C6 /* Pods-Runner.release.xcconfig */,
4B05AE96C27BBD0C62C10BF8 /* Pods-Runner.profile.xcconfig */,
990B7AE4995B1DA3D21F475A /* Pods-RunnerTests.debug.xcconfig */,
DCDE481F29A6AC188DDBFB70 /* Pods-RunnerTests.release.xcconfig */,
E891B130134FBA205ED3C2E4 /* Pods-RunnerTests.profile.xcconfig */,
);
name = Pods;
path = Pods;
sourceTree = "<group>";
};
9740EEB11CF90186004384FC /* Flutter */ = {
isa = PBXGroup;
children = (
@@ -94,6 +136,8 @@
97C146F01CF9000F007C117D /* Runner */,
97C146EF1CF9000F007C117D /* Products */,
331C8082294A63A400263BE5 /* RunnerTests */,
72B87A4C26233A1630FEF9DB /* Pods */,
4F9D784F5CA1FA42C9C1C04D /* Frameworks */,
);
sourceTree = "<group>";
};
@@ -128,8 +172,10 @@
isa = PBXNativeTarget;
buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
buildPhases = (
6CA8EFA5842F4100A4769160 /* [CP] Check Pods Manifest.lock */,
331C807D294A63A400263BE5 /* Sources */,
331C807F294A63A400263BE5 /* Resources */,
98F37246806251D350F291F4 /* Frameworks */,
);
buildRules = (
);
@@ -145,12 +191,14 @@
isa = PBXNativeTarget;
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
buildPhases = (
C6D974DBE54952499F275A1B /* [CP] Check Pods Manifest.lock */,
9740EEB61CF901F6004384FC /* Run Script */,
97C146EA1CF9000F007C117D /* Sources */,
97C146EB1CF9000F007C117D /* Frameworks */,
97C146EC1CF9000F007C117D /* Resources */,
9705A1C41CF9048500538489 /* Embed Frameworks */,
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
A5E6C420AAAF89A8795B6C9E /* [CP] Embed Pods Frameworks */,
);
buildRules = (
);
@@ -238,6 +286,28 @@
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin";
};
6CA8EFA5842F4100A4769160 /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
"${PODS_ROOT}/Manifest.lock",
);
name = "[CP] Check Pods Manifest.lock";
outputFileListPaths = (
);
outputPaths = (
"$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
showEnvVarsInLog = 0;
};
9740EEB61CF901F6004384FC /* Run Script */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
@@ -253,6 +323,45 @@
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
};
A5E6C420AAAF89A8795B6C9E /* [CP] Embed Pods Frameworks */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
);
name = "[CP] Embed Pods Frameworks";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
showEnvVarsInLog = 0;
};
C6D974DBE54952499F275A1B /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
"${PODS_ROOT}/Manifest.lock",
);
name = "[CP] Check Pods Manifest.lock";
outputFileListPaths = (
);
outputPaths = (
"$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
showEnvVarsInLog = 0;
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
@@ -378,6 +487,7 @@
};
331C8088294A63A400263BE5 /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 990B7AE4995B1DA3D21F475A /* Pods-RunnerTests.debug.xcconfig */;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
@@ -395,6 +505,7 @@
};
331C8089294A63A400263BE5 /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = DCDE481F29A6AC188DDBFB70 /* Pods-RunnerTests.release.xcconfig */;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
@@ -410,6 +521,7 @@
};
331C808A294A63A400263BE5 /* Profile */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = E891B130134FBA205ED3C2E4 /* Pods-RunnerTests.profile.xcconfig */;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
+4
View File
@@ -11,6 +11,7 @@ import '../../features/auth/data/auth_repository.dart';
import '../../features/auth/data/auth_repository_impl.dart';
import '../../features/auth/presentation/bloc/auth_bloc.dart';
import '../../features/calendar/ui/calendar_state_manager.dart';
import '../../features/friends/data/friends_api.dart';
import '../../features/users/data/users_api.dart';
final sl = GetIt.instance;
@@ -44,6 +45,9 @@ Future<void> configureDependencies() async {
final usersApi = UsersApi(apiClient);
sl.registerSingleton<UsersApi>(usersApi);
final friendsApi = FriendsApi(apiClient);
sl.registerSingleton<FriendsApi>(friendsApi);
final authRepository = AuthRepositoryImpl(
api: authApi,
tokenStorage: tokenStorage,
@@ -1,7 +1,12 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../../../../core/di/injection.dart';
import '../../../../core/theme/design_tokens.dart';
import '../../../../shared/widgets/toast/index.dart';
import '../../../../shared/widgets/page_header.dart' as widgets;
import '../../../friends/data/friends_api.dart';
import '../../../users/data/models/user_response.dart';
import '../../../users/data/users_api.dart';
class ContactsScreen extends StatefulWidget {
const ContactsScreen({super.key});
@@ -12,37 +17,156 @@ class ContactsScreen extends StatefulWidget {
class _ContactsScreenState extends State<ContactsScreen> {
final _searchController = TextEditingController();
final _searchFocusNode = FocusNode();
final List<ContactItem> _recentContacts = [
ContactItem(
name: 'Toki',
email: 'toki@xunmee.com',
color: AppColors.blue500,
),
ContactItem(
name: 'Mina',
email: 'mina@xunmee.com',
color: AppColors.violet600,
),
];
List<FriendResponse> _friends = [];
List<UserResponse> _searchResults = [];
bool _isLoading = true;
bool _isSearching = false;
bool _hasSearched = false;
final Set<String> _sentRequestIds = {};
Set<String> _friendIds = {};
final List<ContactItem> _allContacts = [
ContactItem(name: 'Aki', email: 'aki@xunmee.com', color: AppColors.blue600),
ContactItem(
name: 'Lynn',
email: 'lynn@xunmee.com',
color: const Color(0xFF0EA5E9),
),
ContactItem(
name: 'Nora',
email: 'nora@xunmee.com',
color: AppColors.violet500,
),
];
@override
void initState() {
super.initState();
_loadFriends();
}
Future<void> _loadFriends() async {
try {
final friendsApi = sl<FriendsApi>();
final friends = await friendsApi.getFriends();
if (mounted) {
setState(() {
_friends = friends;
_friendIds = friends.map((f) => f.friend.id).toSet();
_isLoading = false;
});
}
} catch (e) {
if (mounted) {
setState(() {
_isLoading = false;
});
}
}
}
bool _isValidEmail(String email) {
final emailRegex = RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,}$');
return emailRegex.hasMatch(email);
}
Future<void> _onSearch() async {
final query = _searchController.text.trim();
if (query.isEmpty) {
Toast.show(context, '请输入邮箱地址', type: ToastType.warning);
return;
}
if (!_isValidEmail(query)) {
Toast.show(context, '请输入有效的邮箱地址', type: ToastType.warning);
return;
}
setState(() {
_isSearching = true;
_searchResults = [];
_hasSearched = true;
});
try {
final usersApi = sl<UsersApi>();
final results = await usersApi.searchUsers(query);
if (mounted) {
setState(() {
_searchResults = results;
_isSearching = false;
});
}
} catch (e) {
if (mounted) {
setState(() {
_isSearching = false;
});
Toast.show(context, '搜索失败,请稍后重试', type: ToastType.error);
}
}
}
Future<void> _sendFriendRequest(String targetUserId, String? content) async {
try {
final friendsApi = sl<FriendsApi>();
await friendsApi.sendRequest(targetUserId);
if (mounted) {
setState(() {
_sentRequestIds.add(targetUserId);
});
Toast.show(context, '好友请求已发送', type: ToastType.success);
}
} catch (e) {
if (mounted) {
Toast.show(context, '发送失败,请稍后重试', type: ToastType.error);
}
}
}
void _showAddFriendDialog(UserResponse user) {
final controller = TextEditingController();
showDialog(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('添加好友'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('${user.username} 发送好友请求'),
const SizedBox(height: 16),
TextField(
controller: controller,
decoration: const InputDecoration(
labelText: '验证消息(可选)',
hintText: '你好,我是...',
border: OutlineInputBorder(),
),
maxLines: 3,
maxLength: 200,
),
],
),
actions: [
TextButton(
onPressed: () {
controller.dispose();
Navigator.pop(dialogContext);
},
child: const Text('取消'),
),
FilledButton(
onPressed: () {
Navigator.pop(dialogContext);
_sendFriendRequest(
user.id,
controller.text.isEmpty ? null : controller.text,
);
},
child: const Text('发送'),
),
],
),
).then((_) => controller.dispose());
}
@override
void dispose() {
_searchController.dispose();
_searchFocusNode.dispose();
super.dispose();
}
@@ -61,14 +185,21 @@ class _ContactsScreenState extends State<ContactsScreen> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildSearchRow(),
const SizedBox(height: 16),
_buildSectionTitle('最近联系'),
const SizedBox(height: 8),
_buildContactCard(_recentContacts),
_buildSearchResults(),
const SizedBox(height: 16),
_buildSectionTitle('全部联系人'),
const SizedBox(height: 8),
_buildContactCard(_allContacts),
if (_isLoading)
const Center(
child: Padding(
padding: EdgeInsets.all(20),
child: CircularProgressIndicator(),
),
)
else if (_friends.isEmpty)
_buildEmptyState()
else
_buildContactCard(_friends),
],
),
),
@@ -83,36 +214,52 @@ class _ContactsScreenState extends State<ContactsScreen> {
return Row(
children: [
Expanded(
child: GestureDetector(
onTap: () {},
child: Container(
height: 40,
padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(
color: AppColors.surfaceTertiary,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0xFFE4EBF7)),
),
child: Row(
children: [
const Icon(Icons.search, size: 16, color: AppColors.slate400),
const SizedBox(width: 8),
Text(
'搜索联系人',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: AppColors.slate400,
),
),
],
child: Container(
height: 40,
decoration: BoxDecoration(
color: AppColors.surfaceTertiary,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0xFFE4EBF7)),
),
child: TextField(
controller: _searchController,
focusNode: _searchFocusNode,
decoration: const InputDecoration(
hintText: '输入邮箱搜索用户',
hintStyle: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: AppColors.slate400,
),
prefixIcon: Icon(
Icons.search,
size: 16,
color: AppColors.slate400,
),
border: InputBorder.none,
contentPadding: EdgeInsets.symmetric(
horizontal: 12,
vertical: 10,
),
),
style: const TextStyle(fontSize: 13),
keyboardType: TextInputType.emailAddress,
textInputAction: TextInputAction.search,
onSubmitted: (_) => _onSearch(),
onChanged: (value) {
if (value.isEmpty) {
setState(() {
_hasSearched = false;
_searchResults = [];
});
}
},
),
),
),
const SizedBox(width: 10),
GestureDetector(
onTap: () => context.push('/contacts/add'),
onTap: _onSearch,
child: Container(
width: 40,
height: 40,
@@ -121,17 +268,233 @@ class _ContactsScreenState extends State<ContactsScreen> {
borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0xFFD7E6FF)),
),
child: const Icon(
Icons.person_add,
size: 16,
color: AppColors.blue500,
),
child: _isSearching
? const Padding(
padding: EdgeInsets.all(10),
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.search, size: 16, color: AppColors.blue500),
),
),
],
);
}
Widget _buildSearchResults() {
if (!_hasSearched) {
return const SizedBox.shrink();
}
return Container(
margin: const EdgeInsets.only(top: 8),
decoration: BoxDecoration(
color: AppColors.white,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.08),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Column(
children: [
if (_isSearching)
Container(
padding: const EdgeInsets.all(20),
child: const Center(child: CircularProgressIndicator()),
)
else if (_searchResults.isEmpty)
Container(
padding: const EdgeInsets.all(20),
child: const Center(
child: Text(
'未找到该用户',
style: TextStyle(fontSize: 14, color: AppColors.slate500),
),
),
)
else
Column(
children: [
for (int i = 0; i < _searchResults.length; i++) ...[
_buildSearchResultItem(_searchResults[i]),
if (i < _searchResults.length - 1)
Container(
height: 1,
margin: const EdgeInsets.symmetric(horizontal: 14),
color: const Color(0xFFEEF2F7),
),
],
],
),
],
),
);
}
Widget _buildSearchResultItem(UserResponse user) {
final isFriend = _friendIds.contains(user.id);
final isSent = _sentRequestIds.contains(user.id);
final avatarColor = _getAvatarColor(user.id);
return Container(
height: 70,
padding: const EdgeInsets.symmetric(horizontal: 14),
child: Row(
children: [
Container(
width: 42,
height: 42,
decoration: BoxDecoration(
color: _getAvatarBackground(avatarColor),
borderRadius: BorderRadius.circular(21),
border: Border.all(color: _getAvatarBorder(avatarColor)),
),
child: user.avatarUrl != null
? ClipRRect(
borderRadius: BorderRadius.circular(21),
child: Image.network(
user.avatarUrl!,
width: 42,
height: 42,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => Icon(
Icons.person,
size: 18,
color: _getAvatarColor(user.id),
),
),
)
: Icon(Icons.person, size: 18, color: _getAvatarColor(user.id)),
),
const SizedBox(width: 12),
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
user.username,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: AppColors.slate900,
),
),
if (user.bio != null) ...[
const SizedBox(height: 2),
Text(
user.bio!,
style: const TextStyle(
fontSize: 12,
color: AppColors.slate500,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
],
),
),
_buildAddButton(user.id, isFriend, isSent),
],
),
);
}
Widget _buildAddButton(String userId, bool isFriend, bool isSent) {
if (isFriend) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: AppColors.slate300,
borderRadius: BorderRadius.circular(8),
),
child: const Text(
'已是好友',
style: TextStyle(fontSize: 12, color: AppColors.slate500),
),
);
}
if (isSent) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: AppColors.slate300,
borderRadius: BorderRadius.circular(8),
),
child: const Text(
'已发送',
style: TextStyle(fontSize: 12, color: AppColors.slate500),
),
);
}
return GestureDetector(
onTap: () {
final user = _searchResults.firstWhere((u) => u.id == userId);
_showAddFriendDialog(user);
},
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: const Color(0xFFF1F7FF),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: const Color(0xFFD7E6FF)),
),
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.person_add, size: 14, color: AppColors.blue500),
SizedBox(width: 4),
Text(
'添加',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: AppColors.blue500,
),
),
],
),
),
);
}
Widget _buildEmptyState() {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(32),
decoration: BoxDecoration(
color: AppColors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: const Color(0xFFE3EAF6)),
),
child: Column(
children: [
const Icon(Icons.person_outline, size: 48, color: AppColors.slate400),
const SizedBox(height: 12),
const Text(
'暂无联系人',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w500,
color: AppColors.slate500,
),
),
const SizedBox(height: 4),
const Text(
'搜索邮箱添加好友开始聊天吧',
style: TextStyle(fontSize: 13, color: AppColors.slate400),
),
],
),
);
}
Widget _buildSectionTitle(String title) {
return Text(
title,
@@ -143,7 +506,7 @@ class _ContactsScreenState extends State<ContactsScreen> {
);
}
Widget _buildContactCard(List<ContactItem> contacts) {
Widget _buildContactCard(List<FriendResponse> friends) {
return Container(
decoration: BoxDecoration(
color: AppColors.white,
@@ -152,9 +515,9 @@ class _ContactsScreenState extends State<ContactsScreen> {
),
child: Column(
children: [
for (int i = 0; i < contacts.length; i++) ...[
_buildContactItem(contacts[i]),
if (i < contacts.length - 1)
for (int i = 0; i < friends.length; i++) ...[
_buildContactItem(friends[i]),
if (i < friends.length - 1)
Container(
height: 1,
margin: const EdgeInsets.symmetric(horizontal: 14),
@@ -166,9 +529,12 @@ class _ContactsScreenState extends State<ContactsScreen> {
);
}
Widget _buildContactItem(ContactItem contact) {
Widget _buildContactItem(FriendResponse friend) {
final friendInfo = friend.friend;
final color = _getAvatarColor(friendInfo.id);
return GestureDetector(
onTap: () => context.push('/contacts/add?id=${contact.email}'),
onTap: () => context.push('/contacts/add?id=${friendInfo.id}'),
child: Container(
height: 70,
padding: const EdgeInsets.symmetric(horizontal: 14),
@@ -178,11 +544,23 @@ class _ContactsScreenState extends State<ContactsScreen> {
width: 42,
height: 42,
decoration: BoxDecoration(
color: _getAvatarBackground(contact.color),
color: _getAvatarBackground(color),
borderRadius: BorderRadius.circular(21),
border: Border.all(color: _getAvatarBorder(contact.color)),
border: Border.all(color: _getAvatarBorder(color)),
),
child: Icon(Icons.person, size: 18, color: contact.color),
child: friendInfo.avatarUrl != null
? ClipRRect(
borderRadius: BorderRadius.circular(21),
child: Image.network(
friendInfo.avatarUrl!,
width: 42,
height: 42,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) =>
Icon(Icons.person, size: 18, color: color),
),
)
: Icon(Icons.person, size: 18, color: color),
),
const SizedBox(width: 12),
Expanded(
@@ -191,22 +569,13 @@ class _ContactsScreenState extends State<ContactsScreen> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
contact.name,
friendInfo.username,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: AppColors.slate900,
),
),
const SizedBox(height: 4),
Text(
contact.email,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: AppColors.slate500,
),
),
],
),
),
@@ -216,6 +585,18 @@ class _ContactsScreenState extends State<ContactsScreen> {
);
}
Color _getAvatarColor(String id) {
final colors = [
AppColors.blue500,
AppColors.violet600,
AppColors.blue600,
const Color(0xFF0EA5E9),
AppColors.violet500,
];
final index = id.hashCode.abs() % colors.length;
return colors[index];
}
Color _getAvatarBackground(Color color) {
if (color == AppColors.blue500) return const Color(0xFFEEF4FF);
if (color == AppColors.violet600) return AppColors.surfaceInfoLight;
@@ -234,11 +615,3 @@ class _ContactsScreenState extends State<ContactsScreen> {
return const Color(0xFFDDE8FB);
}
}
class ContactItem {
final String name;
final String email;
final Color color;
ContactItem({required this.name, required this.email, required this.color});
}
@@ -0,0 +1,96 @@
import 'package:social_app/core/api/i_api_client.dart';
class FriendsApi {
final IApiClient _client;
static const _prefix = '/api/v1/friends';
FriendsApi(this._client);
Future<List<FriendResponse>> getFriends() async {
final response = await _client.get(_prefix);
final List<dynamic> data = response.data;
return data.map((json) => FriendResponse.fromJson(json)).toList();
}
Future<List<FriendResponse>> getIncomingRequests() async {
final response = await _client.get('$_prefix/requests/inbox');
final List<dynamic> data = response.data;
return data.map((json) => FriendResponse.fromJson(json)).toList();
}
Future<List<FriendResponse>> getOutgoingRequests() async {
final response = await _client.get('$_prefix/requests/outgoing');
final List<dynamic> data = response.data;
return data.map((json) => FriendResponse.fromJson(json)).toList();
}
Future<FriendResponse> sendRequest(String targetUserId) async {
final response = await _client.post(
'$_prefix/requests',
data: {'target_user_id': targetUserId},
);
return FriendResponse.fromJson(response.data);
}
Future<FriendResponse> acceptRequest(String friendshipId) async {
final response = await _client.post(
'$_prefix/requests/$friendshipId/accept',
);
return FriendResponse.fromJson(response.data);
}
Future<FriendResponse> declineRequest(String friendshipId) async {
final response = await _client.post(
'$_prefix/requests/$friendshipId/decline',
);
return FriendResponse.fromJson(response.data);
}
Future<void> removeFriend(String friendshipId) async {
await _client.delete('$_prefix/$friendshipId');
}
}
class FriendResponse {
final String id;
final UserBasicInfo friend;
final String status;
final DateTime createdAt;
final DateTime? acceptedAt;
FriendResponse({
required this.id,
required this.friend,
required this.status,
required this.createdAt,
this.acceptedAt,
});
factory FriendResponse.fromJson(Map<String, dynamic> json) {
return FriendResponse(
id: json['id'] as String,
friend: UserBasicInfo.fromJson(json['friend'] as Map<String, dynamic>),
status: json['status'] as String,
createdAt: DateTime.parse(json['created_at'] as String),
acceptedAt: json['accepted_at'] != null
? DateTime.parse(json['accepted_at'] as String)
: null,
);
}
}
class UserBasicInfo {
final String id;
final String username;
final String? avatarUrl;
UserBasicInfo({required this.id, required this.username, this.avatarUrl});
factory UserBasicInfo.fromJson(Map<String, dynamic> json) {
return UserBasicInfo(
id: json['id'] as String,
username: json['username'] as String,
avatarUrl: json['avatar_url'] as String?,
);
}
}
@@ -3,6 +3,7 @@ import 'package:go_router/go_router.dart';
import '../../../../core/di/injection.dart';
import '../../../../core/theme/design_tokens.dart';
import '../../../../shared/widgets/page_header.dart' as widgets;
import '../../../friends/data/friends_api.dart';
import '../../../users/data/models/user_response.dart';
import '../../../users/data/users_api.dart';
@@ -16,20 +17,35 @@ class SettingsScreen extends StatefulWidget {
class _SettingsScreenState extends State<SettingsScreen> {
UserResponse? _user;
bool _isLoading = true;
int _friendsCount = 0;
String? _firstFriendName;
@override
void initState() {
super.initState();
_loadUser();
_loadData();
}
Future<void> _loadUser() async {
Future<void> _loadData() async {
try {
final usersApi = sl<UsersApi>();
final user = await usersApi.getMe();
final friendsApi = sl<FriendsApi>();
final results = await Future.wait([
usersApi.getMe(),
friendsApi.getFriends(),
]);
final user = results[0] as UserResponse;
final friends = results[1] as List<FriendResponse>;
if (mounted) {
setState(() {
_user = user;
_friendsCount = friends.length;
_firstFriendName = friends.isNotEmpty
? friends.first.friend.username
: null;
_isLoading = false;
});
}
@@ -181,6 +197,16 @@ class _SettingsScreenState extends State<SettingsScreen> {
);
}
String _buildFriendsSubtitle() {
if (_friendsCount == 0) {
return '暂无联系人';
}
if (_friendsCount == 1) {
return '已添加 1 位:$_firstFriendName';
}
return '已添加 $_friendsCount 位联系人';
}
Widget _buildQuickActions(BuildContext context) {
return Container(
height: 120,
@@ -199,7 +225,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
iconBg: AppColors.surfaceTertiary,
iconBorder: const Color(0xFFE6ECF7),
title: '联系人',
subtitle: '已添加 1 位:Toki',
subtitle: _buildFriendsSubtitle(),
onTap: () => context.push('/contacts'),
),
),