feat: add rider profiles and friends foundation

This commit is contained in:
Developer 2026-09-03 19:37:26 -04:00
parent 80f71698a0
commit 250c37711a
18 changed files with 3550 additions and 4 deletions

View File

@ -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);
}
}

View File

@ -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<NavigatorState>();
@ -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(),

View File

@ -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<AccountScreen> {
),
),
],
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(

View File

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

View File

@ -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<RiderProfileScreen> createState() => _RiderProfileScreenState();
}
class _RiderProfileScreenState extends ConsumerState<RiderProfileScreen> {
final _formKey = GlobalKey<FormState>();
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<void> _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),
),
),
),
],
),
),
);
},
),
);
}
}

View File

@ -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;
}
}

View File

@ -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<String> 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(),
),
],
);
}
}

View File

@ -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);

View File

@ -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<FriendshipStatus> 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<void> 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<void> 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<void> 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<void> 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<void> 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<void> 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<void> 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<List<FriendSummary>> 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 <FriendSummary>[];
});
}
/// Stream of incoming friend requests.
Stream<List<FriendRequest>> 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 <FriendRequest>[];
});
}
/// Stream of outgoing friend requests.
Stream<List<FriendRequest>> 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 <FriendRequest>[];
});
}
/// Stream of blocked riders.
Stream<List<BlockedRider>> 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 <BlockedRider>[];
});
}
}

View File

@ -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<String> 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);
}
}

View File

@ -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<PresetAvatar> 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<String, dynamic> toJson() => {
'isSearchableByHandle': isSearchableByHandle,
'isProfilePublic': isProfilePublic,
'allowFriendRequests': allowFriendRequests,
};
factory ProfilePrivacySettings.fromJson(Map<String, dynamic>? 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<String, dynamic> 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<String, dynamic> 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<String, dynamic>?,
),
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<String, dynamic> toJson() => {
'uid': uid,
'handle': handle,
'displayName': displayName,
'avatarPreset': avatarPreset,
'avatarUrl': avatarUrl,
'friendedAt': friendedAt.toIso8601String(),
};
factory FriendSummary.fromJson(Map<String, dynamic> 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<String, dynamic> toJson() => {
'requestId': requestId,
'senderUid': senderUid,
'senderHandle': senderHandle,
'senderDisplayName': senderDisplayName,
'senderAvatarPreset': senderAvatarPreset,
'senderAvatarUrl': senderAvatarUrl,
'receiverUid': receiverUid,
'sentAt': sentAt.toIso8601String(),
};
factory FriendRequest.fromJson(Map<String, dynamic> 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<String, dynamic> toJson() => {
'blockedUid': blockedUid,
'blockedHandle': blockedHandle,
'blockedDisplayName': blockedDisplayName,
'blockedAt': blockedAt.toIso8601String(),
};
factory BlockedRider.fromJson(Map<String, dynamic> 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';
}

View File

@ -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<RiderProfile?> 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<RiderProfile?> 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<bool> 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<RiderProfile> 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<void> 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;
}
}
}

View File

@ -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<RiderSearchResult?> 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;
}
}
}

View File

@ -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<RiderProfileService>((ref) {
return RiderProfileService();
});
/// Provider for FriendshipService.
final friendshipServiceProvider = Provider<FriendshipService>((ref) {
return FriendshipService();
});
/// Provider for RiderSearchService.
final riderSearchServiceProvider = Provider<RiderSearchService>((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<RiderProfile?>((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<List<FriendSummary>>((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<List<FriendRequest>>((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<List<FriendRequest>>((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<List<BlockedRider>>((ref) {
final user = ref.watch(currentUserProvider);
if (user == null || user.isAnonymous) {
return Stream.value([]);
}
final service = ref.watch(friendshipServiceProvider);
return service.blockedRidersStream(user.uid);
});

View File

@ -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<ArgumentError>()),
);
});
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);
});
});
}

View File

@ -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'));
}
});
});
}

View File

@ -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<ArgumentError>()),
);
expect(
() => service.saveProfile(
uid: 'uid_1',
handle: 'admin', // reserved
displayName: 'Admin User',
),
throwsA(isA<ArgumentError>()),
);
});
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'));
});
});
}

View File

@ -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<AuthState>.broadcast();
AuthUser? user;
_FakeAuth({this.user});
@override
Stream<AuthState> 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<void> deleteCurrentUser() async {}
@override
void dispose() => _states.close();
@override
Future<String?> getIdToken([bool forceRefresh = false]) async => 'token';
@override
Future<void> reauthenticate({String? password}) async {}
@override
Future<void> reloadCurrentUser() async {}
@override
Future<void> sendEmailVerification() async {}
@override
Future<void> sendPasswordResetEmail(String email) async {}
@override
Future<void> signInAnonymously() async {}
@override
Future<void> signInWithEmailAndPassword(String email, String password) async {}
@override
Future<void> createUserWithEmailAndPassword(String email, String password,
{String? displayName}) async {}
@override
Future<void> signInWithGoogle() async {}
@override
Future<void> signOut() async {}
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late SharedPreferences prefs;
setUp(() async {
SharedPreferences.setMockInitialValues({});
prefs = await SharedPreferences.getInstance();
});
Widget buildTestable(Widget child, List<Override> 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);
});
});
}