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

895 lines
33 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/theme/evolution_theme_config.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: $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: $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: $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: $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: $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: $e')),
);
}
}
}
@override
Widget build(BuildContext context) {
final authState = ref.watch(authNotifierProvider);
final isAuthed = authState.isAuthenticated;
final themeMode = ref.watch(dashboardThemeModeProvider);
final isEvolution = themeMode == DashboardThemeMode.evolution;
final bgColor = isEvolution
? Colors.black
: Theme.of(context).scaffoldBackgroundColor;
final titleFont = isEvolution ? 'Sora' : null;
if (!isAuthed) {
return Scaffold(
backgroundColor: bgColor,
appBar: AppBar(
title: Text(
'Friends & Riders',
style: TextStyle(fontFamily: titleFont, fontWeight: FontWeight.w800),
),
),
body: Center(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.group_outlined,
size: 64,
color: Color(0xFF00E5FF),
),
const SizedBox(height: 16),
Text(
'MOTORCLOUD ACCOUNT REQUIRED',
style: TextStyle(
fontFamily: titleFont,
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: const Color(0xFF00E5FF),
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: bgColor,
appBar: AppBar(
title: Text(
'Friends & Riders',
style: TextStyle(fontFamily: titleFont, fontWeight: FontWeight.w800),
),
actions: [
IconButton(
icon: const Icon(Icons.person_outline),
tooltip: 'My Profile',
onPressed: () => context.push('/settings/profile'),
),
],
bottom: TabBar(
controller: _tabController,
indicatorColor: const Color(0xFF00E5FF),
labelColor: const Color(0xFF00E5FF),
unselectedLabelColor: Colors.white60,
tabs: [
Tab(text: 'FRIENDS ($friendsCount)'),
Tab(
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.amberAccent,
borderRadius: BorderRadius.circular(10),
),
child: Text(
'$incomingCount',
style: const TextStyle(
color: Colors.black,
fontSize: 10,
fontWeight: FontWeight.w900,
),
),
),
],
],
),
),
const Tab(text: 'FIND RIDERS'),
],
),
),
body: TabBarView(
controller: _tabController,
children: [
// TAB 1: FRIENDS LIST
friendsAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (err, _) => Center(child: Text('Error: $err')),
data: (friends) {
if (friends.isEmpty) {
return Center(
child: Padding(
padding: const EdgeInsets.all(32.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: const Color(0xFF00E5FF),
foregroundColor: Colors.black,
),
icon: const Icon(Icons.search),
label: const Text('FIND RIDERS'),
onPressed: () => _tabController.animateTo(2),
),
],
),
),
);
}
return ListView.separated(
padding: const EdgeInsets.all(16.0),
itemCount: friends.length,
separatorBuilder: (context, index) =>
const Divider(color: Colors.white10),
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: const TextStyle(
color: Color(0xFF00E5FF),
fontSize: 12,
),
),
trailing: PopupMenuButton<String>(
icon: const Icon(Icons.more_vert, color: Colors.white60),
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'),
],
),
),
const PopupMenuItem(
value: 'block',
child: Row(
children: [
Icon(Icons.block, size: 18, color: Colors.redAccent),
SizedBox(width: 8),
Text('Block Rider', style: TextStyle(color: Colors.redAccent)),
],
),
),
],
),
);
},
);
},
),
// TAB 2: REQUESTS
ListView(
padding: const EdgeInsets.all(16.0),
children: [
// Incoming Section
const Text(
'INCOMING REQUESTS',
style: TextStyle(
color: Color(0xFF00E5FF),
fontSize: 12,
fontWeight: FontWeight.w800,
letterSpacing: 0.8,
),
),
const SizedBox(height: 8),
incomingAsync.when(
loading: () => const Center(
child: Padding(
padding: EdgeInsets.all(16.0),
child: CircularProgressIndicator(),
),
),
error: (err, _) => Text('Error: $err'),
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.white.withValues(alpha: 0.05),
margin: const EdgeInsets.only(bottom: 8),
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: const TextStyle(
color: Color(0xFF00E5FF),
fontSize: 11,
),
),
],
),
),
IconButton(
icon: const Icon(
Icons.check_circle_outline,
color: Colors.greenAccent,
),
tooltip: 'Accept',
onPressed: () => _acceptFriendRequest(req),
),
IconButton(
icon: const Icon(
Icons.cancel_outlined,
color: Colors.redAccent,
),
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: $err'),
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,
decoration: const InputDecoration(
hintText: 'Enter @handle (e.g. speed_demon)',
prefixIcon: Icon(Icons.search),
border: OutlineInputBorder(),
),
onSubmitted: (_) => _performSearch(),
),
),
const SizedBox(width: 8),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF00E5FF),
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.white.withValues(alpha: 0.06),
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: const TextStyle(
color: Color(0xFF00E5FF),
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,
),
),
],
const Divider(height: 24, color: Colors.white12),
// 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)
const Chip(
avatar: Icon(Icons.check, size: 16),
label: Text('Friends'),
backgroundColor: Color(0xFF003840),
labelStyle: TextStyle(
color: Color(0xFF00E5FF),
),
)
else if (_searchResult!.relationship ==
FriendshipStatus.requestOutgoing)
ElevatedButton.icon(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.white12,
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)
const Text(
'Check Requests tab to respond',
style: TextStyle(
color: Colors.amberAccent,
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: const Color(0xFF00E5FF),
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,
),
),
],
),
],
),
),
),
],
],
),
),
],
),
);
}
}