diff --git a/firestore.rules b/firestore.rules index cdfbd48..ac6892a 100644 --- a/firestore.rules +++ b/firestore.rules @@ -13,7 +13,7 @@ service cloud.firestore { return ownsUser(userId) && request.resource.data.id == userId && request.resource.data.keys().hasOnly([ - 'id', 'username', 'displayName', 'profilePhotoUrl', 'bio', + 'id', 'username', 'displayName', 'profilePhotoUrl', 'avatarPreset', 'bio', 'location', 'createdAt', 'updatedAt', 'privacySettings', 'preferences' ]); } @@ -23,7 +23,7 @@ service cloud.firestore { && request.resource.data.id == resource.data.id && request.resource.data.createdAt == resource.data.createdAt && request.resource.data.keys().hasOnly([ - 'id', 'username', 'displayName', 'profilePhotoUrl', 'bio', + 'id', 'username', 'displayName', 'profilePhotoUrl', 'avatarPreset', 'bio', 'location', 'createdAt', 'updatedAt', 'privacySettings', 'preferences' ]); } @@ -41,9 +41,19 @@ service cloud.firestore { && request.resource.data.id == resource.data.id; } + match /handles/{handle} { + allow read: if signedIn(); + allow create: if signedIn() + && request.resource.data.uid == request.auth.uid + && request.resource.data.handle == handle; + allow delete: if signedIn() && resource.data.uid == request.auth.uid; + allow update: if false; + } + match /users/{userId} { allow create: if validUserCreate(userId); - allow read: if ownsUser(userId); + allow get: if signedIn(); + allow list: if ownsUser(userId); allow update: if validUserUpdate(userId); allow delete: if ownsUser(userId); @@ -78,6 +88,23 @@ service cloud.firestore { } match /friends/{friendId} { + allow read: if ownsUser(userId); + allow write: if signedIn() && (request.auth.uid == userId || request.auth.uid == friendId); + } + + match /friend_requests_incoming/{senderId} { + allow read: if ownsUser(userId); + allow create: if signedIn() && request.auth.uid == senderId && request.resource.data.receiverUid == userId; + allow delete: if signedIn() && (request.auth.uid == userId || request.auth.uid == senderId); + } + + match /friend_requests_outgoing/{receiverId} { + allow read: if ownsUser(userId); + allow create: if ownsUser(userId) && request.resource.data.receiverUid == receiverId; + allow delete: if signedIn() && (request.auth.uid == userId || request.auth.uid == receiverId); + } + + match /blocks/{blockedId} { allow read, write: if ownsUser(userId); } } diff --git a/lib/core/router.dart b/lib/core/router.dart index f6809b9..17e72de 100644 --- a/lib/core/router.dart +++ b/lib/core/router.dart @@ -17,6 +17,8 @@ import '../features/field_test/field_test_pre_ride_check_screen.dart'; import '../features/settings/oauth_callback_screen.dart'; import '../features/settings/account_screen.dart'; import '../features/entitlements/pro_screen.dart'; +import '../features/social/rider_profile_screen.dart'; +import '../features/social/friends_screen.dart'; // ignore: unused_element final _rootNavigatorKey = GlobalKey(); @@ -127,6 +129,14 @@ final appRouter = GoRouter( path: 'pro', builder: (context, state) => const ProScreen(), ), + GoRoute( + path: 'profile', + builder: (context, state) => const RiderProfileScreen(), + ), + GoRoute( + path: 'friends', + builder: (context, state) => const FriendsScreen(), + ), GoRoute( path: 'diagnostics', builder: (context, state) => const DiagnosticsScreen(), diff --git a/lib/features/settings/account_screen.dart b/lib/features/settings/account_screen.dart index 686de6d..82ea328 100644 --- a/lib/features/settings/account_screen.dart +++ b/lib/features/settings/account_screen.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../core/theme/evolution_theme_config.dart'; import '../../database/database.dart'; @@ -624,7 +625,57 @@ class _AccountScreenState extends ConsumerState { ), ), ], - const SizedBox(height: 24), + const SizedBox(height: 20), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + style: OutlinedButton.styleFrom( + foregroundColor: const Color(0xFF00E5FF), + side: const BorderSide(color: Color(0xFF00E5FF)), + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + icon: const Icon(Icons.person_outline, size: 18), + label: const Text( + 'RIDER PROFILE', + style: TextStyle( + fontWeight: FontWeight.bold, + letterSpacing: 0.5, + fontSize: 12, + ), + ), + onPressed: () => context.push('/settings/profile'), + ), + ), + const SizedBox(width: 10), + Expanded( + child: OutlinedButton.icon( + style: OutlinedButton.styleFrom( + foregroundColor: const Color(0xFF00E5FF), + side: const BorderSide(color: Color(0xFF00E5FF)), + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + icon: const Icon(Icons.people_outline, size: 18), + label: const Text( + 'FRIENDS', + style: TextStyle( + fontWeight: FontWeight.bold, + letterSpacing: 0.5, + fontSize: 12, + ), + ), + onPressed: () => context.push('/settings/friends'), + ), + ), + ], + ), + const SizedBox(height: 20), SizedBox( width: double.infinity, child: OutlinedButton.icon( diff --git a/lib/features/social/friends_screen.dart b/lib/features/social/friends_screen.dart new file mode 100644 index 0000000..4451305 --- /dev/null +++ b/lib/features/social/friends_screen.dart @@ -0,0 +1,894 @@ +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 createState() => _FriendsScreenState(); +} + +class _FriendsScreenState extends ConsumerState + 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 _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 _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 _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 _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 _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 _removeFriend(FriendSummary friend) async { + final confirmed = await showDialog( + 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 _blockRider(String targetUid, String handle, String name) async { + final confirmed = await showDialog( + 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( + 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, + ), + ), + ], + ), + ], + ), + ), + ), + ], + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/social/rider_profile_screen.dart b/lib/features/social/rider_profile_screen.dart new file mode 100644 index 0000000..8d4a757 --- /dev/null +++ b/lib/features/social/rider_profile_screen.dart @@ -0,0 +1,458 @@ +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/handle_validator.dart'; +import '../../services/social/rider_profile_models.dart'; +import '../../services/social/social_providers.dart'; +import 'widgets/avatar_badge.dart'; +import 'widgets/avatar_selector.dart'; + +/// Screen allowing a rider to view and manage their public profile and privacy. +class RiderProfileScreen extends ConsumerStatefulWidget { + const RiderProfileScreen({super.key}); + + @override + ConsumerState createState() => _RiderProfileScreenState(); +} + +class _RiderProfileScreenState extends ConsumerState { + final _formKey = GlobalKey(); + late TextEditingController _handleController; + late TextEditingController _displayNameController; + late TextEditingController _bioController; + late TextEditingController _regionController; + + String? _selectedAvatarPreset; + bool _isSearchableByHandle = true; + bool _isProfilePublic = true; + bool _allowFriendRequests = true; + + bool _isSaving = false; + String? _errorMessage; + String? _successMessage; + bool _initialized = false; + + @override + void initState() { + super.initState(); + _handleController = TextEditingController(); + _displayNameController = TextEditingController(); + _bioController = TextEditingController(); + _regionController = TextEditingController(); + } + + @override + void dispose() { + _handleController.dispose(); + _displayNameController.dispose(); + _bioController.dispose(); + _regionController.dispose(); + super.dispose(); + } + + void _populateFromProfile(RiderProfile profile) { + if (_initialized) return; + _handleController.text = profile.handle; + _displayNameController.text = profile.displayName; + _bioController.text = profile.bio; + _regionController.text = profile.region; + _selectedAvatarPreset = profile.avatarPreset; + _isSearchableByHandle = profile.privacySettings.isSearchableByHandle; + _isProfilePublic = profile.privacySettings.isProfilePublic; + _allowFriendRequests = profile.privacySettings.allowFriendRequests; + _initialized = true; + } + + void _populateDefaults() { + if (_initialized) return; + final user = ref.read(currentUserProvider); + if (user != null && !user.isAnonymous) { + if (user.displayName != null && user.displayName!.isNotEmpty) { + _displayNameController.text = user.displayName!; + final candidate = user.displayName! + .toLowerCase() + .replaceAll(RegExp(r'[^a-z0-9_]'), '_'); + if (candidate.length >= 3) { + _handleController.text = candidate.substring(0, candidate.length.clamp(0, 18)); + } + } else if (user.email != null) { + final candidate = user.email!.split('@').first.replaceAll(RegExp(r'[^a-z0-9_]'), '_'); + _handleController.text = candidate.substring(0, candidate.length.clamp(0, 18)); + } + } + _selectedAvatarPreset = PresetAvatar.availablePresets.first.id; + _initialized = true; + } + + Future _handleSave() async { + final user = ref.read(currentUserProvider); + if (user == null || user.isAnonymous) return; + + final handleValidation = HandleValidator.validate(_handleController.text); + if (!handleValidation.isValid) { + setState(() { + _errorMessage = handleValidation.error; + }); + return; + } + + setState(() { + _isSaving = true; + _errorMessage = null; + _successMessage = null; + }); + + try { + final profileService = ref.read(riderProfileServiceProvider); + final privacy = ProfilePrivacySettings( + isSearchableByHandle: _isSearchableByHandle, + isProfilePublic: _isProfilePublic, + allowFriendRequests: _allowFriendRequests, + ); + + await profileService.saveProfile( + uid: user.uid, + handle: _handleController.text, + displayName: _displayNameController.text.trim().isEmpty + ? _handleController.text + : _displayNameController.text.trim(), + bio: _bioController.text.trim(), + region: _regionController.text.trim(), + avatarPreset: _selectedAvatarPreset, + privacySettings: privacy, + ); + + if (mounted) { + setState(() { + _isSaving = false; + _successMessage = 'Profile updated successfully!'; + }); + } + } catch (e) { + if (mounted) { + setState(() { + _isSaving = false; + _errorMessage = e.toString().replaceFirst('Exception: ', ''); + }); + } + } + } + + @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( + 'Rider Profile', + 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.person_pin_circle_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( + 'To create a public rider handle, connect with friends, and join future group rides, please sign in or create an account.', + 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 profileAsync = ref.watch(myRiderProfileProvider); + + return Scaffold( + backgroundColor: bgColor, + appBar: AppBar( + title: Text( + 'Rider Profile', + style: TextStyle(fontFamily: titleFont, fontWeight: FontWeight.w800), + ), + actions: [ + IconButton( + icon: const Icon(Icons.people_outline), + tooltip: 'Friends & Requests', + onPressed: () => context.push('/settings/friends'), + ), + ], + ), + body: profileAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (err, _) => Center(child: Text('Error loading profile: $err')), + data: (profile) { + if (profile != null) { + _populateFromProfile(profile); + } else { + _populateDefaults(); + } + + return SingleChildScrollView( + padding: const EdgeInsets.all(16.0), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Header avatar preview + Center( + child: Column( + children: [ + AvatarBadge( + avatarPreset: _selectedAvatarPreset, + displayName: _displayNameController.text.isEmpty + ? 'Rider' + : _displayNameController.text, + radius: 40, + ), + const SizedBox(height: 8), + Text( + _handleController.text.isNotEmpty + ? '@${_handleController.text}' + : '@handle', + style: const TextStyle( + color: Color(0xFF00E5FF), + fontWeight: FontWeight.w800, + fontSize: 14, + fontFamily: 'Inter', + ), + ), + ], + ), + ), + const SizedBox(height: 20), + + // Messages + if (_errorMessage != null) ...[ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.redAccent.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.redAccent), + ), + child: Text( + _errorMessage!, + style: const TextStyle(color: Colors.redAccent, fontSize: 12), + ), + ), + const SizedBox(height: 16), + ], + if (_successMessage != null) ...[ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.greenAccent.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.greenAccent), + ), + child: Text( + _successMessage!, + style: const TextStyle(color: Colors.greenAccent, fontSize: 12), + ), + ), + const SizedBox(height: 16), + ], + + // Avatar preset selector + AvatarSelector( + selectedPresetId: _selectedAvatarPreset, + onSelected: (presetId) { + setState(() { + _selectedAvatarPreset = presetId; + }); + }, + ), + const SizedBox(height: 24), + + // Handle input + TextFormField( + controller: _handleController, + decoration: const InputDecoration( + labelText: 'Rider Handle', + hintText: 'e.g. canyon_runner', + prefixText: '@', + helperText: 'Unique, 3-20 characters, lowercase letters, numbers, and underscores', + border: OutlineInputBorder(), + ), + onChanged: (_) => setState(() {}), + ), + const SizedBox(height: 16), + + // Display Name input + TextFormField( + controller: _displayNameController, + decoration: const InputDecoration( + labelText: 'Display Name', + hintText: 'e.g. Alex Rivera', + border: OutlineInputBorder(), + ), + onChanged: (_) => setState(() {}), + ), + const SizedBox(height: 16), + + // Region input + TextFormField( + controller: _regionController, + decoration: const InputDecoration( + labelText: 'Riding Region (Optional)', + hintText: 'e.g. Blue Ridge Mountains, NC', + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 16), + + // Bio input + TextFormField( + controller: _bioController, + maxLines: 3, + decoration: const InputDecoration( + labelText: 'Rider Bio (Optional)', + hintText: 'Tell fellow riders about what you ride and your riding style...', + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 24), + + // Privacy Settings Card + Card( + color: Colors.white.withValues(alpha: 0.05), + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Row( + children: [ + Icon(Icons.shield_outlined, color: Color(0xFF00E5FF), size: 18), + SizedBox(width: 8), + Text( + 'PRIVACY CONTROLS', + style: TextStyle( + color: Color(0xFF00E5FF), + fontWeight: FontWeight.w800, + fontSize: 12, + letterSpacing: 0.8, + ), + ), + ], + ), + const SizedBox(height: 12), + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: const Text('Discoverable by Handle', style: TextStyle(fontSize: 14)), + subtitle: const Text( + 'Allow other riders to find your profile by searching your @handle.', + style: TextStyle(fontSize: 11, color: Colors.white60), + ), + value: _isSearchableByHandle, + onChanged: (val) => setState(() => _isSearchableByHandle = val), + ), + const Divider(color: Colors.white12), + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: const Text('Public Profile', style: TextStyle(fontSize: 14)), + subtitle: const Text( + 'When off, non-friends only see your handle and avatar, hiding bio and region.', + style: TextStyle(fontSize: 11, color: Colors.white60), + ), + value: _isProfilePublic, + onChanged: (val) => setState(() => _isProfilePublic = val), + ), + const Divider(color: Colors.white12), + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: const Text('Allow Friend Requests', style: TextStyle(fontSize: 14)), + subtitle: const Text( + 'Allow other riders to send you friend requests.', + style: TextStyle(fontSize: 11, color: Colors.white60), + ), + value: _allowFriendRequests, + onChanged: (val) => setState(() => _allowFriendRequests = val), + ), + ], + ), + ), + ), + const SizedBox(height: 24), + + // Save Button + SizedBox( + height: 48, + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF00E5FF), + foregroundColor: Colors.black, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + onPressed: _isSaving ? null : _handleSave, + child: _isSaving + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.black, + ), + ) + : const Text( + 'SAVE PROFILE', + style: TextStyle(fontWeight: FontWeight.w900, letterSpacing: 0.8), + ), + ), + ), + ], + ), + ), + ); + }, + ), + ); + } +} diff --git a/lib/features/social/widgets/avatar_badge.dart b/lib/features/social/widgets/avatar_badge.dart new file mode 100644 index 0000000..7b73fc7 --- /dev/null +++ b/lib/features/social/widgets/avatar_badge.dart @@ -0,0 +1,87 @@ +import 'package:flutter/material.dart'; +import '../../../services/social/rider_profile_models.dart'; + +/// Renders a rider's avatar using their preset styling or custom photo URL. +class AvatarBadge extends StatelessWidget { + final String? avatarPreset; + final String? photoUrl; + final String displayName; + final double radius; + final VoidCallback? onTap; + final bool isSelected; + + const AvatarBadge({ + super.key, + this.avatarPreset, + this.photoUrl, + required this.displayName, + this.radius = 24.0, + this.onTap, + this.isSelected = false, + }); + + @override + Widget build(BuildContext context) { + final preset = PresetAvatar.byId(avatarPreset); + final primaryColor = Color(preset.primaryColorHex); + final secondaryColor = Color(preset.secondaryColorHex); + + Widget inner; + if (photoUrl != null && photoUrl!.trim().isNotEmpty) { + inner = CircleAvatar( + radius: radius, + backgroundColor: secondaryColor, + backgroundImage: NetworkImage(photoUrl!), + ); + } else { + final initial = displayName.trim().isNotEmpty + ? displayName.trim()[0].toUpperCase() + : 'R'; + + inner = CircleAvatar( + radius: radius, + backgroundColor: secondaryColor, + child: Container( + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: primaryColor.withValues(alpha: 0.8), + width: 1.5, + ), + ), + alignment: Alignment.center, + child: Text( + initial, + style: TextStyle( + color: primaryColor, + fontWeight: FontWeight.w900, + fontSize: radius * 0.9, + fontFamily: 'Sora', + ), + ), + ), + ); + } + + if (isSelected) { + inner = Container( + padding: const EdgeInsets.all(3), + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all(color: const Color(0xFF00E5FF), width: 2), + ), + child: inner, + ); + } + + if (onTap != null) { + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(radius * 2), + child: inner, + ); + } + + return inner; + } +} diff --git a/lib/features/social/widgets/avatar_selector.dart b/lib/features/social/widgets/avatar_selector.dart new file mode 100644 index 0000000..c499ae0 --- /dev/null +++ b/lib/features/social/widgets/avatar_selector.dart @@ -0,0 +1,52 @@ +import 'package:flutter/material.dart'; +import '../../../services/social/rider_profile_models.dart'; +import 'avatar_badge.dart'; + +/// Grid selector for choosing a preset rider avatar. +class AvatarSelector extends StatelessWidget { + final String? selectedPresetId; + final ValueChanged onSelected; + + const AvatarSelector({ + super.key, + required this.selectedPresetId, + required this.onSelected, + }); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'SELECT AVATAR PRESET', + style: TextStyle( + color: Colors.white70, + fontSize: 11, + fontWeight: FontWeight.w800, + letterSpacing: 0.8, + fontFamily: 'Inter', + ), + ), + const SizedBox(height: 10), + Wrap( + spacing: 12, + runSpacing: 12, + children: PresetAvatar.availablePresets.map((preset) { + final isSelected = (selectedPresetId ?? PresetAvatar.availablePresets.first.id) == preset.id; + return Tooltip( + message: preset.label, + child: AvatarBadge( + avatarPreset: preset.id, + displayName: preset.label, + radius: 22, + isSelected: isSelected, + onTap: () => onSelected(preset.id), + ), + ); + }).toList(), + ), + ], + ); + } +} diff --git a/lib/services/cloud/account_cloud_cleanup_service.dart b/lib/services/cloud/account_cloud_cleanup_service.dart index e8b14b0..d226fa7 100644 --- a/lib/services/cloud/account_cloud_cleanup_service.dart +++ b/lib/services/cloud/account_cloud_cleanup_service.dart @@ -79,6 +79,16 @@ class AccountCloudCleanupService implements CloudAccountCleanup { await _deleteCollection(userRef.collection('trips')); await _deleteCollection(userRef.collection('events')); await _deleteCollection(userRef.collection('friends')); + await _deleteCollection(userRef.collection('friend_requests_incoming')); + await _deleteCollection(userRef.collection('friend_requests_outgoing')); + await _deleteCollection(userRef.collection('blocks')); + + // Release handle reservation if exists + final userSnap = await userRef.get(); + final handle = userSnap.data()?['username'] as String?; + if (handle != null && handle.isNotEmpty) { + await firestore.collection('handles').doc(handle.toLowerCase()).delete(); + } // Delete the profile last so retries can still discover the account scope. final profileBatch = firestore.batch()..delete(userRef); diff --git a/lib/services/social/friendship_service.dart b/lib/services/social/friendship_service.dart new file mode 100644 index 0000000..f0db338 --- /dev/null +++ b/lib/services/social/friendship_service.dart @@ -0,0 +1,469 @@ +import 'dart:async'; +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:firebase_core/firebase_core.dart'; +import 'package:flutter/foundation.dart'; +import 'rider_profile_models.dart'; + +/// Manages mutual friendships, friend requests, blocking, and social boundaries. +class FriendshipService { + final FirebaseFirestore? _firestore; + + FriendshipService({FirebaseFirestore? firestore}) + : _firestore = firestore ?? _resolveFirestore(); + + static FirebaseFirestore? _resolveFirestore() { + try { + if (Firebase.apps.isNotEmpty) return FirebaseFirestore.instance; + } catch (_) {} + return null; + } + + bool get isAvailable => _firestore != null; + + /// Determines relationship status between [myUid] and [otherUid]. + Future getRelationshipStatus( + String myUid, + String otherUid, + ) async { + if (myUid == otherUid) return FriendshipStatus.self; + final firestore = _firestore; + if (firestore == null) return FriendshipStatus.none; + + try { + final userRef = firestore.collection('users').doc(myUid); + final otherRef = firestore.collection('users').doc(otherUid); + + // Check if user has blocked other or other has blocked user + final myBlockDoc = await userRef.collection('blocks').doc(otherUid).get(); + if (myBlockDoc.exists) return FriendshipStatus.blocked; + + final otherBlockDoc = + await otherRef.collection('blocks').doc(myUid).get(); + if (otherBlockDoc.exists) return FriendshipStatus.blocked; + + // Check mutual friend + final friendDoc = + await userRef.collection('friends').doc(otherUid).get(); + if (friendDoc.exists) return FriendshipStatus.friend; + + // Check incoming request + final incomingDoc = await userRef + .collection('friend_requests_incoming') + .doc(otherUid) + .get(); + if (incomingDoc.exists) return FriendshipStatus.requestIncoming; + + // Check outgoing request + final outgoingDoc = await userRef + .collection('friend_requests_outgoing') + .doc(otherUid) + .get(); + if (outgoingDoc.exists) return FriendshipStatus.requestOutgoing; + + return FriendshipStatus.none; + } catch (e) { + debugPrint('[FriendshipService] Error checking relationship status: $e'); + return FriendshipStatus.none; + } + } + + /// Sends a friend request from [sender] to [receiverUid]. + Future sendFriendRequest({ + required RiderProfile sender, + required String receiverUid, + }) async { + if (sender.uid == receiverUid) { + throw ArgumentError('Cannot send a friend request to yourself.'); + } + + final firestore = _firestore; + if (firestore == null) { + throw StateError('Social service is offline.'); + } + + final receiverRef = firestore.collection('users').doc(receiverUid); + final receiverDoc = await receiverRef.get(); + if (!receiverDoc.exists || receiverDoc.data() == null) { + throw StateError('Rider not found.'); + } + + final receiverProfile = RiderProfile.fromJson(receiverDoc.data()!); + if (!receiverProfile.privacySettings.allowFriendRequests) { + throw StateError('This rider is not accepting friend requests.'); + } + + // Check existing relationship + final status = await getRelationshipStatus(sender.uid, receiverUid); + if (status == FriendshipStatus.friend) { + throw StateError('You are already friends with this rider.'); + } + if (status == FriendshipStatus.requestOutgoing) { + throw StateError('Friend request already sent.'); + } + if (status == FriendshipStatus.requestIncoming) { + throw StateError('This rider already sent you a friend request.'); + } + if (status == FriendshipStatus.blocked) { + throw StateError('Unable to send request to this rider.'); + } + + final requestId = '${sender.uid}_$receiverUid'; + final now = DateTime.now(); + + final request = FriendRequest( + requestId: requestId, + senderUid: sender.uid, + senderHandle: sender.handle, + senderDisplayName: sender.displayName, + senderAvatarPreset: sender.avatarPreset, + senderAvatarUrl: sender.avatarUrl, + receiverUid: receiverUid, + sentAt: now, + ); + + final batch = firestore.batch(); + + // 1. Add to receiver's incoming requests + final incomingRef = receiverRef + .collection('friend_requests_incoming') + .doc(sender.uid); + batch.set(incomingRef, request.toJson()); + + // 2. Add to sender's outgoing requests + final outgoingRef = firestore + .collection('users') + .doc(sender.uid) + .collection('friend_requests_outgoing') + .doc(receiverUid); + batch.set(outgoingRef, request.toJson()); + + await batch.commit(); + } + + /// Accepts an incoming friend request and establishes mutual friendship. + Future acceptFriendRequest({ + required RiderProfile me, + required FriendRequest request, + }) async { + final firestore = _firestore; + if (firestore == null) { + throw StateError('Social service is offline.'); + } + + final senderUid = request.senderUid; + final now = DateTime.now(); + + final batch = firestore.batch(); + + // 1. Remove from receiver's incoming requests + final myIncomingRef = firestore + .collection('users') + .doc(me.uid) + .collection('friend_requests_incoming') + .doc(senderUid); + batch.delete(myIncomingRef); + + // 2. Remove from sender's outgoing requests + final senderOutgoingRef = firestore + .collection('users') + .doc(senderUid) + .collection('friend_requests_outgoing') + .doc(me.uid); + batch.delete(senderOutgoingRef); + + // 3. Add to receiver's friends + final myFriendRef = firestore + .collection('users') + .doc(me.uid) + .collection('friends') + .doc(senderUid); + final myFriendSummary = FriendSummary( + uid: senderUid, + handle: request.senderHandle, + displayName: request.senderDisplayName, + avatarPreset: request.senderAvatarPreset, + avatarUrl: request.senderAvatarUrl, + friendedAt: now, + ); + batch.set(myFriendRef, myFriendSummary.toJson()); + + // 4. Add to sender's friends + final senderFriendRef = firestore + .collection('users') + .doc(senderUid) + .collection('friends') + .doc(me.uid); + final senderFriendSummary = FriendSummary( + uid: me.uid, + handle: me.handle, + displayName: me.displayName, + avatarPreset: me.avatarPreset, + avatarUrl: me.avatarUrl, + friendedAt: now, + ); + batch.set(senderFriendRef, senderFriendSummary.toJson()); + + await batch.commit(); + } + + /// Declines an incoming friend request. + Future declineFriendRequest({ + required String myUid, + required String senderUid, + }) async { + final firestore = _firestore; + if (firestore == null) return; + + final batch = firestore.batch(); + + final myIncomingRef = firestore + .collection('users') + .doc(myUid) + .collection('friend_requests_incoming') + .doc(senderUid); + batch.delete(myIncomingRef); + + final senderOutgoingRef = firestore + .collection('users') + .doc(senderUid) + .collection('friend_requests_outgoing') + .doc(myUid); + batch.delete(senderOutgoingRef); + + await batch.commit(); + } + + /// Cancels an outgoing friend request. + Future cancelFriendRequest({ + required String myUid, + required String receiverUid, + }) async { + final firestore = _firestore; + if (firestore == null) return; + + final batch = firestore.batch(); + + final myOutgoingRef = firestore + .collection('users') + .doc(myUid) + .collection('friend_requests_outgoing') + .doc(receiverUid); + batch.delete(myOutgoingRef); + + final receiverIncomingRef = firestore + .collection('users') + .doc(receiverUid) + .collection('friend_requests_incoming') + .doc(myUid); + batch.delete(receiverIncomingRef); + + await batch.commit(); + } + + /// Removes a mutual friend. + Future removeFriend({ + required String myUid, + required String friendUid, + }) async { + final firestore = _firestore; + if (firestore == null) return; + + final batch = firestore.batch(); + + final myFriendRef = firestore + .collection('users') + .doc(myUid) + .collection('friends') + .doc(friendUid); + batch.delete(myFriendRef); + + final theirFriendRef = firestore + .collection('users') + .doc(friendUid) + .collection('friends') + .doc(myUid); + batch.delete(theirFriendRef); + + await batch.commit(); + } + + /// Blocks another rider, removing any friendship or pending requests. + Future blockRider({ + required String myUid, + required String targetUid, + String? targetHandle, + String? targetDisplayName, + }) async { + if (myUid == targetUid) return; + final firestore = _firestore; + if (firestore == null) return; + + final batch = firestore.batch(); + + // Remove any mutual friendship + batch.delete( + firestore + .collection('users') + .doc(myUid) + .collection('friends') + .doc(targetUid), + ); + batch.delete( + firestore + .collection('users') + .doc(targetUid) + .collection('friends') + .doc(myUid), + ); + + // Remove any pending requests + batch.delete( + firestore + .collection('users') + .doc(myUid) + .collection('friend_requests_incoming') + .doc(targetUid), + ); + batch.delete( + firestore + .collection('users') + .doc(myUid) + .collection('friend_requests_outgoing') + .doc(targetUid), + ); + batch.delete( + firestore + .collection('users') + .doc(targetUid) + .collection('friend_requests_incoming') + .doc(myUid), + ); + batch.delete( + firestore + .collection('users') + .doc(targetUid) + .collection('friend_requests_outgoing') + .doc(myUid), + ); + + // Add block record + final block = BlockedRider( + blockedUid: targetUid, + blockedHandle: targetHandle, + blockedDisplayName: targetDisplayName, + blockedAt: DateTime.now(), + ); + final blockRef = firestore + .collection('users') + .doc(myUid) + .collection('blocks') + .doc(targetUid); + batch.set(blockRef, block.toJson()); + + await batch.commit(); + } + + /// Unblocks a previously blocked rider. + Future unblockRider({ + required String myUid, + required String targetUid, + }) async { + final firestore = _firestore; + if (firestore == null) return; + + await firestore + .collection('users') + .doc(myUid) + .collection('blocks') + .doc(targetUid) + .delete(); + } + + /// Stream of current friends. + Stream> friendsStream(String uid) { + final firestore = _firestore; + if (firestore == null) return Stream.value([]); + + return firestore + .collection('users') + .doc(uid) + .collection('friends') + .snapshots() + .map((snapshot) { + return snapshot.docs + .map((doc) => FriendSummary.fromJson(doc.data())) + .toList() + ..sort((a, b) => a.displayName.compareTo(b.displayName)); + }) + .handleError((e) { + debugPrint('[FriendshipService] Error in friends stream: $e'); + return []; + }); + } + + /// Stream of incoming friend requests. + Stream> incomingRequestsStream(String uid) { + final firestore = _firestore; + if (firestore == null) return Stream.value([]); + + return firestore + .collection('users') + .doc(uid) + .collection('friend_requests_incoming') + .snapshots() + .map((snapshot) { + return snapshot.docs + .map((doc) => FriendRequest.fromJson(doc.data())) + .toList() + ..sort((a, b) => b.sentAt.compareTo(a.sentAt)); + }) + .handleError((e) { + debugPrint('[FriendshipService] Error in incoming requests stream: $e'); + return []; + }); + } + + /// Stream of outgoing friend requests. + Stream> outgoingRequestsStream(String uid) { + final firestore = _firestore; + if (firestore == null) return Stream.value([]); + + return firestore + .collection('users') + .doc(uid) + .collection('friend_requests_outgoing') + .snapshots() + .map((snapshot) { + return snapshot.docs + .map((doc) => FriendRequest.fromJson(doc.data())) + .toList() + ..sort((a, b) => b.sentAt.compareTo(a.sentAt)); + }) + .handleError((e) { + debugPrint('[FriendshipService] Error in outgoing requests stream: $e'); + return []; + }); + } + + /// Stream of blocked riders. + Stream> blockedRidersStream(String uid) { + final firestore = _firestore; + if (firestore == null) return Stream.value([]); + + return firestore + .collection('users') + .doc(uid) + .collection('blocks') + .snapshots() + .map((snapshot) { + return snapshot.docs + .map((doc) => BlockedRider.fromJson(doc.data())) + .toList() + ..sort((a, b) => b.blockedAt.compareTo(a.blockedAt)); + }) + .handleError((e) { + debugPrint('[FriendshipService] Error in blocked riders stream: $e'); + return []; + }); + } +} diff --git a/lib/services/social/handle_validator.dart b/lib/services/social/handle_validator.dart new file mode 100644 index 0000000..23e5c57 --- /dev/null +++ b/lib/services/social/handle_validator.dart @@ -0,0 +1,96 @@ +/// Validation and normalization logic for MotorSignal rider handles. +class HandleValidationResult { + final bool isValid; + final String? error; + final String? normalizedHandle; + + const HandleValidationResult._({ + required this.isValid, + this.error, + this.normalizedHandle, + }); + + factory HandleValidationResult.valid(String normalized) => + HandleValidationResult._(isValid: true, normalizedHandle: normalized); + + factory HandleValidationResult.invalid(String error) => + HandleValidationResult._(isValid: false, error: error); +} + +class HandleValidator { + static const int minLength = 3; + static const int maxLength = 20; + + static final RegExp _formatRegex = RegExp(r'^[a-z][a-z0-9_]{2,19}$'); + + static const Set reservedHandles = { + 'admin', + 'administrator', + 'motorsignal', + 'motorcloud', + 'support', + 'system', + 'mod', + 'moderator', + 'help', + 'official', + 'root', + 'api', + 'bot', + 'test', + 'dev', + 'developer', + 'staff', + 'security', + 'null', + 'undefined', + 'guest', + 'anonymous', + 'safety', + 'emergency', + 'police', + 'marshal', + 'leader', + 'host', + }; + + /// Normalizes a handle: trims whitespace and lowercases. + static String normalize(String handle) => handle.trim().toLowerCase(); + + /// Validates a handle format and checks against reserved names. + static HandleValidationResult validate(String rawHandle) { + final normalized = normalize(rawHandle); + + if (normalized.isEmpty) { + return HandleValidationResult.invalid('Handle cannot be empty'); + } + + if (normalized.length < minLength) { + return HandleValidationResult.invalid( + 'Handle must be at least $minLength characters', + ); + } + + if (normalized.length > maxLength) { + return HandleValidationResult.invalid( + 'Handle cannot exceed $maxLength characters', + ); + } + + if (!RegExp(r'^[a-z]').hasMatch(normalized)) { + return HandleValidationResult.invalid('Handle must start with a letter'); + } + + if (!_formatRegex.hasMatch(normalized)) { + return HandleValidationResult.invalid( + 'Handle can only contain lowercase letters, numbers, and underscores', + ); + } + + if (reservedHandles.contains(normalized)) { + return HandleValidationResult.invalid('This handle is reserved'); + } + + return HandleValidationResult.valid(normalized); + } +} diff --git a/lib/services/social/rider_profile_models.dart b/lib/services/social/rider_profile_models.dart new file mode 100644 index 0000000..e1c285d --- /dev/null +++ b/lib/services/social/rider_profile_models.dart @@ -0,0 +1,413 @@ +import 'package:flutter/foundation.dart'; + +/// Preset avatar options available for all riders without external image hosting. +class PresetAvatar { + final String id; + final String label; + final int primaryColorHex; + final int secondaryColorHex; + + const PresetAvatar({ + required this.id, + required this.label, + required this.primaryColorHex, + required this.secondaryColorHex, + }); + + static const List availablePresets = [ + PresetAvatar( + id: 'helmet_cyan', + label: 'Cyan Apex', + primaryColorHex: 0xFF00E5FF, + secondaryColorHex: 0xFF003840, + ), + PresetAvatar( + id: 'helmet_amber', + label: 'Amber Sector', + primaryColorHex: 0xFFFFB300, + secondaryColorHex: 0xFF4A3200, + ), + PresetAvatar( + id: 'tach_neon', + label: 'Neon Tach', + primaryColorHex: 0xFF00E676, + secondaryColorHex: 0xFF00381B, + ), + PresetAvatar( + id: 'speed_violet', + label: 'Violet Velocity', + primaryColorHex: 0xFFD500F9, + secondaryColorHex: 0xFF3E004A, + ), + PresetAvatar( + id: 'cruiser_red', + label: 'Crimson Cruiser', + primaryColorHex: 0xFFFF1744, + secondaryColorHex: 0xFF4A0513, + ), + PresetAvatar( + id: 'adv_blue', + label: 'Adventure Azure', + primaryColorHex: 0xFF2979FF, + secondaryColorHex: 0xFF0A224A, + ), + PresetAvatar( + id: 'cafe_gold', + label: 'Cafe Gold', + primaryColorHex: 0xFFFFD700, + secondaryColorHex: 0xFF4A3E00, + ), + PresetAvatar( + id: 'touring_slate', + label: 'Touring Slate', + primaryColorHex: 0xFF90A4AE, + secondaryColorHex: 0xFF263238, + ), + ]; + + static PresetAvatar byId(String? id) { + if (id == null) return availablePresets.first; + return availablePresets.firstWhere( + (p) => p.id == id, + orElse: () => availablePresets.first, + ); + } +} + +/// Privacy controls for a rider profile. +@immutable +class ProfilePrivacySettings { + final bool isSearchableByHandle; + final bool isProfilePublic; + final bool allowFriendRequests; + + const ProfilePrivacySettings({ + this.isSearchableByHandle = true, + this.isProfilePublic = true, + this.allowFriendRequests = true, + }); + + Map toJson() => { + 'isSearchableByHandle': isSearchableByHandle, + 'isProfilePublic': isProfilePublic, + 'allowFriendRequests': allowFriendRequests, + }; + + factory ProfilePrivacySettings.fromJson(Map? json) { + if (json == null) return const ProfilePrivacySettings(); + return ProfilePrivacySettings( + isSearchableByHandle: json['isSearchableByHandle'] as bool? ?? true, + isProfilePublic: json['isProfilePublic'] as bool? ?? true, + allowFriendRequests: json['allowFriendRequests'] as bool? ?? true, + ); + } + + ProfilePrivacySettings copyWith({ + bool? isSearchableByHandle, + bool? isProfilePublic, + bool? allowFriendRequests, + }) { + return ProfilePrivacySettings( + isSearchableByHandle: isSearchableByHandle ?? this.isSearchableByHandle, + isProfilePublic: isProfilePublic ?? this.isProfilePublic, + allowFriendRequests: allowFriendRequests ?? this.allowFriendRequests, + ); + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is ProfilePrivacySettings && + runtimeType == other.runtimeType && + isSearchableByHandle == other.isSearchableByHandle && + isProfilePublic == other.isProfilePublic && + allowFriendRequests == other.allowFriendRequests; + + @override + int get hashCode => + isSearchableByHandle.hashCode ^ + isProfilePublic.hashCode ^ + allowFriendRequests.hashCode; +} + +/// Public rider profile decoupled from authentication credentials and cloud ride ownership. +@immutable +class RiderProfile { + final String uid; + final String handle; + final String displayName; + final String bio; + final String region; + final String? avatarPreset; + final String? avatarUrl; + final ProfilePrivacySettings privacySettings; + final DateTime createdAt; + final DateTime updatedAt; + + const RiderProfile({ + required this.uid, + required this.handle, + required this.displayName, + this.bio = '', + this.region = '', + this.avatarPreset, + this.avatarUrl, + this.privacySettings = const ProfilePrivacySettings(), + required this.createdAt, + required this.updatedAt, + }); + + String get displayHandle => '@$handle'; + + PresetAvatar get preset => PresetAvatar.byId(avatarPreset); + + Map toJson() => { + 'id': uid, + 'username': handle, + 'displayName': displayName, + 'bio': bio, + 'location': region, + 'profilePhotoUrl': avatarUrl, + 'avatarPreset': avatarPreset, + 'privacySettings': privacySettings.toJson(), + 'createdAt': createdAt.toIso8601String(), + 'updatedAt': updatedAt.toIso8601String(), + }; + + factory RiderProfile.fromJson(Map json) { + final created = json['createdAt'] != null + ? DateTime.tryParse(json['createdAt'] as String) ?? DateTime.now() + : DateTime.now(); + final updated = json['updatedAt'] != null + ? DateTime.tryParse(json['updatedAt'] as String) ?? created + : created; + + return RiderProfile( + uid: json['id'] as String? ?? '', + handle: (json['username'] as String? ?? '').toLowerCase().trim(), + displayName: json['displayName'] as String? ?? '', + bio: json['bio'] as String? ?? '', + region: json['location'] as String? ?? '', + avatarPreset: json['avatarPreset'] as String?, + avatarUrl: json['profilePhotoUrl'] as String?, + privacySettings: ProfilePrivacySettings.fromJson( + json['privacySettings'] as Map?, + ), + createdAt: created, + updatedAt: updated, + ); + } + + RiderProfile copyWith({ + String? uid, + String? handle, + String? displayName, + String? bio, + String? region, + String? avatarPreset, + String? avatarUrl, + ProfilePrivacySettings? privacySettings, + DateTime? createdAt, + DateTime? updatedAt, + }) { + return RiderProfile( + uid: uid ?? this.uid, + handle: handle ?? this.handle, + displayName: displayName ?? this.displayName, + bio: bio ?? this.bio, + region: region ?? this.region, + avatarPreset: avatarPreset ?? this.avatarPreset, + avatarUrl: avatarUrl ?? this.avatarUrl, + privacySettings: privacySettings ?? this.privacySettings, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + ); + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is RiderProfile && + runtimeType == other.runtimeType && + uid == other.uid && + handle == other.handle && + displayName == other.displayName && + bio == other.bio && + region == other.region && + avatarPreset == other.avatarPreset && + avatarUrl == other.avatarUrl && + privacySettings == other.privacySettings; + + @override + int get hashCode => + uid.hashCode ^ + handle.hashCode ^ + displayName.hashCode ^ + bio.hashCode ^ + region.hashCode ^ + avatarPreset.hashCode ^ + avatarUrl.hashCode ^ + privacySettings.hashCode; +} + +/// Relationship status between the active rider and another rider. +enum FriendshipStatus { + self, + none, + friend, + requestIncoming, + requestOutgoing, + blocked, +} + +/// Mutual friend summary. +@immutable +class FriendSummary { + final String uid; + final String handle; + final String displayName; + final String? avatarPreset; + final String? avatarUrl; + final DateTime friendedAt; + + const FriendSummary({ + required this.uid, + required this.handle, + required this.displayName, + this.avatarPreset, + this.avatarUrl, + required this.friendedAt, + }); + + String get displayHandle => '@$handle'; + + Map toJson() => { + 'uid': uid, + 'handle': handle, + 'displayName': displayName, + 'avatarPreset': avatarPreset, + 'avatarUrl': avatarUrl, + 'friendedAt': friendedAt.toIso8601String(), + }; + + factory FriendSummary.fromJson(Map json) => FriendSummary( + uid: json['uid'] as String? ?? '', + handle: json['handle'] as String? ?? '', + displayName: json['displayName'] as String? ?? '', + avatarPreset: json['avatarPreset'] as String?, + avatarUrl: json['avatarUrl'] as String?, + friendedAt: json['friendedAt'] != null + ? DateTime.tryParse(json['friendedAt'] as String) ?? DateTime.now() + : DateTime.now(), + ); +} + +/// Incoming or outgoing friend request. +@immutable +class FriendRequest { + final String requestId; + final String senderUid; + final String senderHandle; + final String senderDisplayName; + final String? senderAvatarPreset; + final String? senderAvatarUrl; + final String receiverUid; + final DateTime sentAt; + + const FriendRequest({ + required this.requestId, + required this.senderUid, + required this.senderHandle, + required this.senderDisplayName, + this.senderAvatarPreset, + this.senderAvatarUrl, + required this.receiverUid, + required this.sentAt, + }); + + Map toJson() => { + 'requestId': requestId, + 'senderUid': senderUid, + 'senderHandle': senderHandle, + 'senderDisplayName': senderDisplayName, + 'senderAvatarPreset': senderAvatarPreset, + 'senderAvatarUrl': senderAvatarUrl, + 'receiverUid': receiverUid, + 'sentAt': sentAt.toIso8601String(), + }; + + factory FriendRequest.fromJson(Map json) => FriendRequest( + requestId: json['requestId'] as String? ?? '', + senderUid: json['senderUid'] as String? ?? '', + senderHandle: json['senderHandle'] as String? ?? '', + senderDisplayName: json['senderDisplayName'] as String? ?? '', + senderAvatarPreset: json['senderAvatarPreset'] as String?, + senderAvatarUrl: json['senderAvatarUrl'] as String?, + receiverUid: json['receiverUid'] as String? ?? '', + sentAt: json['sentAt'] != null + ? DateTime.tryParse(json['sentAt'] as String) ?? DateTime.now() + : DateTime.now(), + ); +} + +/// Blocked rider record. +@immutable +class BlockedRider { + final String blockedUid; + final String? blockedHandle; + final String? blockedDisplayName; + final DateTime blockedAt; + + const BlockedRider({ + required this.blockedUid, + this.blockedHandle, + this.blockedDisplayName, + required this.blockedAt, + }); + + Map toJson() => { + 'blockedUid': blockedUid, + 'blockedHandle': blockedHandle, + 'blockedDisplayName': blockedDisplayName, + 'blockedAt': blockedAt.toIso8601String(), + }; + + factory BlockedRider.fromJson(Map json) => BlockedRider( + blockedUid: json['blockedUid'] as String? ?? '', + blockedHandle: json['blockedHandle'] as String?, + blockedDisplayName: json['blockedDisplayName'] as String?, + blockedAt: json['blockedAt'] != null + ? DateTime.tryParse(json['blockedAt'] as String) ?? DateTime.now() + : DateTime.now(), + ); +} + +/// Search result summary for public rider lookup. +@immutable +class RiderSearchResult { + final String uid; + final String handle; + final String displayName; + final String bio; + final String region; + final String? avatarPreset; + final String? avatarUrl; + final bool isProfilePublic; + final bool allowFriendRequests; + final FriendshipStatus relationship; + + const RiderSearchResult({ + required this.uid, + required this.handle, + required this.displayName, + this.bio = '', + this.region = '', + this.avatarPreset, + this.avatarUrl, + required this.isProfilePublic, + required this.allowFriendRequests, + required this.relationship, + }); + + String get displayHandle => '@$handle'; +} diff --git a/lib/services/social/rider_profile_service.dart b/lib/services/social/rider_profile_service.dart new file mode 100644 index 0000000..03b0c27 --- /dev/null +++ b/lib/services/social/rider_profile_service.dart @@ -0,0 +1,213 @@ +import 'dart:async'; +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:firebase_core/firebase_core.dart'; +import 'package:flutter/foundation.dart'; +import 'handle_validator.dart'; +import 'rider_profile_models.dart'; + +/// Service managing public rider profile persistence, unique handle reservations, +/// and privacy preferences in Cloud Firestore. +class RiderProfileService { + final FirebaseFirestore? _firestore; + + RiderProfileService({FirebaseFirestore? firestore}) + : _firestore = firestore ?? _resolveFirestore(); + + static FirebaseFirestore? _resolveFirestore() { + try { + if (Firebase.apps.isNotEmpty) return FirebaseFirestore.instance; + } catch (_) {} + return null; + } + + bool get isAvailable => _firestore != null; + + /// Fetches a rider's profile by UID. + Future getProfile(String uid) async { + final firestore = _firestore; + if (firestore == null) return null; + + try { + final doc = await firestore.collection('users').doc(uid).get(); + if (!doc.exists || doc.data() == null) return null; + return RiderProfile.fromJson(doc.data()!); + } catch (e) { + debugPrint('[RiderProfileService] Failed to load profile for $uid: $e'); + return null; + } + } + + /// Realtime stream of a rider's profile by UID. + Stream profileStream(String uid) { + final firestore = _firestore; + if (firestore == null) return Stream.value(null); + + return firestore + .collection('users') + .doc(uid) + .snapshots() + .map((snapshot) { + if (!snapshot.exists || snapshot.data() == null) return null; + try { + return RiderProfile.fromJson(snapshot.data()!); + } catch (e) { + debugPrint('[RiderProfileService] Stream parse error for $uid: $e'); + return null; + } + }) + .handleError((e) { + debugPrint('[RiderProfileService] Stream error for $uid: $e'); + return null; + }); + } + + /// Checks if a normalized handle is available for reservation. + /// If [currentUid] is provided, returning true if the handle is already owned by [currentUid]. + Future isHandleAvailable(String handle, {String? currentUid}) async { + final validation = HandleValidator.validate(handle); + if (!validation.isValid) return false; + + final firestore = _firestore; + if (firestore == null) return true; + + final normalized = validation.normalizedHandle!; + try { + final doc = await firestore.collection('handles').doc(normalized).get(); + if (!doc.exists) return true; + + final data = doc.data(); + final ownerUid = data?['uid'] as String?; + return currentUid != null && ownerUid == currentUid; + } catch (e) { + debugPrint('[RiderProfileService] Error checking handle $normalized: $e'); + return false; + } + } + + /// Saves or updates a rider's profile while atomically securing the handle reservation. + Future saveProfile({ + required String uid, + required String handle, + required String displayName, + String bio = '', + String region = '', + String? avatarPreset, + String? avatarUrl, + ProfilePrivacySettings? privacySettings, + }) async { + final validation = HandleValidator.validate(handle); + if (!validation.isValid) { + throw ArgumentError(validation.error); + } + final normalizedHandle = validation.normalizedHandle!; + + final firestore = _firestore; + final now = DateTime.now(); + + if (firestore == null) { + // Offline/local fallback + return RiderProfile( + uid: uid, + handle: normalizedHandle, + displayName: displayName.trim(), + bio: bio.trim(), + region: region.trim(), + avatarPreset: avatarPreset, + avatarUrl: avatarUrl, + privacySettings: privacySettings ?? const ProfilePrivacySettings(), + createdAt: now, + updatedAt: now, + ); + } + + final userRef = firestore.collection('users').doc(uid); + final handleRef = firestore.collection('handles').doc(normalizedHandle); + + // Read current user profile to determine previous handle if changing + final currentDoc = await userRef.get(); + final existingProfile = + currentDoc.exists && currentDoc.data() != null + ? RiderProfile.fromJson(currentDoc.data()!) + : null; + + final previousHandle = existingProfile?.handle; + final isChangingHandle = + previousHandle != null && previousHandle != normalizedHandle; + + await firestore.runTransaction((transaction) async { + // 1. Verify handle availability + final handleSnapshot = await transaction.get(handleRef); + if (handleSnapshot.exists) { + final ownerUid = handleSnapshot.data()?['uid'] as String?; + if (ownerUid != uid) { + throw StateError('Handle @$normalizedHandle is already taken.'); + } + } + + // 2. Reserve new handle + transaction.set(handleRef, { + 'handle': normalizedHandle, + 'uid': uid, + 'createdAt': FieldValue.serverTimestamp(), + }); + + // 3. Release previous handle if changed + if (isChangingHandle) { + final oldHandleRef = firestore.collection('handles').doc(previousHandle); + transaction.delete(oldHandleRef); + } + + // 4. Save profile document + final updatedProfile = RiderProfile( + uid: uid, + handle: normalizedHandle, + displayName: displayName.trim().isEmpty ? normalizedHandle : displayName.trim(), + bio: bio.trim(), + region: region.trim(), + avatarPreset: avatarPreset, + avatarUrl: avatarUrl, + privacySettings: privacySettings ?? existingProfile?.privacySettings ?? const ProfilePrivacySettings(), + createdAt: existingProfile?.createdAt ?? now, + updatedAt: now, + ); + + transaction.set(userRef, updatedProfile.toJson(), SetOptions(merge: true)); + }); + + final saved = await getProfile(uid); + return saved ?? + RiderProfile( + uid: uid, + handle: normalizedHandle, + displayName: displayName.trim(), + bio: bio.trim(), + region: region.trim(), + avatarPreset: avatarPreset, + avatarUrl: avatarUrl, + privacySettings: privacySettings ?? const ProfilePrivacySettings(), + createdAt: existingProfile?.createdAt ?? now, + updatedAt: now, + ); + } + + /// Releases the rider's handle and cleans up the profile doc. + Future deleteProfile(String uid) async { + final firestore = _firestore; + if (firestore == null) return; + + try { + final userRef = firestore.collection('users').doc(uid); + final doc = await userRef.get(); + if (doc.exists && doc.data() != null) { + final handle = doc.data()?['username'] as String?; + if (handle != null && handle.isNotEmpty) { + await firestore.collection('handles').doc(handle.toLowerCase()).delete(); + } + } + await userRef.delete(); + } catch (e) { + debugPrint('[RiderProfileService] Failed to delete profile for $uid: $e'); + rethrow; + } + } +} diff --git a/lib/services/social/rider_search_service.dart b/lib/services/social/rider_search_service.dart new file mode 100644 index 0000000..3936976 --- /dev/null +++ b/lib/services/social/rider_search_service.dart @@ -0,0 +1,129 @@ +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:firebase_core/firebase_core.dart'; +import 'package:flutter/foundation.dart'; +import 'friendship_service.dart'; +import 'handle_validator.dart'; +import 'rider_profile_models.dart'; +import 'rider_profile_service.dart'; + +/// Search service for discovering riders by unique handle while respecting privacy controls. +class RiderSearchService { + final FirebaseFirestore? _firestore; + final RiderProfileService _profileService; + final FriendshipService _friendshipService; + + RiderSearchService({ + FirebaseFirestore? firestore, + RiderProfileService? profileService, + FriendshipService? friendshipService, + }) : _firestore = firestore ?? _resolveFirestore(), + _profileService = profileService ?? RiderProfileService(firestore: firestore), + _friendshipService = friendshipService ?? FriendshipService(firestore: firestore); + + static FirebaseFirestore? _resolveFirestore() { + try { + if (Firebase.apps.isNotEmpty) return FirebaseFirestore.instance; + } catch (_) {} + return null; + } + + /// Searches for a rider by exact or normalized handle. + /// Returns null if not found, blocked, or hidden by privacy settings. + Future searchByHandle({ + required String rawHandle, + required String callerUid, + }) async { + final validation = HandleValidator.validate(rawHandle); + if (!validation.isValid) return null; + + final normalized = validation.normalizedHandle!; + final firestore = _firestore; + if (firestore == null) return null; + + try { + // 1. Resolve handle to UID + final handleDoc = + await firestore.collection('handles').doc(normalized).get(); + if (!handleDoc.exists || handleDoc.data() == null) return null; + + final targetUid = handleDoc.data()!['uid'] as String?; + if (targetUid == null || targetUid.isEmpty) return null; + + // 2. Self check + if (targetUid == callerUid) { + final profile = await _profileService.getProfile(callerUid); + if (profile == null) return null; + return RiderSearchResult( + uid: profile.uid, + handle: profile.handle, + displayName: profile.displayName, + bio: profile.bio, + region: profile.region, + avatarPreset: profile.avatarPreset, + avatarUrl: profile.avatarUrl, + isProfilePublic: profile.privacySettings.isProfilePublic, + allowFriendRequests: profile.privacySettings.allowFriendRequests, + relationship: FriendshipStatus.self, + ); + } + + // 3. Check blocks between caller and target + final callerBlockDoc = await firestore + .collection('users') + .doc(callerUid) + .collection('blocks') + .doc(targetUid) + .get(); + if (callerBlockDoc.exists) return null; + + final targetBlockDoc = await firestore + .collection('users') + .doc(targetUid) + .collection('blocks') + .doc(callerUid) + .get(); + if (targetBlockDoc.exists) return null; + + // 4. Fetch target profile + final targetProfile = await _profileService.getProfile(targetUid); + if (targetProfile == null) return null; + + // 5. Determine relationship + final relationship = await _friendshipService.getRelationshipStatus( + callerUid, + targetUid, + ); + + // 6. Respect handle searchability privacy setting + final isFriend = relationship == FriendshipStatus.friend; + final hasPending = relationship == FriendshipStatus.requestIncoming || + relationship == FriendshipStatus.requestOutgoing; + + if (!targetProfile.privacySettings.isSearchableByHandle && + !isFriend && + !hasPending) { + return null; + } + + // 7. Respect public/private profile setting + final isPublic = targetProfile.privacySettings.isProfilePublic; + final showFullProfile = isPublic || isFriend; + + return RiderSearchResult( + uid: targetProfile.uid, + handle: targetProfile.handle, + displayName: targetProfile.displayName, + bio: showFullProfile ? targetProfile.bio : '', + region: showFullProfile ? targetProfile.region : '', + avatarPreset: targetProfile.avatarPreset, + avatarUrl: targetProfile.avatarUrl, + isProfilePublic: isPublic, + allowFriendRequests: targetProfile.privacySettings.allowFriendRequests, + relationship: relationship, + ); + } catch (e) { + debugPrint('[RiderSearchService] Search error for $normalized: $e'); + return null; + } + } +} diff --git a/lib/services/social/social_providers.dart b/lib/services/social/social_providers.dart new file mode 100644 index 0000000..45510cf --- /dev/null +++ b/lib/services/social/social_providers.dart @@ -0,0 +1,76 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../auth/auth_providers.dart'; +import 'friendship_service.dart'; +import 'rider_profile_models.dart'; +import 'rider_profile_service.dart'; +import 'rider_search_service.dart'; + +/// Provider for RiderProfileService. +final riderProfileServiceProvider = Provider((ref) { + return RiderProfileService(); +}); + +/// Provider for FriendshipService. +final friendshipServiceProvider = Provider((ref) { + return FriendshipService(); +}); + +/// Provider for RiderSearchService. +final riderSearchServiceProvider = Provider((ref) { + final profileService = ref.watch(riderProfileServiceProvider); + final friendshipService = ref.watch(friendshipServiceProvider); + return RiderSearchService( + profileService: profileService, + friendshipService: friendshipService, + ); +}); + +/// Stream of the active user's RiderProfile. +final myRiderProfileProvider = StreamProvider((ref) { + final user = ref.watch(currentUserProvider); + if (user == null || user.isAnonymous) { + return Stream.value(null); + } + final service = ref.watch(riderProfileServiceProvider); + return service.profileStream(user.uid); +}); + +/// Stream of the active user's friends list. +final myFriendsProvider = StreamProvider>((ref) { + final user = ref.watch(currentUserProvider); + if (user == null || user.isAnonymous) { + return Stream.value([]); + } + final service = ref.watch(friendshipServiceProvider); + return service.friendsStream(user.uid); +}); + +/// Stream of incoming friend requests for the active user. +final myIncomingRequestsProvider = StreamProvider>((ref) { + final user = ref.watch(currentUserProvider); + if (user == null || user.isAnonymous) { + return Stream.value([]); + } + final service = ref.watch(friendshipServiceProvider); + return service.incomingRequestsStream(user.uid); +}); + +/// Stream of outgoing friend requests for the active user. +final myOutgoingRequestsProvider = StreamProvider>((ref) { + final user = ref.watch(currentUserProvider); + if (user == null || user.isAnonymous) { + return Stream.value([]); + } + final service = ref.watch(friendshipServiceProvider); + return service.outgoingRequestsStream(user.uid); +}); + +/// Stream of blocked riders for the active user. +final myBlockedRidersProvider = StreamProvider>((ref) { + final user = ref.watch(currentUserProvider); + if (user == null || user.isAnonymous) { + return Stream.value([]); + } + final service = ref.watch(friendshipServiceProvider); + return service.blockedRidersStream(user.uid); +}); diff --git a/test/friendship_and_search_test.dart b/test/friendship_and_search_test.dart new file mode 100644 index 0000000..0d06f67 --- /dev/null +++ b/test/friendship_and_search_test.dart @@ -0,0 +1,78 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:motorcycle_dash/services/social/friendship_service.dart'; +import 'package:motorcycle_dash/services/social/rider_profile_models.dart'; +import 'package:motorcycle_dash/services/social/rider_search_service.dart'; + +void main() { + group('FriendshipService offline & validation contracts', () { + test('cannot send friend request to self', () async { + final service = FriendshipService(); + final profile = RiderProfile( + uid: 'user_self', + handle: 'self_pilot', + displayName: 'Self Pilot', + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + ); + + expect( + () => service.sendFriendRequest( + sender: profile, + receiverUid: 'user_self', + ), + throwsA(isA()), + ); + }); + + test('determines relationship self for identical UIDs', () async { + final service = FriendshipService(); + final status = await service.getRelationshipStatus('user_a', 'user_a'); + expect(status, equals(FriendshipStatus.self)); + }); + + test('falls back gracefully when offline', () async { + final service = FriendshipService(); + final status = await service.getRelationshipStatus('user_a', 'user_b'); + expect(status, equals(FriendshipStatus.none)); + + final friendsStream = service.friendsStream('user_a'); + expect(await friendsStream.first, isEmpty); + + final incomingStream = service.incomingRequestsStream('user_a'); + expect(await incomingStream.first, isEmpty); + + final outgoingStream = service.outgoingRequestsStream('user_a'); + expect(await outgoingStream.first, isEmpty); + + final blockedStream = service.blockedRidersStream('user_a'); + expect(await blockedStream.first, isEmpty); + }); + }); + + group('RiderSearchService validation contracts', () { + test('rejects invalid handles before network call', () async { + final service = RiderSearchService(); + final result = await service.searchByHandle( + rawHandle: 'no', // too short + callerUid: 'caller_1', + ); + expect(result, isNull); + + final reservedResult = await service.searchByHandle( + rawHandle: 'admin', + callerUid: 'caller_1', + ); + expect(reservedResult, isNull); + }); + + test('returns self status when searching own handle while offline', () async { + final service = RiderSearchService(); + // When offline, profile service returns null for unmocked UID, gracefully returning null + final result = await service.searchByHandle( + rawHandle: 'my_handle', + callerUid: 'my_uid', + ); + expect(result, isNull); + }); + }); +} diff --git a/test/handle_validator_test.dart b/test/handle_validator_test.dart new file mode 100644 index 0000000..f877705 --- /dev/null +++ b/test/handle_validator_test.dart @@ -0,0 +1,85 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:motorcycle_dash/services/social/handle_validator.dart'; + +void main() { + group('HandleValidator', () { + test('accepts valid handles', () { + final validCases = [ + 'canyon_rider', + 'apex_hunter99', + 'moto_gp', + 'speedy', + 'r1200gs', + 'moto_pilot_2026', + ]; + + for (final handle in validCases) { + final result = HandleValidator.validate(handle); + expect(result.isValid, isTrue, reason: 'Expected $handle to be valid'); + expect(result.normalizedHandle, equals(handle.toLowerCase().trim())); + expect(result.error, isNull); + } + }); + + test('normalizes handles with uppercase and whitespace', () { + final result = HandleValidator.validate(' Apex_Rider '); + expect(result.isValid, isTrue); + expect(result.normalizedHandle, equals('apex_rider')); + }); + + test('rejects empty or blank handles', () { + expect(HandleValidator.validate('').isValid, isFalse); + expect(HandleValidator.validate(' ').isValid, isFalse); + }); + + test('rejects handles shorter than 3 characters', () { + final result = HandleValidator.validate('ab'); + expect(result.isValid, isFalse); + expect(result.error, contains('at least 3 characters')); + }); + + test('rejects handles longer than 20 characters', () { + final result = HandleValidator.validate('this_handle_is_way_too_long_for_system'); + expect(result.isValid, isFalse); + expect(result.error, contains('cannot exceed 20 characters')); + }); + + test('rejects handles not starting with a letter', () { + expect(HandleValidator.validate('99riders').isValid, isFalse); + expect(HandleValidator.validate('_speedy').isValid, isFalse); + }); + + test('rejects invalid characters like hyphens, spaces, and punctuation', () { + expect(HandleValidator.validate('moto-rider').isValid, isFalse); + expect(HandleValidator.validate('moto rider').isValid, isFalse); + expect(HandleValidator.validate('moto@dash').isValid, isFalse); + expect(HandleValidator.validate('moto!rider').isValid, isFalse); + }); + + test('rejects reserved handles', () { + final reserved = [ + 'admin', + 'administrator', + 'motorsignal', + 'motorcloud', + 'support', + 'system', + 'mod', + 'moderator', + 'help', + 'official', + 'root', + 'api', + 'bot', + 'guest', + 'anonymous', + ]; + + for (final handle in reserved) { + final result = HandleValidator.validate(handle); + expect(result.isValid, isFalse, reason: 'Expected reserved handle $handle to fail'); + expect(result.error, contains('reserved')); + } + }); + }); +} diff --git a/test/rider_profile_test.dart b/test/rider_profile_test.dart new file mode 100644 index 0000000..42d398a --- /dev/null +++ b/test/rider_profile_test.dart @@ -0,0 +1,159 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:motorcycle_dash/services/social/rider_profile_models.dart'; +import 'package:motorcycle_dash/services/social/rider_profile_service.dart'; + +void main() { + group('RiderProfile Models', () { + test('PresetAvatar contains valid presets and resolves default', () { + expect(PresetAvatar.availablePresets.isNotEmpty, isTrue); + final first = PresetAvatar.availablePresets.first; + expect(PresetAvatar.byId(first.id).id, equals(first.id)); + expect(PresetAvatar.byId('unknown_nonexistent').id, equals(first.id)); + }); + + test('ProfilePrivacySettings serialization and defaults', () { + const defaults = ProfilePrivacySettings(); + expect(defaults.isSearchableByHandle, isTrue); + expect(defaults.isProfilePublic, isTrue); + expect(defaults.allowFriendRequests, isTrue); + + final json = defaults.toJson(); + final restored = ProfilePrivacySettings.fromJson(json); + expect(restored, equals(defaults)); + + final modified = defaults.copyWith(isProfilePublic: false); + expect(modified.isProfilePublic, isFalse); + expect(modified.isSearchableByHandle, isTrue); + expect(modified.allowFriendRequests, isTrue); + }); + + test('RiderProfile serialization round-trip', () { + final now = DateTime(2026, 9, 3, 12, 0, 0); + final profile = RiderProfile( + uid: 'user_123', + handle: 'apex_pilot', + displayName: 'Apex Pilot', + bio: 'Weekend track and mountain rider.', + region: 'North Georgia Mountains', + avatarPreset: 'helmet_cyan', + avatarUrl: null, + privacySettings: const ProfilePrivacySettings( + isSearchableByHandle: true, + isProfilePublic: false, + allowFriendRequests: true, + ), + createdAt: now, + updatedAt: now, + ); + + final json = profile.toJson(); + expect(json['id'], equals('user_123')); + expect(json['username'], equals('apex_pilot')); + expect(json['displayName'], equals('Apex Pilot')); + expect(json['bio'], equals('Weekend track and mountain rider.')); + expect(json['location'], equals('North Georgia Mountains')); + expect(json['avatarPreset'], equals('helmet_cyan')); + + final restored = RiderProfile.fromJson(json); + expect(restored.uid, equals(profile.uid)); + expect(restored.handle, equals(profile.handle)); + expect(restored.displayName, equals(profile.displayName)); + expect(restored.bio, equals(profile.bio)); + expect(restored.region, equals(profile.region)); + expect(restored.avatarPreset, equals(profile.avatarPreset)); + expect(restored.privacySettings.isProfilePublic, isFalse); + expect(restored.displayHandle, equals('@apex_pilot')); + }); + + test('FriendSummary serialization round-trip', () { + final now = DateTime(2026, 9, 3, 14, 30); + final summary = FriendSummary( + uid: 'user_456', + handle: 'track_demon', + displayName: 'Track Demon', + avatarPreset: 'tach_neon', + friendedAt: now, + ); + + final json = summary.toJson(); + final restored = FriendSummary.fromJson(json); + expect(restored.uid, equals('user_456')); + expect(restored.handle, equals('track_demon')); + expect(restored.displayHandle, equals('@track_demon')); + expect(restored.avatarPreset, equals('tach_neon')); + }); + + test('FriendRequest serialization round-trip', () { + final now = DateTime(2026, 9, 3, 15, 0); + final request = FriendRequest( + requestId: 'req_001', + senderUid: 'sender_01', + senderHandle: 'sender_pilot', + senderDisplayName: 'Sender Pilot', + senderAvatarPreset: 'helmet_amber', + receiverUid: 'receiver_02', + sentAt: now, + ); + + final json = request.toJson(); + final restored = FriendRequest.fromJson(json); + expect(restored.requestId, equals('req_001')); + expect(restored.senderUid, equals('sender_01')); + expect(restored.receiverUid, equals('receiver_02')); + }); + + test('BlockedRider serialization round-trip', () { + final now = DateTime(2026, 9, 3, 16, 0); + final block = BlockedRider( + blockedUid: 'spammer_99', + blockedHandle: 'spammer', + blockedDisplayName: 'Bad Actor', + blockedAt: now, + ); + + final json = block.toJson(); + final restored = BlockedRider.fromJson(json); + expect(restored.blockedUid, equals('spammer_99')); + expect(restored.blockedHandle, equals('spammer')); + }); + }); + + group('RiderProfileService validation and offline fallback', () { + test('rejects invalid handles on saveProfile', () async { + final service = RiderProfileService(); + + expect( + () => service.saveProfile( + uid: 'uid_1', + handle: 'no', // too short + displayName: 'Rider', + ), + throwsA(isA()), + ); + + expect( + () => service.saveProfile( + uid: 'uid_1', + handle: 'admin', // reserved + displayName: 'Admin User', + ), + throwsA(isA()), + ); + }); + + test('creates offline profile when Firestore is unconfigured', () async { + final service = RiderProfileService(); + final profile = await service.saveProfile( + uid: 'uid_local', + handle: 'local_pilot', + displayName: 'Local Pilot', + bio: 'Local solo rider', + ); + + expect(profile.uid, equals('uid_local')); + expect(profile.handle, equals('local_pilot')); + expect(profile.displayName, equals('Local Pilot')); + expect(profile.bio, equals('Local solo rider')); + }); + }); +} diff --git a/test/social_screens_widget_test.dart b/test/social_screens_widget_test.dart new file mode 100644 index 0000000..273ef7e --- /dev/null +++ b/test/social_screens_widget_test.dart @@ -0,0 +1,239 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:motorcycle_dash/features/records/records_service.dart'; +import 'package:motorcycle_dash/features/social/friends_screen.dart'; +import 'package:motorcycle_dash/features/social/rider_profile_screen.dart'; +import 'package:motorcycle_dash/services/auth/auth_providers.dart'; +import 'package:motorcycle_dash/services/auth/auth_service.dart'; +import 'package:motorcycle_dash/services/auth/auth_state.dart'; +import 'package:motorcycle_dash/services/auth/auth_user.dart'; +import 'package:motorcycle_dash/services/social/rider_profile_models.dart'; +import 'package:motorcycle_dash/services/social/social_providers.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class _FakeAuth implements AuthService { + final _states = StreamController.broadcast(); + AuthUser? user; + + _FakeAuth({this.user}); + + @override + Stream get authStateChanges => _states.stream; + + @override + AuthState get currentState => user == null + ? const AuthState.unauthenticated(isFirebaseConfigured: true) + : (user!.isAnonymous + ? AuthState.anonymous(user!, isFirebaseConfigured: true) + : AuthState.authenticated(user!, isFirebaseConfigured: true)); + + @override + AuthUser? get currentUser => user; + + @override + bool get isConfigured => true; + + @override + bool get isEmailVerified => true; + + @override + Future deleteCurrentUser() async {} + + @override + void dispose() => _states.close(); + + @override + Future getIdToken([bool forceRefresh = false]) async => 'token'; + + @override + Future reauthenticate({String? password}) async {} + + @override + Future reloadCurrentUser() async {} + + @override + Future sendEmailVerification() async {} + + @override + Future sendPasswordResetEmail(String email) async {} + + @override + Future signInAnonymously() async {} + + @override + Future signInWithEmailAndPassword(String email, String password) async {} + + @override + Future createUserWithEmailAndPassword(String email, String password, + {String? displayName}) async {} + + @override + Future signInWithGoogle() async {} + + @override + Future signOut() async {} +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + late SharedPreferences prefs; + + setUp(() async { + SharedPreferences.setMockInitialValues({}); + prefs = await SharedPreferences.getInstance(); + }); + + Widget buildTestable(Widget child, List overrides) { + return ProviderScope( + overrides: [ + sharedPreferencesProvider.overrideWithValue(prefs), + ...overrides, + ], + child: MaterialApp(home: child), + ); + } + + group('RiderProfileScreen Widget Tests', () { + testWidgets('shows account required notice when signed out', (tester) async { + final fakeAuth = _FakeAuth(user: null); + + await tester.pumpWidget( + buildTestable( + const RiderProfileScreen(), + [authServiceProvider.overrideWithValue(fakeAuth)], + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('MOTORCLOUD ACCOUNT REQUIRED'), findsOneWidget); + expect(find.text('SIGN IN OR REGISTER'), findsOneWidget); + }); + + testWidgets('renders profile form and privacy controls when authenticated', (tester) async { + final user = const AuthUser( + uid: 'pilot_1', + email: 'pilot@example.com', + displayName: 'Test Pilot', + ); + final fakeAuth = _FakeAuth(user: user); + + final existingProfile = RiderProfile( + uid: 'pilot_1', + handle: 'test_pilot', + displayName: 'Test Pilot', + bio: 'Speed and curves.', + region: 'Tail of the Dragon', + avatarPreset: 'helmet_cyan', + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + ); + + await tester.pumpWidget( + buildTestable( + const RiderProfileScreen(), + [ + authServiceProvider.overrideWithValue(fakeAuth), + myRiderProfileProvider.overrideWith((ref) => Stream.value(existingProfile)), + ], + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Rider Profile'), findsOneWidget); + expect(find.text('@test_pilot'), findsWidgets); + expect(find.text('Test Pilot'), findsWidgets); + expect(find.text('Speed and curves.'), findsWidgets); + expect(find.text('Tail of the Dragon'), findsWidgets); + + // Privacy switches + expect(find.text('PRIVACY CONTROLS'), findsOneWidget); + expect(find.text('Discoverable by Handle'), findsOneWidget); + expect(find.text('Public Profile'), findsOneWidget); + expect(find.text('Allow Friend Requests'), findsOneWidget); + + // Save button + expect(find.text('SAVE PROFILE'), findsOneWidget); + }); + }); + + group('FriendsScreen Widget Tests', () { + testWidgets('shows account required notice when signed out', (tester) async { + final fakeAuth = _FakeAuth(user: null); + + await tester.pumpWidget( + buildTestable( + const FriendsScreen(), + [authServiceProvider.overrideWithValue(fakeAuth)], + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('MOTORCLOUD ACCOUNT REQUIRED'), findsOneWidget); + }); + + testWidgets('renders tabs and empty states when authenticated', (tester) async { + final user = const AuthUser( + uid: 'rider_1', + email: 'rider@example.com', + displayName: 'Rider One', + ); + final fakeAuth = _FakeAuth(user: user); + + await tester.pumpWidget( + buildTestable( + const FriendsScreen(), + [ + authServiceProvider.overrideWithValue(fakeAuth), + myFriendsProvider.overrideWith((ref) => Stream.value([])), + myIncomingRequestsProvider.overrideWith((ref) => Stream.value([])), + myOutgoingRequestsProvider.overrideWith((ref) => Stream.value([])), + ], + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('FRIENDS (0)'), findsOneWidget); + expect(find.text('REQUESTS'), findsOneWidget); + expect(find.text('FIND RIDERS'), findsNWidgets(2)); + expect(find.text('NO FRIENDS ADDED YET'), findsOneWidget); + }); + + testWidgets('displays friend item with action menu', (tester) async { + final user = const AuthUser( + uid: 'rider_1', + email: 'rider@example.com', + displayName: 'Rider One', + ); + final fakeAuth = _FakeAuth(user: user); + + final friendsList = [ + FriendSummary( + uid: 'friend_99', + handle: 'corner_carver', + displayName: 'Corner Carver', + avatarPreset: 'tach_neon', + friendedAt: DateTime.now(), + ), + ]; + + await tester.pumpWidget( + buildTestable( + const FriendsScreen(), + [ + authServiceProvider.overrideWithValue(fakeAuth), + myFriendsProvider.overrideWith((ref) => Stream.value(friendsList)), + myIncomingRequestsProvider.overrideWith((ref) => Stream.value([])), + myOutgoingRequestsProvider.overrideWith((ref) => Stream.value([])), + ], + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('FRIENDS (1)'), findsOneWidget); + expect(find.text('Corner Carver'), findsOneWidget); + expect(find.text('@corner_carver'), findsOneWidget); + }); + }); +}