MotorSignal-App/lib/features/social/friends_screen.dart

1022 lines
38 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/error_mapper.dart';
import '../../core/responsive/responsive_layout.dart';
import '../../core/responsive/viewport_size.dart';
import '../../core/theme/theme_extensions.dart';
import '../../services/auth/auth_providers.dart';
import '../../services/social/rider_profile_models.dart';
import '../../services/social/social_providers.dart';
import 'widgets/avatar_badge.dart';
/// Screen managing friends, requests, and rider discovery.
class FriendsScreen extends ConsumerStatefulWidget {
const FriendsScreen({super.key});
@override
ConsumerState<FriendsScreen> createState() => _FriendsScreenState();
}
class _FriendsScreenState extends ConsumerState<FriendsScreen>
with SingleTickerProviderStateMixin {
late TabController _tabController;
final _searchController = TextEditingController();
bool _isSearching = false;
RiderSearchResult? _searchResult;
String? _searchError;
@override
void initState() {
super.initState();
_tabController = TabController(length: 3, vsync: this);
}
@override
void dispose() {
_tabController.dispose();
_searchController.dispose();
super.dispose();
}
Future<void> _performSearch() async {
final query = _searchController.text.trim();
if (query.isEmpty) return;
final user = ref.read(currentUserProvider);
if (user == null || user.isAnonymous) return;
setState(() {
_isSearching = true;
_searchError = null;
_searchResult = null;
});
try {
final searchService = ref.read(riderSearchServiceProvider);
final result = await searchService.searchByHandle(
rawHandle: query,
callerUid: user.uid,
);
if (mounted) {
setState(() {
_isSearching = false;
_searchResult = result;
if (result == null) {
_searchError = 'No rider found with handle "$query"';
}
});
}
} catch (e) {
if (mounted) {
setState(() {
_isSearching = false;
_searchError = 'Search error: ${ErrorMapper.map(e)}';
});
}
}
}
Future<void> _sendFriendRequest(RiderSearchResult target) async {
final user = ref.read(currentUserProvider);
final myProfileAsync = ref.read(myRiderProfileProvider);
final myProfile = myProfileAsync.value;
if (user == null || user.isAnonymous || myProfile == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Please create your rider profile first.'),
),
);
return;
}
try {
final friendshipService = ref.read(friendshipServiceProvider);
await friendshipService.sendFriendRequest(
sender: myProfile,
receiverUid: target.uid,
);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Friend request sent to @${target.handle}')),
);
}
await _performSearch();
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))),
);
}
}
}
Future<void> _acceptFriendRequest(FriendRequest request) async {
final myProfile = ref.read(myRiderProfileProvider).value;
if (myProfile == null) return;
try {
final friendshipService = ref.read(friendshipServiceProvider);
await friendshipService.acceptFriendRequest(
me: myProfile,
request: request,
);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Now friends with @${request.senderHandle}!'),
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error accepting request: ${ErrorMapper.map(e)}')),
);
}
}
}
Future<void> _declineFriendRequest(FriendRequest request) async {
final user = ref.read(currentUserProvider);
if (user == null) return;
try {
final friendshipService = ref.read(friendshipServiceProvider);
await friendshipService.declineFriendRequest(
myUid: user.uid,
senderUid: request.senderUid,
);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error declining request: ${ErrorMapper.map(e)}')),
);
}
}
}
Future<void> _cancelFriendRequest(String receiverUid) async {
final user = ref.read(currentUserProvider);
if (user == null) return;
try {
final friendshipService = ref.read(friendshipServiceProvider);
await friendshipService.cancelFriendRequest(
myUid: user.uid,
receiverUid: receiverUid,
);
if (_searchResult?.uid == receiverUid) {
await _performSearch();
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error canceling request: ${ErrorMapper.map(e)}')),
);
}
}
}
Future<void> _removeFriend(FriendSummary friend) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text('Remove @${friend.handle}?'),
content: Text(
'Are you sure you want to remove ${friend.displayName} from your friends list?',
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text('CANCEL'),
),
ElevatedButton(
style: ElevatedButton.styleFrom(backgroundColor: Colors.redAccent),
onPressed: () => Navigator.of(ctx).pop(true),
child: const Text('REMOVE'),
),
],
),
);
if (confirmed != true) return;
final user = ref.read(currentUserProvider);
if (user == null) return;
try {
final friendshipService = ref.read(friendshipServiceProvider);
await friendshipService.removeFriend(
myUid: user.uid,
friendUid: friend.uid,
);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error removing friend: ${ErrorMapper.map(e)}')),
);
}
}
}
Future<void> _blockRider(String targetUid, String handle, String name) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text('Block @$handle?'),
content: Text(
'Are you sure you want to block $name? They will not be able to find your profile, send requests, or see your activity.',
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text('CANCEL'),
),
ElevatedButton(
style: ElevatedButton.styleFrom(backgroundColor: Colors.redAccent),
onPressed: () => Navigator.of(ctx).pop(true),
child: const Text('BLOCK'),
),
],
),
);
if (confirmed != true) return;
final user = ref.read(currentUserProvider);
if (user == null) return;
try {
final friendshipService = ref.read(friendshipServiceProvider);
await friendshipService.blockRider(
myUid: user.uid,
targetUid: targetUid,
targetHandle: handle,
targetDisplayName: name,
);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Blocked @$handle')),
);
setState(() {
_searchResult = null;
});
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error blocking rider: ${ErrorMapper.map(e)}')),
);
}
}
}
@override
Widget build(BuildContext context) {
final colors = context.motorColors;
final authState = ref.watch(authNotifierProvider);
final isAuthed = authState.isAuthenticated;
if (!isAuthed) {
return Scaffold(
backgroundColor: colors.surfaceContainer,
appBar: AppBar(
backgroundColor: colors.surfaceContainerHigh,
title: const Text(
'Friends & Riders',
style: TextStyle(fontWeight: FontWeight.w800),
),
),
body: Center(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.group_outlined,
size: 64,
color: colors.cyberCyan,
),
const SizedBox(height: 16),
const Text(
'MOTORCLOUD ACCOUNT REQUIRED',
style: TextStyle(
fontWeight: FontWeight.w800,
fontSize: 16,
color: Colors.white,
letterSpacing: 0.8,
),
),
const SizedBox(height: 8),
const Text(
'Sign in to add riding buddies, share meetups, and coordinate future group rides.',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 13, color: Colors.white70),
),
const SizedBox(height: 24),
ElevatedButton.icon(
style: ElevatedButton.styleFrom(
backgroundColor: colors.cyberCyan,
foregroundColor: Colors.black,
),
icon: const Icon(Icons.login),
label: const Text('SIGN IN OR REGISTER'),
onPressed: () => context.push('/settings/account'),
),
],
),
),
),
);
}
final friendsAsync = ref.watch(myFriendsProvider);
final incomingAsync = ref.watch(myIncomingRequestsProvider);
final outgoingAsync = ref.watch(myOutgoingRequestsProvider);
final incomingCount = incomingAsync.value?.length ?? 0;
final friendsCount = friendsAsync.value?.length ?? 0;
return Scaffold(
backgroundColor: colors.surfaceContainer,
appBar: AppBar(
backgroundColor: colors.surfaceContainerHigh,
title: const Text(
'Friends & Riders',
style: TextStyle(fontWeight: FontWeight.w800),
),
actions: [
IconButton(
icon: const Icon(Icons.person_outline),
tooltip: 'My Profile',
onPressed: () => context.push('/settings/profile'),
),
],
bottom: TabBar(
controller: _tabController,
isScrollable: false,
tabAlignment: TabAlignment.fill,
labelPadding: EdgeInsets.zero,
indicatorColor: colors.cyberCyan,
labelColor: colors.cyberCyan,
unselectedLabelColor: Colors.white60,
tabs: [
Tab(
child: FittedBox(
fit: BoxFit.scaleDown,
child: Text('Friends ($friendsCount)'),
),
),
Tab(
child: FittedBox(
fit: BoxFit.scaleDown,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Text('Requests'),
if (incomingCount > 0) ...[
const SizedBox(width: 6),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
color: colors.warningAmber,
borderRadius: BorderRadius.circular(10),
),
child: Text(
'$incomingCount',
style: const TextStyle(
color: Colors.black,
fontSize: 10,
fontWeight: FontWeight.w900,
),
),
),
],
],
),
),
),
const Tab(
child: FittedBox(
fit: BoxFit.scaleDown,
child: Text('Find Riders'),
),
),
],
),
),
body: TabBarView(
controller: _tabController,
children: [
// TAB 1: FRIENDS LIST
friendsAsync.when(
loading: () => Center(
child: CircularProgressIndicator(color: colors.cyberCyan),
),
error: (err, _) => Center(
child: Text(
'Error: ${ErrorMapper.map(err)}',
style: TextStyle(color: colors.raceRed),
),
),
data: (friends) {
if (friends.isEmpty) {
return SingleChildScrollView(
child: Center(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.people_alt_outlined,
size: 48,
color: Colors.white24,
),
const SizedBox(height: 12),
const Text(
'NO FRIENDS ADDED YET',
style: TextStyle(
color: Colors.white70,
fontWeight: FontWeight.w800,
fontSize: 14,
letterSpacing: 0.8,
),
),
const SizedBox(height: 6),
const Text(
'Switch to "Find Riders" to search for riding buddies by @handle.',
textAlign: TextAlign.center,
style: TextStyle(color: Colors.white38, fontSize: 12),
),
const SizedBox(height: 16),
ElevatedButton.icon(
style: ElevatedButton.styleFrom(
backgroundColor: colors.cyberCyan,
foregroundColor: Colors.black,
),
icon: const Icon(Icons.search),
label: const Text('FIND RIDERS'),
onPressed: () => _tabController.animateTo(2),
),
],
),
),
),
);
}
return ResponsiveLayout(
builder: (context, snapshot) {
final isWide = snapshot.sizeClass == WindowSizeClass.expanded ||
(snapshot.isLandscape && snapshot.width >= 720);
if (isWide) {
final crossAxisCount = snapshot.width >= 1100 ? 3 : 2;
return GridView.builder(
padding: const EdgeInsets.all(20),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: crossAxisCount,
crossAxisSpacing: 16,
mainAxisSpacing: 16,
mainAxisExtent: 88,
),
itemCount: friends.length,
itemBuilder: (context, index) {
final friend = friends[index];
return Card(
color: colors.surfaceContainerHigh,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(color: colors.outlineVariant),
),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 14,
vertical: 8,
),
child: Row(
children: [
AvatarBadge(
avatarPreset: friend.avatarPreset,
photoUrl: friend.avatarUrl,
displayName: friend.displayName,
radius: 24,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
mainAxisAlignment:
MainAxisAlignment.center,
children: [
Text(
friend.displayName,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontWeight: FontWeight.w700,
color: Colors.white,
),
),
Text(
friend.displayHandle,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: colors.cyberCyan,
fontSize: 12,
),
),
],
),
),
_buildFriendMenu(friend, colors),
],
),
),
);
},
);
}
return ListView.separated(
padding: const EdgeInsets.all(16.0),
itemCount: friends.length,
separatorBuilder: (context, index) =>
Divider(color: colors.outlineVariant),
itemBuilder: (context, index) {
final friend = friends[index];
return ListTile(
contentPadding: EdgeInsets.zero,
leading: AvatarBadge(
avatarPreset: friend.avatarPreset,
photoUrl: friend.avatarUrl,
displayName: friend.displayName,
radius: 24,
),
title: Text(
friend.displayName,
style: const TextStyle(
fontWeight: FontWeight.w700,
color: Colors.white,
),
),
subtitle: Text(
friend.displayHandle,
style: TextStyle(
color: colors.cyberCyan,
fontSize: 12,
),
),
trailing: _buildFriendMenu(friend, colors),
);
},
);
},
);
},
),
// TAB 2: REQUESTS
ListView(
padding: const EdgeInsets.all(16.0),
children: [
// Incoming Section
Text(
'INCOMING REQUESTS',
style: TextStyle(
color: colors.cyberCyan,
fontSize: 12,
fontWeight: FontWeight.w800,
letterSpacing: 0.8,
),
),
const SizedBox(height: 8),
incomingAsync.when(
loading: () => Center(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: CircularProgressIndicator(color: colors.cyberCyan),
),
),
error: (err, _) => Text(
'Error: ${ErrorMapper.map(err)}',
style: TextStyle(color: colors.raceRed),
),
data: (requests) {
if (requests.isEmpty) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: 12.0),
child: Text(
'No pending incoming requests',
style: TextStyle(color: Colors.white38, fontSize: 12),
),
);
}
return Column(
children: requests.map((req) {
return Card(
color: colors.surfaceContainerHigh,
margin: const EdgeInsets.only(bottom: 8),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
side: BorderSide(color: colors.outlineVariant),
),
child: Padding(
padding: const EdgeInsets.all(12.0),
child: Row(
children: [
AvatarBadge(
avatarPreset: req.senderAvatarPreset,
photoUrl: req.senderAvatarUrl,
displayName: req.senderDisplayName,
radius: 20,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
req.senderDisplayName,
style: const TextStyle(
fontWeight: FontWeight.w700,
color: Colors.white,
),
),
Text(
'@${req.senderHandle}',
style: TextStyle(
color: colors.cyberCyan,
fontSize: 11,
),
),
],
),
),
IconButton(
icon: Icon(
Icons.check_circle_outline,
color: colors.launchGreen,
),
tooltip: 'Accept',
onPressed: () => _acceptFriendRequest(req),
),
IconButton(
icon: Icon(
Icons.cancel_outlined,
color: colors.raceRed,
),
tooltip: 'Decline',
onPressed: () => _declineFriendRequest(req),
),
],
),
),
);
}).toList(),
);
},
),
const SizedBox(height: 24),
// Outgoing Section
const Text(
'SENT REQUESTS',
style: TextStyle(
color: Colors.white70,
fontSize: 12,
fontWeight: FontWeight.w800,
letterSpacing: 0.8,
),
),
const SizedBox(height: 8),
outgoingAsync.when(
loading: () => const SizedBox.shrink(),
error: (err, _) => Text(
'Error: ${ErrorMapper.map(err)}',
style: TextStyle(color: colors.raceRed),
),
data: (requests) {
if (requests.isEmpty) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: 12.0),
child: Text(
'No pending sent requests',
style: TextStyle(color: Colors.white38, fontSize: 12),
),
);
}
return Column(
children: requests.map((req) {
return ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(
Icons.outgoing_mail,
color: Colors.white54,
),
title: Text(
'To Rider (${req.receiverUid.substring(0, req.receiverUid.length.clamp(0, 8))}...)',
style: const TextStyle(
fontSize: 13,
color: Colors.white,
),
),
subtitle: Text(
'Sent ${req.sentAt.toLocal().toString().split('.')[0]}',
style: const TextStyle(
fontSize: 11,
color: Colors.white38,
),
),
trailing: TextButton(
onPressed: () =>
_cancelFriendRequest(req.receiverUid),
child: const Text('CANCEL'),
),
);
}).toList(),
);
},
),
],
),
// TAB 3: FIND RIDERS
Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Expanded(
child: TextField(
controller: _searchController,
style: const TextStyle(color: Colors.white),
decoration: InputDecoration(
hintText: 'Enter @handle (e.g. speed_demon)',
prefixIcon: const Icon(Icons.search),
border: const OutlineInputBorder(),
filled: true,
fillColor: colors.surfaceContainerHigh,
),
onSubmitted: (_) => _performSearch(),
),
),
const SizedBox(width: 8),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: colors.cyberCyan,
foregroundColor: Colors.black,
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 16,
),
),
onPressed: _isSearching ? null : _performSearch,
child: _isSearching
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.black,
),
)
: const Text('SEARCH'),
),
],
),
const SizedBox(height: 24),
if (_searchError != null)
Center(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Text(
_searchError!,
style: const TextStyle(
color: Colors.white60,
fontSize: 13,
),
),
),
),
if (_searchResult != null) ...[
Card(
color: colors.surfaceContainerHigh,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(color: colors.outlineVariant),
),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
AvatarBadge(
avatarPreset: _searchResult!.avatarPreset,
photoUrl: _searchResult!.avatarUrl,
displayName: _searchResult!.displayName,
radius: 28,
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_searchResult!.displayName,
style: const TextStyle(
fontWeight: FontWeight.w800,
fontSize: 16,
color: Colors.white,
),
),
Text(
_searchResult!.displayHandle,
style: TextStyle(
color: colors.cyberCyan,
fontWeight: FontWeight.w700,
fontSize: 13,
),
),
if (_searchResult!.region.isNotEmpty) ...[
const SizedBox(height: 2),
Text(
_searchResult!.region,
style: const TextStyle(
color: Colors.white60,
fontSize: 11,
),
),
],
],
),
),
],
),
if (_searchResult!.bio.isNotEmpty) ...[
const SizedBox(height: 12),
Text(
_searchResult!.bio,
style: const TextStyle(
color: Colors.white70,
fontSize: 12,
fontStyle: FontStyle.italic,
),
),
],
Divider(height: 24, color: colors.outlineVariant),
// Relationship Action
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
if (_searchResult!.relationship ==
FriendshipStatus.self)
const Text(
'This is your profile',
style: TextStyle(
color: Colors.white38,
fontSize: 12,
),
)
else if (_searchResult!.relationship ==
FriendshipStatus.friend)
Chip(
avatar: Icon(Icons.check,
size: 16, color: colors.cyberCyan),
label: const Text('Friends'),
backgroundColor:
colors.cyberCyan.withValues(alpha: 0.15),
labelStyle: TextStyle(
color: colors.cyberCyan,
),
)
else if (_searchResult!.relationship ==
FriendshipStatus.requestOutgoing)
ElevatedButton.icon(
style: ElevatedButton.styleFrom(
backgroundColor:
colors.surfaceContainerHighest,
foregroundColor: Colors.white,
),
icon: const Icon(Icons.cancel_outlined,
size: 16),
label: const Text('CANCEL REQUEST'),
onPressed: () =>
_cancelFriendRequest(_searchResult!.uid),
)
else if (_searchResult!.relationship ==
FriendshipStatus.requestIncoming)
Text(
'Check Requests tab to respond',
style: TextStyle(
color: colors.warningAmber,
fontSize: 12,
),
)
else if (!_searchResult!.allowFriendRequests)
const Text(
'Not accepting friend requests',
style: TextStyle(
color: Colors.white38,
fontSize: 12,
),
)
else
ElevatedButton.icon(
style: ElevatedButton.styleFrom(
backgroundColor: colors.cyberCyan,
foregroundColor: Colors.black,
),
icon: const Icon(Icons.person_add, size: 16),
label: const Text('ADD FRIEND'),
onPressed: () =>
_sendFriendRequest(_searchResult!),
),
if (_searchResult!.relationship !=
FriendshipStatus.self)
IconButton(
icon: const Icon(
Icons.block,
color: Colors.white38,
size: 18,
),
tooltip: 'Block Rider',
onPressed: () => _blockRider(
_searchResult!.uid,
_searchResult!.handle,
_searchResult!.displayName,
),
),
],
),
],
),
),
),
],
],
),
),
],
),
);
}
Widget _buildFriendMenu(
FriendSummary friend, MotorSignalThemeColors colors) {
return PopupMenuButton<String>(
icon: const Icon(Icons.more_vert, color: Colors.white60),
color: colors.surfaceContainerHighest,
onSelected: (val) {
if (val == 'remove') {
_removeFriend(friend);
} else if (val == 'block') {
_blockRider(friend.uid, friend.handle, friend.displayName);
}
},
itemBuilder: (context) => [
const PopupMenuItem(
value: 'remove',
child: Row(
children: [
Icon(Icons.person_remove_outlined, size: 18),
SizedBox(width: 8),
Text('Remove Friend'),
],
),
),
PopupMenuItem(
value: 'block',
child: Row(
children: [
Icon(Icons.block, size: 18, color: colors.raceRed),
const SizedBox(width: 8),
Text('Block Rider', style: TextStyle(color: colors.raceRed)),
],
),
),
],
);
}
}