fix(clubs,routes): owner no longer shown "Request to Join"; Route Planner starts at rider's area (#89)

* fix(clubs): respect existing membership in join presentation (#72)

* fix(routes): start planner from cached rider location (#76)

---------

Co-authored-by: Developer <dev@example.com>
This commit is contained in:
Billie Ponder 2026-10-26 07:04:37 -04:00 committed by GitHub
parent 63df8f9a50
commit 8fc267d5fd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 312 additions and 10 deletions

View File

@ -7,6 +7,7 @@ import '../../core/responsive/viewport_size.dart';
import '../../core/theme/theme_extensions.dart'; import '../../core/theme/theme_extensions.dart';
import '../../services/auth/auth_providers.dart'; import '../../services/auth/auth_providers.dart';
import '../../services/map/map_provider_interface.dart'; import '../../services/map/map_provider_interface.dart';
import '../../services/map/map_providers.dart';
import '../../services/map/unified_map_view.dart'; import '../../services/map/unified_map_view.dart';
import '../../services/routes/navigation_handoff.dart'; import '../../services/routes/navigation_handoff.dart';
import '../../services/routes/route_models.dart'; import '../../services/routes/route_models.dart';
@ -43,6 +44,8 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
void initState() { void initState() {
super.initState(); super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
ref.read(mapFollowStateProvider.notifier).recenter();
_loadInitialData(); _loadInitialData();
}); });
} }
@ -593,12 +596,16 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
const maxAllowed = RouteService.maxTechnicalStops; const maxAllowed = RouteService.maxTechnicalStops;
final isAtLimit = state.stops.length >= maxAllowed; final isAtLimit = state.stops.length >= maxAllowed;
// Center map on road geometry, first stop, or default final lastKnownLocation = ref.watch(routePlannerLocationProvider).valueOrNull;
// Route context takes priority over cached rider location and the US overview.
final mapCenter = state.geometry.isNotEmpty final mapCenter = state.geometry.isNotEmpty
? state.geometry.first ? state.geometry.first
: (state.stops.isNotEmpty : (state.stops.isNotEmpty
? AppLatLng(state.stops.first.latitude, state.stops.first.longitude) ? AppLatLng(state.stops.first.latitude, state.stops.first.longitude)
: const AppLatLng(37.7749, -122.4194)); : lastKnownLocation ?? const AppLatLng(39.5, -98.35));
final mapZoom = state.geometry.isNotEmpty || state.stops.isNotEmpty || lastKnownLocation != null
? 12.0 : 3.0;
// Build markers // Build markers
final markers = state.stops.map((stop) { final markers = state.stops.map((stop) {
@ -723,6 +730,7 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
maxAllowed, maxAllowed,
isAtLimit, isAtLimit,
mapCenter, mapCenter,
mapZoom,
markers, markers,
polylines, polylines,
isNarrow, isNarrow,
@ -737,6 +745,7 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
maxAllowed, maxAllowed,
isAtLimit, isAtLimit,
mapCenter, mapCenter,
mapZoom,
markers, markers,
polylines, polylines,
isNarrow, isNarrow,
@ -754,6 +763,7 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
int maxAllowed, int maxAllowed,
bool isAtLimit, bool isAtLimit,
AppLatLng mapCenter, AppLatLng mapCenter,
double mapZoom,
List<AppMapMarker> markers, List<AppMapMarker> markers,
List<AppPolyline> polylines, List<AppPolyline> polylines,
bool isNarrow, bool isNarrow,
@ -779,7 +789,7 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
UnifiedMapView( UnifiedMapView(
key: const ValueKey('route_planner_map_expanded'), key: const ValueKey('route_planner_map_expanded'),
center: mapCenter, center: mapCenter,
zoom: 12.0, zoom: mapZoom,
markers: markers, markers: markers,
polylines: polylines, polylines: polylines,
), ),
@ -873,6 +883,7 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
int maxAllowed, int maxAllowed,
bool isAtLimit, bool isAtLimit,
AppLatLng mapCenter, AppLatLng mapCenter,
double mapZoom,
List<AppMapMarker> markers, List<AppMapMarker> markers,
List<AppPolyline> polylines, List<AppPolyline> polylines,
bool isNarrow, bool isNarrow,
@ -978,7 +989,7 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
UnifiedMapView( UnifiedMapView(
key: const ValueKey('route_planner_map_compact'), key: const ValueKey('route_planner_map_compact'),
center: mapCenter, center: mapCenter,
zoom: 12.0, zoom: mapZoom,
markers: markers, markers: markers,
polylines: polylines, polylines: polylines,
), ),

View File

@ -119,9 +119,9 @@ class ClubMembershipPresentation {
); );
} }
// Authenticated user with an active membership // Root ownership remains authoritative when the member record is missing or stale.
if (member != null && member.status == ClubMemberStatus.active) { if (club.ownerUid == currentUserId || member?.isActive == true) {
final isOwner = member.role == ClubRole.owner || club.ownerUid == currentUserId; final isOwner = member?.role == ClubRole.owner || club.ownerUid == currentUserId;
if (isOwner) { if (isOwner) {
return const ClubMembershipPresentation( return const ClubMembershipPresentation(
state: ClubMembershipPresentationState.activeOwner, state: ClubMembershipPresentationState.activeOwner,
@ -135,7 +135,7 @@ class ClubMembershipPresentation {
); );
} }
if (member.role == ClubRole.admin) { if (member?.role == ClubRole.admin) {
return const ClubMembershipPresentation( return const ClubMembershipPresentation(
state: ClubMembershipPresentationState.activeAdmin, state: ClubMembershipPresentationState.activeAdmin,
statusBadge: 'ADMIN', statusBadge: 'ADMIN',

View File

@ -129,6 +129,7 @@ class ClubCard extends StatelessWidget {
), ),
// Admission policy badge // Admission policy badge
if (role == null)
_buildAdmissionBadge(context, club.admissionPolicy, colors), _buildAdmissionBadge(context, club.admissionPolicy, colors),
// Region badge if available // Region badge if available

View File

@ -1,4 +1,5 @@
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:geolocator/geolocator.dart';
import '../auth/auth_providers.dart'; import '../auth/auth_providers.dart';
import '../map/map_provider_interface.dart'; import '../map/map_provider_interface.dart';
import 'aws_location_route_provider.dart'; import 'aws_location_route_provider.dart';
@ -7,6 +8,19 @@ import 'route_models.dart';
import 'route_provider.dart'; import 'route_provider.dart';
import 'route_service.dart'; import 'route_service.dart';
/// One cached location read per planner visit; never starts GPS or asks permission.
final routePlannerLocationProvider = FutureProvider.autoDispose<AppLatLng?>((ref) async {
try {
final position = await Geolocator.getLastKnownPosition();
if (position == null || !RoutePlannerNotifier.isValidCoordinate(position.latitude, position.longitude)) {
return null;
}
return AppLatLng(position.latitude, position.longitude);
} catch (_) {
return null;
}
});
/// Provider for RouteService. /// Provider for RouteService.
final routeServiceProvider = Provider<RouteService>((ref) { final routeServiceProvider = Provider<RouteService>((ref) {
return RouteService(); return RouteService();

View File

@ -43,7 +43,7 @@ void main() {
expect(find.text('@pacific-coast-riders'), findsOneWidget); expect(find.text('@pacific-coast-riders'), findsOneWidget);
expect(find.text('Cruising the Pacific Coast Highway together.'), findsOneWidget); expect(find.text('Cruising the Pacific Coast Highway together.'), findsOneWidget);
expect(find.text('42 members'), findsOneWidget); expect(find.text('42 members'), findsOneWidget);
expect(find.text('Request to Join'), findsOneWidget); expect(find.text('Request to Join'), findsNothing);
expect(find.text('NorCal'), findsOneWidget); expect(find.text('NorCal'), findsOneWidget);
expect(find.text('Sport Touring'), findsOneWidget); expect(find.text('Sport Touring'), findsOneWidget);
expect(find.text('OWNER'), findsOneWidget); expect(find.text('OWNER'), findsOneWidget);
@ -72,6 +72,24 @@ void main() {
expect(find.byIcon(Icons.lock_open_outlined), findsNothing); expect(find.byIcon(Icons.lock_open_outlined), findsNothing);
}); });
for (final role in ClubRole.values) {
testWidgets('active ${role.name} has no admission prompt', (tester) async {
await tester.pumpWidget(MaterialApp(
home: Scaffold(body: ClubCard(club: sampleClub, role: role)),
));
expect(find.text('Request to Join'), findsNothing);
});
}
testWidgets('non-member retains request admission prompt', (tester) async {
await tester.pumpWidget(MaterialApp(
home: Scaffold(body: ClubCard(club: sampleClub)),
));
expect(find.text('Request to Join'), findsOneWidget);
});
testWidgets('suppresses role pill for standard members and shows for admin/owner', (tester) async { testWidgets('suppresses role pill for standard members and shows for admin/owner', (tester) async {
// Test member role (should NOT be rendered) // Test member role (should NOT be rendered)
await tester.pumpWidget( await tester.pumpWidget(

View File

@ -31,6 +31,53 @@ void main() {
expect(p.primaryAction, isNull); expect(p.primaryAction, isNull);
}); });
test('root owner cannot join even with a missing or stale member record', () {
for (final status in [null, ...ClubMemberStatus.values]) {
final p = ClubMembershipPresentation.resolve(
club: baseClub.copyWith(admissionPolicy: ClubAdmissionPolicy.request),
member: status == null ? null : ClubMember(
uid: 'owner_uid',
clubId: baseClub.id,
role: ClubRole.member,
status: status,
updatedAt: DateTime(2026, 1, 1),
),
currentUserId: 'owner_uid',
isAuthenticated: true,
);
expect(p.state, ClubMembershipPresentationState.activeOwner);
expect(p.primaryAction, isNull);
expect(p.secondaryAction, isNull);
expect(p.canLeave, isFalse);
}
});
test('request admission actions respect active, pending and outsider states', () {
for (final role in ClubRole.values) {
for (final status in [null, ClubMemberStatus.active, ClubMemberStatus.requested]) {
final p = ClubMembershipPresentation.resolve(
club: baseClub.copyWith(admissionPolicy: ClubAdmissionPolicy.request),
member: status == null ? null : ClubMember(
uid: 'rider_1',
clubId: baseClub.id,
role: role,
status: status,
updatedAt: DateTime(2026, 1, 1),
),
currentUserId: 'rider_1',
isAuthenticated: true,
);
expect(p.primaryAction, status == null
? ClubMembershipAction.requestToJoin
: status == ClubMemberStatus.requested
? ClubMembershipAction.withdrawRequest
: role == ClubRole.owner ? null : ClubMembershipAction.leaveClub);
}
}
});
test('resolves archived state when club.isArchived is true', () { test('resolves archived state when club.isArchived is true', () {
final archivedClub = baseClub.copyWith(archivedAt: DateTime(2026, 2, 1)); final archivedClub = baseClub.copyWith(archivedAt: DateTime(2026, 2, 1));
final p = ClubMembershipPresentation.resolve( final p = ClubMembershipPresentation.resolve(

View File

@ -0,0 +1,211 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:motorcycle_dash/features/routes/route_planner_screen.dart';
import 'package:motorcycle_dash/services/auth/auth_providers.dart';
import 'package:motorcycle_dash/services/auth/auth_user.dart';
import 'package:motorcycle_dash/services/map/map_provider_interface.dart';
import 'package:motorcycle_dash/services/map/map_providers.dart';
import 'package:motorcycle_dash/services/map/open_street_map_provider.dart';
import 'package:motorcycle_dash/services/map/unified_map_view.dart';
import 'package:motorcycle_dash/services/routes/route_providers.dart';
class FakeMapProvider extends Fake implements OpenStreetMapProvider {
@override
Widget buildMapView({
required BuildContext context,
required AppLatLng center,
required double zoom,
double bearing = 0.0,
required List<AppMapMarker> markers,
required List<AppPolyline> polylines,
required bool isFollowMode,
required MapConfig config,
required ValueChanged<AppLatLng>? onPositionChanged,
required VoidCallback? onUserInteraction,
}) => const SizedBox.expand();
}
void main() {
const riderPosition = AppLatLng(33.749, -84.388);
const overview = AppLatLng(39.5, -98.35);
Future<ProviderContainer> openPlanner(
WidgetTester tester,
Future<AppLatLng?> location, {
bool realMap = false,
bool initiallyFollowing = true,
}) async {
await tester.pumpWidget(
ProviderScope(
overrides: [
currentUserProvider.overrideWith(
(ref) => const AuthUser(uid: 'rider'),
),
routePlannerLocationProvider.overrideWith((ref) => location),
mapFollowStateProvider.overrideWith(
(ref) => MapFollowNotifier()..setFollow(initiallyFollowing),
),
if (!realMap)
openStreetMapProviderInstance.overrideWithValue(FakeMapProvider()),
],
child: const MaterialApp(home: RoutePlannerScreen()),
),
);
await tester.pumpAndSettle();
return ProviderScope.containerOf(
tester.element(find.byType(RoutePlannerScreen)),
);
}
UnifiedMapView map(WidgetTester tester) =>
tester.widget(find.byType(UnifiedMapView));
for (final size in [const Size(360, 640), const Size(800, 360)]) {
testWidgets(
'known rider position centers the planner at $size without adding a stop',
(tester) async {
await tester.binding.setSurfaceSize(size);
addTearDown(() => tester.binding.setSurfaceSize(null));
final container = await openPlanner(
tester,
Future.value(riderPosition),
);
expect(map(tester).center, riderPosition);
expect(map(tester).zoom, 12);
expect(container.read(routePlannerNotifierProvider).stops, isEmpty);
},
);
}
testWidgets('pending or absent location shows an overview without blocking', (
tester,
) async {
final location = Completer<AppLatLng?>();
await openPlanner(tester, location.future);
expect(find.text('ROUTE PLANNER'), findsOneWidget);
expect(map(tester).center, overview);
expect(map(tester).zoom, 3);
location.complete(null);
await tester.pumpAndSettle();
expect(map(tester).center, overview);
expect(map(tester).zoom, 3);
});
testWidgets('cached position arriving later updates the overview', (
tester,
) async {
final location = Completer<AppLatLng?>();
await openPlanner(tester, location.future);
expect(map(tester).zoom, 3);
location.complete(riderPosition);
await tester.pumpAndSettle();
expect(map(tester).center, riderPosition);
expect(map(tester).zoom, 12);
});
testWidgets(
'previously panned map does not prevent initial cached centering',
(tester) async {
final location = Completer<AppLatLng?>();
await openPlanner(
tester,
location.future,
realMap: true,
initiallyFollowing: false,
);
final controller = tester
.widget<FlutterMap>(find.byType(FlutterMap))
.mapController!;
expect(controller.camera.center.latitude, overview.latitude);
location.complete(riderPosition);
await tester.pumpAndSettle();
expect(
controller.camera.center.latitude,
closeTo(riderPosition.latitude, 0.00001),
);
expect(
controller.camera.center.longitude,
closeTo(riderPosition.longitude, 0.00001),
);
expect(controller.camera.zoom, 12);
},
);
testWidgets('route stops take priority over a late cached rider position', (
tester,
) async {
final location = Completer<AppLatLng?>();
final container = await openPlanner(tester, location.future);
container
.read(routePlannerNotifierProvider.notifier)
.addStop(displayName: 'Route start', latitude: 35.6, longitude: -83.5);
await tester.pumpAndSettle();
expect(map(tester).center, const AppLatLng(35.6, -83.5));
expect(map(tester).zoom, 12);
location.complete(riderPosition);
await tester.pumpAndSettle();
expect(map(tester).center, const AppLatLng(35.6, -83.5));
expect(map(tester).zoom, 12);
});
test(
'location source only reads cache and tolerates unavailable GPS',
() async {
const channel = MethodChannel('flutter.baseflow.com/geolocator');
final calls = <String>[];
final messenger =
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
messenger.setMockMethodCallHandler(channel, (call) async {
calls.add(call.method);
throw PlatformException(code: 'PERMISSION_DENIED');
});
addTearDown(() => messenger.setMockMethodCallHandler(channel, null));
final container = ProviderContainer();
addTearDown(container.dispose);
expect(await container.read(routePlannerLocationProvider.future), isNull);
expect(calls, ['getLastKnownPosition']);
},
);
for (final position in [riderPosition, null, const AppLatLng(0, 0)]) {
test(
'cached location source validates $position without starting GPS',
() async {
const channel = MethodChannel('flutter.baseflow.com/geolocator');
final calls = <String>[];
final messenger =
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
messenger.setMockMethodCallHandler(channel, (call) async {
calls.add(call.method);
return position == null
? null
: {
'latitude': position.latitude,
'longitude': position.longitude,
};
});
addTearDown(() => messenger.setMockMethodCallHandler(channel, null));
final container = ProviderContainer();
addTearDown(container.dispose);
expect(
await container.read(routePlannerLocationProvider.future),
position == riderPosition ? riderPosition : null,
);
expect(calls, ['getLastKnownPosition']);
},
);
}
}