Files
social-app/apps/lib/features/contacts/presentation/screens/contacts_screen.dart
T

845 lines
26 KiB
Dart

import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../../../../app/di/injection.dart';
import '../../../../core/l10n/l10n.dart';
import '../../../../data/models/user_profile.dart';
import '../../../../core/theme/design_tokens.dart';
import '../../../../shared/widgets/app_loading_indicator.dart';
import '../../../../shared/widgets/toast/index.dart';
import '../../../../shared/widgets/app_button.dart';
import '../../../../shared/widgets/back_title_page_header.dart';
import '../../../contacts/data/friends_api.dart';
import '../../../contacts/data/users/users_api.dart';
class ContactsScreen extends StatefulWidget {
const ContactsScreen({super.key});
@override
State<ContactsScreen> createState() => _ContactsScreenState();
}
class _ContactsScreenState extends State<ContactsScreen> {
final _searchController = TextEditingController();
final _searchFocusNode = FocusNode();
List<FriendResponse> _friends = [];
List<FriendRequestResponse> _pendingRequests = [];
List<UserProfile> _searchResults = [];
bool _isLoading = true;
bool _isSearching = false;
bool _hasSearched = false;
String? _sendingRequestUserId;
final Set<String> _sentRequestIds = {};
Set<String> _friendIds = {};
@override
void initState() {
super.initState();
_loadFriends();
}
Future<void> _loadFriends() async {
try {
final friendsApi = sl<FriendsApi>();
final friends = await friendsApi.getFriends();
final outgoingRequests = await friendsApi.getOutgoingRequests();
final pendingRequests = outgoingRequests
.where((r) => r.status == 'pending')
.toList();
if (mounted) {
setState(() {
_friends = friends;
_friendIds = friends.map((f) => f.friend.id).toSet();
_sentRequestIds.addAll(outgoingRequests.map((r) => r.recipient.id));
_pendingRequests = pendingRequests;
_isLoading = false;
});
}
} catch (e) {
if (mounted) {
setState(() {
_isLoading = false;
});
}
}
}
Future<void> _onSearch() async {
final query = _searchController.text.trim();
if (query.isEmpty) {
Toast.show(
context,
context.l10n.contactsSearchEmptyQuery,
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,
context.l10n.contactsSearchFailed,
type: ToastType.error,
);
}
}
}
Future<void> _sendFriendRequest(String targetUserId, String? content) async {
if (_sendingRequestUserId != null) {
return;
}
try {
setState(() {
_sendingRequestUserId = targetUserId;
});
final friendsApi = sl<FriendsApi>();
await friendsApi.sendRequest(targetUserId, content: content);
if (mounted) {
setState(() {
_sentRequestIds.add(targetUserId);
_sendingRequestUserId = null;
});
Toast.show(
context,
context.l10n.contactsFriendRequestSent,
type: ToastType.success,
);
}
} catch (e) {
if (mounted) {
setState(() {
_sendingRequestUserId = null;
});
Toast.show(
context,
context.l10n.contactsSendFailed,
type: ToastType.error,
);
}
}
}
void _showAddFriendDialog(UserProfile user) {
final controller = TextEditingController();
showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
backgroundColor: Theme.of(
context,
).colorScheme.surface.withValues(alpha: 0),
builder: (sheetContext) {
final colorScheme = Theme.of(sheetContext).colorScheme;
return Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(sheetContext).viewInsets.bottom,
),
child: Container(
width: double.infinity,
constraints: BoxConstraints(
maxHeight: MediaQuery.of(sheetContext).size.height * 0.7,
),
padding: const EdgeInsets.fromLTRB(
AppSpacing.xxl,
AppSpacing.lg,
AppSpacing.xxl,
AppSpacing.lg,
),
decoration: BoxDecoration(
color: colorScheme.surface,
borderRadius: BorderRadius.vertical(
top: Radius.circular(AppRadius.xxl),
),
),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Center(
child: Container(
width: 40,
height: AppSpacing.xs,
decoration: BoxDecoration(
color: colorScheme.outlineVariant,
borderRadius: BorderRadius.circular(AppRadius.full),
),
),
),
const SizedBox(height: AppSpacing.lg),
Text(
context.l10n.contactsAddSheetTitle(user.username),
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.w600,
color: colorScheme.onSurface,
),
),
const SizedBox(height: AppSpacing.sm),
Text(
context.l10n.contactsAddSheetDesc,
style: TextStyle(
fontSize: 13,
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: AppSpacing.lg),
Container(
decoration: BoxDecoration(
color: colorScheme.surfaceContainerLowest,
borderRadius: BorderRadius.circular(AppRadius.lg),
border: Border.all(color: colorScheme.outlineVariant),
),
child: TextField(
controller: controller,
maxLines: 3,
minLines: 2,
maxLength: 200,
decoration: InputDecoration(
hintText: context.l10n.contactsAddSheetMessageHint,
hintStyle: TextStyle(
fontSize: 13,
color: colorScheme.outline,
),
border: InputBorder.none,
contentPadding: EdgeInsets.all(AppSpacing.lg),
counterStyle: TextStyle(
fontSize: 11,
color: colorScheme.outline,
),
),
),
),
const SizedBox(height: AppSpacing.lg),
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: AppButton(
text: context.l10n.commonCancel,
isOutlined: true,
onPressed: () => Navigator.pop(sheetContext),
),
),
const SizedBox(width: AppSpacing.md),
Expanded(
child: _buildSendButton(
user.id,
controller,
sheetContext,
),
),
],
),
SizedBox(
height:
MediaQuery.of(sheetContext).padding.bottom +
AppSpacing.sm,
),
],
),
),
),
);
},
);
}
@override
void dispose() {
_searchController.dispose();
_searchFocusNode.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Scaffold(
backgroundColor: colorScheme.surfaceContainerLow,
resizeToAvoidBottomInset: false,
body: SafeArea(
maintainBottomViewPadding: true,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
BackTitlePageHeader(
title: context.l10n.contactsTitle,
onBack: () => context.pop(),
),
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildSearchRow(),
_buildSearchResults(),
const SizedBox(height: 16),
if (_pendingRequests.isNotEmpty) ...[
_buildSectionTitle(context.l10n.contactsSectionNew),
const SizedBox(height: 8),
_buildPendingRequestCard(_pendingRequests),
const SizedBox(height: 16),
],
_buildSectionTitle(context.l10n.contactsSectionAll),
const SizedBox(height: 8),
if (_isLoading)
const Center(
child: Padding(
padding: EdgeInsets.all(20),
child: AppLoadingIndicator(size: 22),
),
)
else if (_friends.isEmpty)
_buildEmptyState()
else
_buildContactCard(_friends),
],
),
),
),
],
),
),
);
}
Widget _buildSearchRow() {
final colorScheme = Theme.of(context).colorScheme;
return Row(
children: [
Expanded(
child: Container(
height: 40,
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: colorScheme.outlineVariant),
),
child: TextField(
controller: _searchController,
focusNode: _searchFocusNode,
decoration: InputDecoration(
hintText: context.l10n.contactsSearchHint,
hintStyle: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: colorScheme.outline,
),
prefixIcon: Icon(
Icons.search,
size: 16,
color: colorScheme.outline,
),
border: InputBorder.none,
contentPadding: EdgeInsets.symmetric(
horizontal: 12,
vertical: 10,
),
),
style: const TextStyle(fontSize: 13),
keyboardType: TextInputType.text,
textInputAction: TextInputAction.search,
onSubmitted: (_) => _onSearch(),
onChanged: (value) {
if (value.isEmpty) {
setState(() {
_hasSearched = false;
_searchResults = [];
});
}
},
),
),
),
const SizedBox(width: 10),
GestureDetector(
onTap: _onSearch,
child: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: colorScheme.primaryContainer.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: colorScheme.primaryContainer),
),
child: _isSearching
? Padding(
padding: EdgeInsets.all(10),
child: AppLoadingIndicator(
size: 16,
strokeWidth: 2,
color: colorScheme.primary,
trackColor: colorScheme.primaryContainer,
withContainer: false,
),
)
: Icon(Icons.search, size: 16, color: colorScheme.primary),
),
),
],
);
}
Widget _buildSearchResults() {
final colorScheme = Theme.of(context).colorScheme;
if (!_hasSearched) {
return const SizedBox.shrink();
}
return Container(
margin: const EdgeInsets.only(top: 8),
decoration: BoxDecoration(
color: colorScheme.surface,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: colorScheme.shadow.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: AppLoadingIndicator(size: 22)),
)
else if (_searchResults.isEmpty)
Container(
padding: const EdgeInsets.all(20),
child: Center(
child: Text(
context.l10n.contactsSearchNoUser,
style: TextStyle(
fontSize: 14,
color: colorScheme.onSurfaceVariant,
),
),
),
)
else
Column(
children: [
for (int i = 0; i < _searchResults.length; i++) ...[
_buildSearchResultItem(_searchResults[i]),
if (i < _searchResults.length - 1) _buildDivider(),
],
],
),
],
),
);
}
Widget _buildSearchResultItem(UserProfile user) {
final colorScheme = Theme.of(context).colorScheme;
final isFriend = _friendIds.contains(user.id);
final isSent = _sentRequestIds.contains(user.id);
return Container(
height: 70,
padding: const EdgeInsets.symmetric(horizontal: 14),
child: Row(
children: [
_buildAvatar(user.avatarUrl, user.id, colorScheme),
const SizedBox(width: 12),
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
user.username,
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: colorScheme.onSurface,
),
),
if (user.bio != null) ...[
const SizedBox(height: 2),
Text(
user.bio!,
style: TextStyle(
fontSize: 12,
color: colorScheme.onSurfaceVariant,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
],
),
),
_buildAddButton(user.id, isFriend, isSent),
],
),
);
}
Widget _buildAddButton(String userId, bool isFriend, bool isSent) {
final colorScheme = Theme.of(context).colorScheme;
if (isFriend) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
child: Text(
context.l10n.contactsStatusAlreadyFriend,
style: TextStyle(fontSize: 12, color: colorScheme.onSurfaceVariant),
),
);
}
if (isSent) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
child: Text(
context.l10n.contactsStatusSent,
style: TextStyle(fontSize: 12, color: colorScheme.onSurfaceVariant),
),
);
}
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: colorScheme.primaryContainer.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: colorScheme.primaryContainer),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.person_add, size: 14, color: colorScheme.primary),
const SizedBox(width: 4),
Text(
context.l10n.contactsAdd,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: colorScheme.primary,
),
),
],
),
),
);
}
Widget _buildEmptyState() {
final colorScheme = Theme.of(context).colorScheme;
return Container(
width: double.infinity,
padding: const EdgeInsets.all(32),
decoration: BoxDecoration(
color: colorScheme.surface,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: colorScheme.outlineVariant),
),
child: Column(
children: [
Icon(Icons.person_outline, size: 48, color: colorScheme.outline),
const SizedBox(height: 12),
Text(
context.l10n.contactsEmptyTitle,
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w500,
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 4),
Text(
context.l10n.contactsEmptyDesc,
style: TextStyle(fontSize: 13, color: colorScheme.outline),
),
],
),
);
}
Widget _buildSectionTitle(String title) {
final colorScheme = Theme.of(context).colorScheme;
return Text(
title,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: colorScheme.onSurfaceVariant,
),
);
}
Widget _buildPendingRequestCard(List<FriendRequestResponse> requests) {
final colorScheme = Theme.of(context).colorScheme;
return Container(
decoration: BoxDecoration(
color: colorScheme.surface,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: colorScheme.outlineVariant),
),
child: Column(
children: [
for (int i = 0; i < requests.length; i++) ...[
_buildPendingRequestItem(requests[i]),
if (i < requests.length - 1) _buildDivider(),
],
],
),
);
}
Widget _buildPendingRequestItem(FriendRequestResponse request) {
final colorScheme = Theme.of(context).colorScheme;
final recipient = request.recipient;
return Container(
height: 70,
padding: const EdgeInsets.symmetric(horizontal: 14),
child: Row(
children: [
_buildAvatar(recipient.avatarUrl, recipient.id, colorScheme),
const SizedBox(width: 12),
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
recipient.username,
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: colorScheme.onSurface,
),
),
const SizedBox(height: 2),
Text(
context.l10n.contactsPendingConfirm,
style: TextStyle(
fontSize: 12,
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
],
),
);
}
Widget _buildContactCard(List<FriendResponse> friends) {
final colorScheme = Theme.of(context).colorScheme;
return Container(
decoration: BoxDecoration(
color: colorScheme.surface,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: colorScheme.outlineVariant),
),
child: Column(
children: [
for (int i = 0; i < friends.length; i++) ...[
_buildContactItem(friends[i]),
if (i < friends.length - 1) _buildDivider(),
],
],
),
);
}
Widget _buildContactItem(FriendResponse friend) {
final colorScheme = Theme.of(context).colorScheme;
final friendInfo = friend.friend;
return GestureDetector(
onTap: () => context.push('/contacts/add?id=${friendInfo.id}'),
child: Container(
height: 70,
padding: const EdgeInsets.symmetric(horizontal: 14),
child: Row(
children: [
_buildAvatar(friendInfo.avatarUrl, friendInfo.id, colorScheme),
const SizedBox(width: 12),
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
friendInfo.username,
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: colorScheme.onSurface,
),
),
],
),
),
],
),
),
);
}
Color _getAvatarBackground(
Color color,
ColorScheme colorScheme,
AppColorPalette palette,
) {
final avatarIndex = palette.avatarColors.indexOf(color);
final opacities = [0.30, 0.20, 0.35, 0.15, 0.30];
final opacity = avatarIndex >= 0 && avatarIndex < opacities.length
? opacities[avatarIndex]
: 0.30;
final isTertiary = avatarIndex == 4;
return isTertiary
? colorScheme.tertiaryContainer.withValues(alpha: opacity)
: colorScheme.primaryContainer.withValues(alpha: opacity);
}
Color _getAvatarBorder(
Color color,
ColorScheme colorScheme,
AppColorPalette palette,
) {
final avatarIndex = palette.avatarColors.indexOf(color);
final opacities = [0.25, 0.20, 0.30, 0.15, 0.25];
final opacity = avatarIndex >= 0 && avatarIndex < opacities.length
? opacities[avatarIndex]
: 0.25;
final isTertiary = avatarIndex == 4;
return isTertiary
? colorScheme.tertiary.withValues(alpha: opacity)
: colorScheme.primary.withValues(alpha: opacity);
}
Widget _buildDivider() {
final colorScheme = Theme.of(context).colorScheme;
return Container(
height: 1,
margin: const EdgeInsets.symmetric(horizontal: 14),
color: colorScheme.outlineVariant,
);
}
Widget _buildAvatar(
String? avatarUrl,
String userId,
ColorScheme colorScheme,
) {
final palette = Theme.of(context).extension<AppColorPalette>()!;
final avatarColor = palette
.avatarColors[userId.hashCode.abs() % palette.avatarColors.length];
return Container(
width: 42,
height: 42,
decoration: BoxDecoration(
color: _getAvatarBackground(avatarColor, colorScheme, palette),
borderRadius: BorderRadius.circular(21),
border: Border.all(
color: _getAvatarBorder(avatarColor, colorScheme, palette),
),
),
child: avatarUrl != null
? ClipRRect(
borderRadius: BorderRadius.circular(21),
child: Image.network(
avatarUrl,
width: 42,
height: 42,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) =>
Icon(Icons.person, size: 18, color: avatarColor),
),
)
: Icon(Icons.person, size: 18, color: avatarColor),
);
}
Widget _buildSendButton(
String userId,
TextEditingController controller,
BuildContext sheetContext,
) {
final colorScheme = Theme.of(context).colorScheme;
return SizedBox(
height: 44,
child: ElevatedButton(
onPressed: _sendingRequestUserId == userId
? null
: () async {
final content = controller.text.trim();
Navigator.pop(sheetContext);
await _sendFriendRequest(
userId,
content.isEmpty ? null : content,
);
},
style: ElevatedButton.styleFrom(
backgroundColor: colorScheme.primary,
foregroundColor: colorScheme.onPrimary,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.sm),
),
),
child: _sendingRequestUserId == userId
? AppLoadingIndicator(
size: 16,
strokeWidth: 2,
color: colorScheme.onPrimary,
trackColor: colorScheme.primary.withValues(alpha: 0.3),
withContainer: false,
)
: Text(
context.l10n.contactsSend,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
),
);
}
}