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)
|
return ownsUser(userId)
|
||||||
&& request.resource.data.id == userId
|
&& request.resource.data.id == userId
|
||||||
&& request.resource.data.keys().hasOnly([
|
&& request.resource.data.keys().hasOnly([
|
||||||
'id', 'username', 'displayName', 'profilePhotoUrl', 'bio',
|
'id', 'username', 'displayName', 'profilePhotoUrl', 'avatarPreset', 'bio',
|
||||||
'location', 'createdAt', 'updatedAt', 'privacySettings', 'preferences'
|
'location', 'createdAt', 'updatedAt', 'privacySettings', 'preferences'
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
@ -23,7 +23,7 @@ service cloud.firestore {
|
||||||
&& request.resource.data.id == resource.data.id
|
&& request.resource.data.id == resource.data.id
|
||||||
&& request.resource.data.createdAt == resource.data.createdAt
|
&& request.resource.data.createdAt == resource.data.createdAt
|
||||||
&& request.resource.data.keys().hasOnly([
|
&& request.resource.data.keys().hasOnly([
|
||||||
'id', 'username', 'displayName', 'profilePhotoUrl', 'bio',
|
'id', 'username', 'displayName', 'profilePhotoUrl', 'avatarPreset', 'bio',
|
||||||
'location', 'createdAt', 'updatedAt', 'privacySettings', 'preferences'
|
'location', 'createdAt', 'updatedAt', 'privacySettings', 'preferences'
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
@ -41,9 +41,19 @@ service cloud.firestore {
|
||||||
&& request.resource.data.id == resource.data.id;
|
&& 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} {
|
match /users/{userId} {
|
||||||
allow create: if validUserCreate(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 update: if validUserUpdate(userId);
|
||||||
allow delete: if ownsUser(userId);
|
allow delete: if ownsUser(userId);
|
||||||
|
|
||||||
|
|
@ -78,6 +88,23 @@ service cloud.firestore {
|
||||||
}
|
}
|
||||||
|
|
||||||
match /friends/{friendId} {
|
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);
|
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/oauth_callback_screen.dart';
|
||||||
import '../features/settings/account_screen.dart';
|
import '../features/settings/account_screen.dart';
|
||||||
import '../features/entitlements/pro_screen.dart';
|
import '../features/entitlements/pro_screen.dart';
|
||||||
|
import '../features/social/rider_profile_screen.dart';
|
||||||
|
import '../features/social/friends_screen.dart';
|
||||||
|
|
||||||
// ignore: unused_element
|
// ignore: unused_element
|
||||||
final _rootNavigatorKey = GlobalKey<NavigatorState>();
|
final _rootNavigatorKey = GlobalKey<NavigatorState>();
|
||||||
|
|
@ -127,6 +129,14 @@ final appRouter = GoRouter(
|
||||||
path: 'pro',
|
path: 'pro',
|
||||||
builder: (context, state) => const ProScreen(),
|
builder: (context, state) => const ProScreen(),
|
||||||
),
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: 'profile',
|
||||||
|
builder: (context, state) => const RiderProfileScreen(),
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: 'friends',
|
||||||
|
builder: (context, state) => const FriendsScreen(),
|
||||||
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: 'diagnostics',
|
path: 'diagnostics',
|
||||||
builder: (context, state) => const DiagnosticsScreen(),
|
builder: (context, state) => const DiagnosticsScreen(),
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import '../../core/theme/evolution_theme_config.dart';
|
import '../../core/theme/evolution_theme_config.dart';
|
||||||
import '../../database/database.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(
|
SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
child: OutlinedButton.icon(
|
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('trips'));
|
||||||
await _deleteCollection(userRef.collection('events'));
|
await _deleteCollection(userRef.collection('events'));
|
||||||
await _deleteCollection(userRef.collection('friends'));
|
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.
|
// Delete the profile last so retries can still discover the account scope.
|
||||||
final profileBatch = firestore.batch()..delete(userRef);
|
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