feat: add rider profiles and friends foundation
This commit is contained in:
parent
80f71698a0
commit
250c37711a
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>[];
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -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'));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -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'));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue