From cf7e0b0a3bb183ab2243bfa039a20f3dcdc0138a Mon Sep 17 00:00:00 2001 From: Selina Ge Date: Sun, 12 Apr 2026 10:45:01 -0400 Subject: [PATCH 01/22] new home page overlays --- game/assets/icons/burger.svg | 3 + game/assets/icons/burger_pin_green.svg | 19 + game/assets/icons/burger_pin_red.svg | 19 + game/assets/icons/burger_pin_yellow.svg | 19 + game/assets/icons/fund.svg | 3 + game/assets/icons/fund_pin_green.svg | 19 + game/assets/icons/fund_pin_red.svg | 19 + game/assets/icons/fund_pin_yellow.svg | 19 + game/assets/icons/journey.svg | 4 + game/assets/icons/mic.svg | 3 + game/assets/icons/mic_pin_green.svg | 19 + game/assets/icons/mic_pin_red.svg | 19 + game/assets/icons/mic_pin_yellow.svg | 19 + game/assets/icons/selected_pin_green.svg | 13 + game/assets/icons/selected_pin_red.svg | 13 + game/assets/icons/selected_pin_yellow.svg | 13 + game/assets/icons/speaker.svg | 3 + game/assets/icons/speaker_pin_green.svg | 24 + game/assets/icons/speaker_pin_red.svg | 24 + game/assets/icons/speaker_pin_yellow.svg | 24 + game/ios/Podfile.lock | 6 + game/lib/navigation_page/bottom_navbar.dart | 19 + game/lib/navigation_page/home_map_page.dart | 1172 +++++++++++++++++++ game/pubspec.lock | 32 + game/pubspec.yaml | 1 + 25 files changed, 1528 insertions(+) create mode 100644 game/assets/icons/burger.svg create mode 100644 game/assets/icons/burger_pin_green.svg create mode 100644 game/assets/icons/burger_pin_red.svg create mode 100644 game/assets/icons/burger_pin_yellow.svg create mode 100644 game/assets/icons/fund.svg create mode 100644 game/assets/icons/fund_pin_green.svg create mode 100644 game/assets/icons/fund_pin_red.svg create mode 100644 game/assets/icons/fund_pin_yellow.svg create mode 100644 game/assets/icons/journey.svg create mode 100644 game/assets/icons/mic.svg create mode 100644 game/assets/icons/mic_pin_green.svg create mode 100644 game/assets/icons/mic_pin_red.svg create mode 100644 game/assets/icons/mic_pin_yellow.svg create mode 100644 game/assets/icons/selected_pin_green.svg create mode 100644 game/assets/icons/selected_pin_red.svg create mode 100644 game/assets/icons/selected_pin_yellow.svg create mode 100644 game/assets/icons/speaker.svg create mode 100644 game/assets/icons/speaker_pin_green.svg create mode 100644 game/assets/icons/speaker_pin_red.svg create mode 100644 game/assets/icons/speaker_pin_yellow.svg create mode 100644 game/lib/navigation_page/home_map_page.dart diff --git a/game/assets/icons/burger.svg b/game/assets/icons/burger.svg new file mode 100644 index 00000000..2d25d521 --- /dev/null +++ b/game/assets/icons/burger.svg @@ -0,0 +1,3 @@ + + + diff --git a/game/assets/icons/burger_pin_green.svg b/game/assets/icons/burger_pin_green.svg new file mode 100644 index 00000000..14bfb2e8 --- /dev/null +++ b/game/assets/icons/burger_pin_green.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/game/assets/icons/burger_pin_red.svg b/game/assets/icons/burger_pin_red.svg new file mode 100644 index 00000000..623a4810 --- /dev/null +++ b/game/assets/icons/burger_pin_red.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/game/assets/icons/burger_pin_yellow.svg b/game/assets/icons/burger_pin_yellow.svg new file mode 100644 index 00000000..bc30b52b --- /dev/null +++ b/game/assets/icons/burger_pin_yellow.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/game/assets/icons/fund.svg b/game/assets/icons/fund.svg new file mode 100644 index 00000000..a95156b2 --- /dev/null +++ b/game/assets/icons/fund.svg @@ -0,0 +1,3 @@ + + + diff --git a/game/assets/icons/fund_pin_green.svg b/game/assets/icons/fund_pin_green.svg new file mode 100644 index 00000000..3dd26af3 --- /dev/null +++ b/game/assets/icons/fund_pin_green.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/game/assets/icons/fund_pin_red.svg b/game/assets/icons/fund_pin_red.svg new file mode 100644 index 00000000..78232d8a --- /dev/null +++ b/game/assets/icons/fund_pin_red.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/game/assets/icons/fund_pin_yellow.svg b/game/assets/icons/fund_pin_yellow.svg new file mode 100644 index 00000000..0129c460 --- /dev/null +++ b/game/assets/icons/fund_pin_yellow.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/game/assets/icons/journey.svg b/game/assets/icons/journey.svg new file mode 100644 index 00000000..21bfa972 --- /dev/null +++ b/game/assets/icons/journey.svg @@ -0,0 +1,4 @@ + + + + diff --git a/game/assets/icons/mic.svg b/game/assets/icons/mic.svg new file mode 100644 index 00000000..5324fe85 --- /dev/null +++ b/game/assets/icons/mic.svg @@ -0,0 +1,3 @@ + + + diff --git a/game/assets/icons/mic_pin_green.svg b/game/assets/icons/mic_pin_green.svg new file mode 100644 index 00000000..c638aa6d --- /dev/null +++ b/game/assets/icons/mic_pin_green.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/game/assets/icons/mic_pin_red.svg b/game/assets/icons/mic_pin_red.svg new file mode 100644 index 00000000..c516ee09 --- /dev/null +++ b/game/assets/icons/mic_pin_red.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/game/assets/icons/mic_pin_yellow.svg b/game/assets/icons/mic_pin_yellow.svg new file mode 100644 index 00000000..7aafd75e --- /dev/null +++ b/game/assets/icons/mic_pin_yellow.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/game/assets/icons/selected_pin_green.svg b/game/assets/icons/selected_pin_green.svg new file mode 100644 index 00000000..9d6713c3 --- /dev/null +++ b/game/assets/icons/selected_pin_green.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/game/assets/icons/selected_pin_red.svg b/game/assets/icons/selected_pin_red.svg new file mode 100644 index 00000000..23ed34bf --- /dev/null +++ b/game/assets/icons/selected_pin_red.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/game/assets/icons/selected_pin_yellow.svg b/game/assets/icons/selected_pin_yellow.svg new file mode 100644 index 00000000..0f92a5c4 --- /dev/null +++ b/game/assets/icons/selected_pin_yellow.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/game/assets/icons/speaker.svg b/game/assets/icons/speaker.svg new file mode 100644 index 00000000..20decd46 --- /dev/null +++ b/game/assets/icons/speaker.svg @@ -0,0 +1,3 @@ + + + diff --git a/game/assets/icons/speaker_pin_green.svg b/game/assets/icons/speaker_pin_green.svg new file mode 100644 index 00000000..2fd28759 --- /dev/null +++ b/game/assets/icons/speaker_pin_green.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/game/assets/icons/speaker_pin_red.svg b/game/assets/icons/speaker_pin_red.svg new file mode 100644 index 00000000..92933f14 --- /dev/null +++ b/game/assets/icons/speaker_pin_red.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/game/assets/icons/speaker_pin_yellow.svg b/game/assets/icons/speaker_pin_yellow.svg new file mode 100644 index 00000000..4ca65a9b --- /dev/null +++ b/game/assets/icons/speaker_pin_yellow.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/game/ios/Podfile.lock b/game/ios/Podfile.lock index 76700fce..30378463 100644 --- a/game/ios/Podfile.lock +++ b/game/ios/Podfile.lock @@ -172,6 +172,8 @@ PODS: - FlutterMacOS - permission_handler_apple (9.3.0): - Flutter + - pointer_interceptor_ios (0.0.1): + - Flutter - PromisesObjC (2.4.0) - shared_preferences_foundation (0.0.1): - Flutter @@ -202,6 +204,7 @@ DEPENDENCIES: - location (from `.symlinks/plugins/location/ios`) - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) + - pointer_interceptor_ios (from `.symlinks/plugins/pointer_interceptor_ios/ios`) - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) - sign_in_with_apple (from `.symlinks/plugins/sign_in_with_apple/ios`) - sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`) @@ -259,6 +262,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/path_provider_foundation/darwin" permission_handler_apple: :path: ".symlinks/plugins/permission_handler_apple/ios" + pointer_interceptor_ios: + :path: ".symlinks/plugins/pointer_interceptor_ios/ios" shared_preferences_foundation: :path: ".symlinks/plugins/shared_preferences_foundation/darwin" sign_in_with_apple: @@ -301,6 +306,7 @@ SPEC CHECKSUMS: nanopb: fad817b59e0457d11a5dfbde799381cd727c1275 path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d + pointer_interceptor_ios: da06a662d5bfd329602b45b2ab41bc0fb5fdb0f0 PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7 sign_in_with_apple: c5dcc141574c8c54d5ac99dd2163c0c72ad22418 diff --git a/game/lib/navigation_page/bottom_navbar.dart b/game/lib/navigation_page/bottom_navbar.dart index 89f7cc74..a5b34a4d 100644 --- a/game/lib/navigation_page/bottom_navbar.dart +++ b/game/lib/navigation_page/bottom_navbar.dart @@ -14,6 +14,7 @@ import 'package:game/widgets/bear_mascot_message.dart'; import 'package:game/utils/utility_functions.dart'; import 'package:showcaseview/showcaseview.dart'; import 'search_filter_home.dart'; +import 'package:game/navigation_page/home_map_page.dart'; /** @@ -64,6 +65,7 @@ class _BottomNavBarState extends State { fontWeight: FontWeight.bold, ); static List _widgetOptions = [ + HomeMapPage(), SearchFilterBar(), GlobalLeaderboardWidget(), ProfilePage(), @@ -386,6 +388,8 @@ class _BottomNavBarState extends State { ), ), bottomNavigationBar: BottomNavigationBar( + type: BottomNavigationBarType.fixed, + showUnselectedLabels: true, items: [ BottomNavigationBarItem( icon: SvgPicture.asset( @@ -398,6 +402,21 @@ class _BottomNavBarState extends State { ), label: 'Home', ), + BottomNavigationBarItem( + icon: SvgPicture.asset( + "assets/icons/journey.svg", + theme: SvgTheme( + currentColor: Colors.grey, + ), + ), + activeIcon: SvgPicture.asset( + "assets/icons/journey.svg", + theme: const SvgTheme( + currentColor: Colors.black, + ), + ), + label: 'Journey', + ), _buildLeaderboardTab(onboarding, screenWidth, screenHeight), _buildProfileTab(onboarding, screenWidth, screenHeight), ], diff --git a/game/lib/navigation_page/home_map_page.dart b/game/lib/navigation_page/home_map_page.dart new file mode 100644 index 00000000..2369ade2 --- /dev/null +++ b/game/lib/navigation_page/home_map_page.dart @@ -0,0 +1,1172 @@ +import 'dart:async'; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:google_maps_flutter/google_maps_flutter.dart'; +import 'package:pointer_interceptor/pointer_interceptor.dart'; +import 'package:game/api/geopoint.dart'; +import 'package:game/constants/constants.dart'; +import 'package:geolocator/geolocator.dart'; +import 'package:flutter_compass/flutter_compass.dart'; + +Future _bitmapDescriptorFromSvgAsset( + BuildContext context, + String assetPath, { + double width = 50, + double height = 53, +}) async { + final dpr = MediaQuery.devicePixelRatioOf(context); + final loader = SvgAssetLoader(assetPath); + final PictureInfo info = await vg.loadPicture(loader, context); + ui.Picture? composed; + ui.Image? image; + try { + final src = info.size; + if (src.width <= 0 || src.height <= 0) { + throw StateError( + '_bitmapDescriptorFromSvgAsset: invalid PictureInfo.size'); + } + + final outW = (width * dpr).round().clamp(1, 4096); + final outH = (height * dpr).round().clamp(1, 4096); + + final recorder = ui.PictureRecorder(); + final canvas = ui.Canvas(recorder); + canvas.scale(outW / src.width, outH / src.height); + canvas.drawPicture(info.picture); + composed = recorder.endRecording(); + + image = await composed.toImage(outW, outH); + final byteData = await image.toByteData(format: ui.ImageByteFormat.png); + if (byteData == null) { + throw StateError( + '_bitmapDescriptorFromSvgAsset: toByteData returned null'); + } + return BitmapDescriptor.bytes( + byteData.buffer.asUint8List(), + width: width, + height: height, + bitmapScaling: MapBitmapScaling.auto, + ); + } finally { + image?.dispose(); + composed?.dispose(); + info.picture.dispose(); + } +} + +enum EventPinState { + later, + soon, + now, +} + +class _ExampleMapPin { + final String id; + final LatLng position; + final int categoryIndex; + final EventPinState state; + + const _ExampleMapPin({ + required this.id, + required this.position, + required this.categoryIndex, + required this.state, + }); +} + +String _pinStateColorName(EventPinState state) => switch (state) { + EventPinState.later => 'green', + EventPinState.soon => 'yellow', + EventPinState.now => 'red', + }; + +String _pinAssetPath(int categoryIndex, EventPinState state) { + final color = _pinStateColorName(state); + final base = switch (categoryIndex) { + 0 => 'burger_pin', + 1 => 'fund_pin', + 2 => 'mic_pin', + 3 => 'speaker_pin', + _ => 'burger_pin', + }; + return 'assets/icons/${base}_$color.svg'; +} + +String _pinIconKey(int categoryIndex, EventPinState state) => + '${categoryIndex}_${state.name}'; + +String _selectedPinAssetPath(EventPinState state) => + 'assets/icons/selected_pin_${_pinStateColorName(state)}.svg'; + +String _selectedPinIconKey(EventPinState state) => 'selected_${state.name}'; + +const Map _kPinSvgViewBox = { + 'burger_pin_green.svg': ui.Size(62, 58), + 'burger_pin_yellow.svg': ui.Size(66, 58), + 'burger_pin_red.svg': ui.Size(62, 58), + 'fund_pin_green.svg': ui.Size(62, 58), + 'fund_pin_yellow.svg': ui.Size(66, 58), + 'fund_pin_red.svg': ui.Size(66, 58), + 'mic_pin_green.svg': ui.Size(66, 58), + 'mic_pin_yellow.svg': ui.Size(66, 58), + 'mic_pin_red.svg': ui.Size(66, 58), + 'speaker_pin_green.svg': ui.Size(62, 58), + 'speaker_pin_yellow.svg': ui.Size(66, 58), + 'speaker_pin_red.svg': ui.Size(66, 58), + 'selected_pin_green.svg': ui.Size(52, 77), + 'selected_pin_yellow.svg': ui.Size(52, 77), + 'selected_pin_red.svg': ui.Size(52, 77), +}; + +ui.Size _pinRasterSize(String assetPath, double scaleMultiplier) { + final name = assetPath.split('/').last; + final vb = _kPinSvgViewBox[name] ?? const ui.Size(62, 58); + final selected = name.startsWith('selected_pin_'); + final refW = selected ? 52.0 : 62.0; + final refH = selected ? 77.0 : 58.0; + final targetW = selected ? 60.0 : 50.0; + final targetH = selected ? 71.0 : 53.0; + final sw = targetW / refW; + final sh = targetH / refH; + final refScale = selected ? (sw > sh ? sw : sh) : (sw < sh ? sw : sh); + final s = refScale * scaleMultiplier; + return ui.Size(vb.width * s, vb.height * s); +} + +const _kExamplePins = <_ExampleMapPin>[ + _ExampleMapPin( + id: 'e1', + position: LatLng(42.4482, -76.4880), + categoryIndex: 0, + state: EventPinState.later, + ), + _ExampleMapPin( + id: 'e2', + position: LatLng(42.4465, -76.4865), + categoryIndex: 0, + state: EventPinState.soon, + ), + _ExampleMapPin( + id: 'e3', + position: LatLng(42.4475, -76.4890), + categoryIndex: 0, + state: EventPinState.now, + ), + _ExampleMapPin( + id: 'e4', + position: LatLng(42.4490, -76.4870), + categoryIndex: 1, + state: EventPinState.later, + ), + _ExampleMapPin( + id: 'e5', + position: LatLng(42.4455, -76.4855), + categoryIndex: 1, + state: EventPinState.now, + ), + _ExampleMapPin( + id: 'e6', + position: LatLng(42.4470, -76.4900), + categoryIndex: 2, + state: EventPinState.soon, + ), + _ExampleMapPin( + id: 'e7', + position: LatLng(42.4485, -76.4860), + categoryIndex: 2, + state: EventPinState.later, + ), + _ExampleMapPin( + id: 'e8', + position: LatLng(42.4460, -76.4885), + categoryIndex: 3, + state: EventPinState.now, + ), + _ExampleMapPin( + id: 'e9', + position: LatLng(42.4495, -76.4865), + categoryIndex: 3, + state: EventPinState.soon, + ), +]; + +class HomeMapPage extends StatefulWidget { + const HomeMapPage({super.key}); + + @override + State createState() => _HomeMapPageState(); +} + +class _HomeMapPageState extends State + with SingleTickerProviderStateMixin { + int? _selectedCategoryIndex; + + String? _selectedMapPinId; + + List>? _pinIconsByScale; + + late final AnimationController _pinBounceController; + + static const List _scaleMultipliers = [ + 0.91, + 0.935, + 0.96, + 0.985, + 1.01, + 1.03, + 1.045, + 1.018, + 1.0, + ]; + + static const LatLng _mapDefaultCenter = LatLng(42.447, -76.4875); + + static const double _kInitialCameraNorthOffsetLat = 0.001; + + static LatLng _cameraTargetBiasedNorth(LatLng userPosition) => LatLng( + userPosition.latitude + _kInitialCameraNorthOffsetLat, + userPosition.longitude, + ); + + static LatLng get _mapInitialCameraTarget => + _cameraTargetBiasedNorth(_mapDefaultCenter); + + static const double _kHomeMapZoom = 16; + + final Completer _mapCompleter = + Completer(); + GeoPoint? _currentLocation; + StreamSubscription? _positionSubscription; + StreamSubscription? _compassSubscription; + BitmapDescriptor _currentLocationIcon = BitmapDescriptor.defaultMarker; + + static const int _kUserLocationMarkerLogicalPx = 72; + double _compassHeading = 0; + + bool _followUserCamera = false; + + double _bounceScale(double t) { + final x = t.clamp(0.0, 1.0); + const med = 0.94; + const peak = 1.045; + const rest = 1.0; + const split = 0.5; + if (x <= split) { + final u = Curves.easeInOutCubic.transform(x / split); + return ui.lerpDouble(med, peak, u)!; + } + final u = Curves.easeInOutCubic.transform((x - split) / (1.0 - split)); + return ui.lerpDouble(peak, rest, u)!; + } + + int _nearestScaleIndex(double scale) { + var bestI = 0; + var bestD = (_scaleMultipliers[0] - scale).abs(); + for (var i = 1; i < _scaleMultipliers.length; i++) { + final d = (_scaleMultipliers[i] - scale).abs(); + if (d < bestD) { + bestD = d; + bestI = i; + } + } + return bestI; + } + + Future _getBytesFromAsset(String path, int width) async { + final data = await rootBundle.load(path); + final codec = await ui.instantiateImageCodec( + data.buffer.asUint8List(), + targetWidth: width, + targetHeight: width, + ); + final fi = await codec.getNextFrame(); + return (await fi.image.toByteData(format: ui.ImageByteFormat.png))! + .buffer + .asUint8List(); + } + + Future _setCustomUserLocationIcon() async { + const w = _kUserLocationMarkerLogicalPx; + final markerBytes = + await _getBytesFromAsset('assets/icons/userlocation.png', w); + if (!mounted) return; + setState(() { + _currentLocationIcon = BitmapDescriptor.bytes( + markerBytes, + width: w.toDouble(), + height: w.toDouble(), + ); + }); + } + + void _onMapCreated(GoogleMapController controller) { + if (!_mapCompleter.isCompleted) { + _mapCompleter.complete(controller); + } + _startHomePositionStream(); + } + + CameraUpdate _homeCameraUpdate(LatLng target) => + CameraUpdate.newCameraPosition( + CameraPosition( + target: _cameraTargetBiasedNorth(target), + zoom: _kHomeMapZoom, + ), + ); + + Future _animateHomeCameraTo( + LatLng target, { + required bool followUser, + }) async { + _followUserCamera = followUser; + if (!_mapCompleter.isCompleted) return; + final controller = await _mapCompleter.future; + if (!mounted) return; + await controller.animateCamera(_homeCameraUpdate(target)); + if (mounted) setState(() {}); + } + + Future _recenterOnUser() async { + final loc = _currentLocation; + if (loc == null) return; + await _animateHomeCameraTo( + LatLng(loc.lat, loc.long), + followUser: true, + ); + } + + Future _animateCameraToBiased(LatLng focus) async { + await _animateHomeCameraTo(focus, followUser: false); + } + + Future _startHomePositionStream() async { + if (_positionSubscription != null) return; + final GoogleMapController controller = await _mapCompleter.future; + if (!mounted) return; + try { + final location = await GeoPoint.current(); + if (!mounted) return; + setState(() => _currentLocation = location); + } catch (e) { + debugPrint('HomeMap: initial location failed: $e'); + } + + _positionSubscription = Geolocator.getPositionStream( + locationSettings: GeoPoint.getLocationSettings(), + ).listen( + (Position? newPos) { + if (!mounted) return; + if (newPos == null) { + _currentLocation = GeoPoint( + _mapDefaultCenter.latitude, + _mapDefaultCenter.longitude, + 0, + ); + } else { + _currentLocation = GeoPoint( + newPos.latitude, + newPos.longitude, + newPos.heading, + ); + } + if (_followUserCamera) { + final loc = _currentLocation!; + controller.animateCamera( + _homeCameraUpdate(LatLng(loc.lat, loc.long)), + ); + } + setState(() {}); + }, + ); + } + + Set _mergedMarkersForMap() { + final pins = _markersForSelectedCategory(); + final at = _currentLocation == null + ? _mapDefaultCenter + : LatLng(_currentLocation!.lat, _currentLocation!.long); + return { + ...pins, + Marker( + markerId: const MarkerId('currentLocation'), + icon: _currentLocationIcon, + position: at, + anchor: const Offset(0.5, 0.5), + rotation: _compassHeading, + zIndex: 0, + onTap: () => unawaited(_animateCameraToBiased(at)), + ), + }; + } + + @override + void initState() { + super.initState(); + _pinBounceController = AnimationController(vsync: this) + ..addListener(() { + if (mounted) setState(() {}); + }); + _setCustomUserLocationIcon(); + _compassSubscription = FlutterCompass.events?.listen((event) { + if (mounted && event.heading != null) { + setState(() => _compassHeading = event.heading!); + } + }); + WidgetsBinding.instance.addPostFrameCallback((_) => _loadPinIcons()); + } + + @override + void dispose() { + _positionSubscription?.cancel(); + _compassSubscription?.cancel(); + _pinBounceController.dispose(); + super.dispose(); + } + + void _runPinBounce() { + if (_pinIconsByScale == null) return; + final pins = _pinsMatchingFilter(); + if (pins.isEmpty) return; + _pinBounceController.duration = const Duration(milliseconds: 300); + _pinBounceController.forward(from: 0); + } + + List<_ExampleMapPin> _pinsMatchingFilter() { + return [ + for (final pin in _kExamplePins) + if (_selectedCategoryIndex == null || + pin.categoryIndex == _selectedCategoryIndex) + pin, + ]; + } + + Future _putPinBitmapInTier( + Map tierMap, + String path, + String key, + double scaleMul, + ) async { + final sz = _pinRasterSize(path, scaleMul); + tierMap[key] = await _bitmapDescriptorFromSvgAsset( + context, + path, + width: sz.width, + height: sz.height, + ); + } + + Future _loadPinIcons() async { + if (!mounted) return; + try { + final byScale = >[]; + for (final m in _scaleMultipliers) { + if (!mounted) return; + final tierMap = {}; + for (var cat = 0; cat < 4; cat++) { + for (final state in EventPinState.values) { + final path = _pinAssetPath(cat, state); + await _putPinBitmapInTier( + tierMap, + path, + _pinIconKey(cat, state), + m, + ); + } + } + for (final state in EventPinState.values) { + if (!mounted) return; + final path = _selectedPinAssetPath(state); + await _putPinBitmapInTier( + tierMap, + path, + _selectedPinIconKey(state), + m, + ); + } + byScale.add(tierMap); + } + if (!mounted) return; + setState(() { + _pinIconsByScale = byScale; + }); + _runPinBounce(); + } catch (e, st) { + debugPrint('HomeMap: pin SVG load failed: $e\n$st'); + if (!mounted) return; + setState(() => _pinIconsByScale = null); + } + } + + Set _markersForSelectedCategory() { + final pins = _pinsMatchingFilter(); + if (pins.isEmpty) return {}; + final byScale = _pinIconsByScale; + if (byScale == null) return {}; + + final scale = _bounceScale(_pinBounceController.value); + final tier = _nearestScaleIndex(scale); + final icons = byScale[tier]; + return { + for (final pin in pins) + Marker( + markerId: MarkerId('${pin.id}_$tier'), + position: pin.position, + icon: icons[_selectedMapPinId == pin.id + ? _selectedPinIconKey(pin.state) + : _pinIconKey(pin.categoryIndex, pin.state)]!, + anchor: _selectedMapPinId == pin.id + ? const Offset(0.5, 0.96) + : const Offset(0.5, 0.52), + zIndex: _selectedMapPinId == pin.id ? 3 : 2, + onTap: () { + setState(() => _selectedMapPinId = pin.id); + unawaited(_animateCameraToBiased(pin.position)); + _runPinBounce(); + }, + ), + }; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Stack( + children: [ + Listener( + onPointerDown: (_) => _followUserCamera = false, + child: GoogleMap( + onMapCreated: _onMapCreated, + compassEnabled: false, + myLocationButtonEnabled: false, + zoomControlsEnabled: false, + myLocationEnabled: false, + mapToolbarEnabled: false, + mapType: MapType.normal, + initialCameraPosition: CameraPosition( + target: _mapInitialCameraTarget, + zoom: _kHomeMapZoom, + ), + markers: _mergedMarkersForMap(), + onTap: (_) { + FocusManager.instance.primaryFocus?.unfocus(); + final hadSelection = _selectedMapPinId != null; + setState(() => _selectedMapPinId = null); + if (hadSelection) _runPinBounce(); + }, + ), + ), + _HomeMapOverlay( + selectedCategoryIndex: _selectedCategoryIndex, + onCategorySelected: (i) { + setState(() { + _selectedCategoryIndex = _selectedCategoryIndex == i ? null : i; + _selectedMapPinId = null; + }); + _runPinBounce(); + }, + onRecenter: _recenterOnUser, + ), + ], + ), + ); + } +} + +class _HomeMapOverlay extends StatelessWidget { + const _HomeMapOverlay({ + required this.selectedCategoryIndex, + required this.onCategorySelected, + required this.onRecenter, + }); + + final int? selectedCategoryIndex; + final ValueChanged onCategorySelected; + final VoidCallback onRecenter; + + @override + Widget build(BuildContext context) { + final dW = MediaQuery.sizeOf(context).width; + final dH = MediaQuery.sizeOf(context).height; + return SafeArea( + bottom: false, + child: Stack( + clipBehavior: Clip.none, + children: [ + Positioned( + left: dW * 0.05, + right: dW * 0.05, + top: dH * 0.01, + child: PointerInterceptor( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const _HomeMapSearchBarExpandable(), + SizedBox(height: dH * 0.01), + _CategoryChipsRow( + selectedIndex: selectedCategoryIndex, + onCategorySelected: onCategorySelected, + ), + ], + ), + ), + ), + Positioned( + right: 16, + bottom: 160, + child: PointerInterceptor( + child: _RecenterButton(onTap: onRecenter), + ), + ), + ], + ), + ); + } +} + +class _HomeMapSearchBarExpandable extends StatefulWidget { + const _HomeMapSearchBarExpandable(); + + @override + State<_HomeMapSearchBarExpandable> createState() => + _HomeMapSearchBarExpandableState(); +} + +class _HomeMapSearchBarExpandableState + extends State<_HomeMapSearchBarExpandable> { + bool _expanded = false; + String? _distance = '5 min'; + String? _time; + String? _date; + + final FocusNode _searchFocus = FocusNode(); + + @override + void initState() { + super.initState(); + _searchFocus.addListener(_onSearchFocusChanged); + } + + void _onSearchFocusChanged() => setState(() {}); + + @override + void dispose() { + _searchFocus.removeListener(_onSearchFocusChanged); + _searchFocus.dispose(); + super.dispose(); + } + + void _toggleFilter() { + setState(() => _expanded = !_expanded); + } + + ColorFilter? get _filterIconColorFilter { + if (_searchFocus.hasFocus) { + return const ColorFilter.mode(AppColors.grayText, BlendMode.srcIn); + } + if (_expanded) { + return const ColorFilter.mode(AppColors.purple, BlendMode.srcIn); + } + return const ColorFilter.mode(AppColors.black30, BlendMode.srcIn); + } + + @override + Widget build(BuildContext context) { + final searchFocused = _searchFocus.hasFocus; + final dH = MediaQuery.sizeOf(context).height; + final barH = dH * 0.055; + return AnimatedContainer( + duration: const Duration(milliseconds: 220), + curve: Curves.easeOutCubic, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(30), + border: Border.all( + width: 1, + color: searchFocused ? AppColors.lightGray : Colors.transparent, + ), + boxShadow: [ + BoxShadow( + color: searchFocused ? AppColors.black20 : AppColors.black10, + blurRadius: searchFocused ? 16 : 10, + offset: Offset(0, searchFocused ? 6 : 4), + ), + ], + ), + child: AnimatedSize( + duration: const Duration(milliseconds: 250), + curve: Curves.easeOutCubic, + alignment: Alignment.topCenter, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SizedBox( + height: barH, + width: double.infinity, + child: Stack( + clipBehavior: Clip.none, + children: [ + TextField( + focusNode: _searchFocus, + onTapOutside: (_) => + FocusManager.instance.primaryFocus?.unfocus(), + textAlignVertical: TextAlignVertical.center, + cursorWidth: 1.5, + style: const TextStyle( + fontSize: 12, + fontFamily: 'Poppins', + color: AppColors.grayText, + ), + cursorColor: AppColors.darkText, + decoration: InputDecoration( + border: InputBorder.none, + isDense: true, + contentPadding: EdgeInsets.zero, + prefixIcon: Icon( + Icons.search, + color: searchFocused + ? AppColors.darkText + : AppColors.black30, + size: 20, + ), + hintText: searchFocused + ? null + : 'Search a name, location, etc...', + hintStyle: const TextStyle( + fontSize: 12, + fontFamily: 'Poppins', + color: AppColors.black30, + height: 1.2, + ), + ), + ), + Positioned( + right: 0, + top: 0, + bottom: 0, + child: SizedBox( + width: 50, + height: 36, + child: Center( + child: InkResponse( + onTap: _toggleFilter, + radius: 22, + child: SvgPicture.asset( + 'assets/icons/Group 578.svg', + width: 32, + height: 32, + colorFilter: _filterIconColorFilter, + ), + ), + ), + ), + ), + ], + ), + ), + if (_expanded) ...[ + const Padding( + padding: EdgeInsets.symmetric(horizontal: 12), + child: Divider( + height: 1, + thickness: 1, + color: AppColors.lightGrayBorder, + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(12, 12, 12, 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: _FilterColumn( + title: 'Distance', + children: [ + _FilterCheckboxRow( + label: '5 min', + value: _distance == '5 min', + onChanged: (v) => + setState(() => _distance = v ? '5 min' : null), + ), + _FilterCheckboxRow( + label: '15 min', + value: _distance == '15 min', + onChanged: (v) => + setState(() => _distance = v ? '15 min' : null), + ), + _FilterCheckboxRow( + label: '20+ min', + value: _distance == '20+ min', + onChanged: (v) => setState( + () => _distance = v ? '20+ min' : null), + ), + ], + ), + ), + Expanded( + child: _FilterColumn( + title: 'Time', + children: [ + _FilterCheckboxRow( + label: 'Current', + value: _time == 'Current', + onChanged: (v) => + setState(() => _time = v ? 'Current' : null), + ), + _FilterCheckboxRow( + label: '30 mins', + value: _time == '30 mins', + onChanged: (v) => + setState(() => _time = v ? '30 mins' : null), + ), + _FilterCheckboxRow( + label: '1+ hour', + value: _time == '1+ hour', + onChanged: (v) => + setState(() => _time = v ? '1+ hour' : null), + ), + ], + ), + ), + Expanded( + child: _FilterColumn( + title: 'Date', + children: [ + _FilterCheckboxRow( + label: 'Today', + value: _date == 'Today', + onChanged: (v) => + setState(() => _date = v ? 'Today' : null), + ), + _FilterCheckboxRow( + label: 'Tomorrow', + value: _date == 'Tomorrow', + onChanged: (v) => + setState(() => _date = v ? 'Tomorrow' : null), + ), + Padding( + padding: const EdgeInsets.only(top: 2), + child: InkWell( + onTap: () => setState(() => _date = 'Custom'), + borderRadius: BorderRadius.circular(4), + child: Padding( + padding: + const EdgeInsets.symmetric(vertical: 4), + child: Row( + children: [ + Icon( + Icons.calendar_today_outlined, + size: 18, + color: _date == 'Custom' + ? AppColors.purple + : AppColors.grayText, + ), + const SizedBox(width: 6), + Text( + 'Custom', + style: TextStyle( + fontSize: 12, + fontFamily: 'Poppins', + fontWeight: FontWeight.w500, + color: _date == 'Custom' + ? AppColors.purple + : AppColors.darkText, + ), + ), + ], + ), + ), + ), + ), + ], + ), + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(12, 0, 12, 12), + child: ElevatedButton( + onPressed: () => setState(() => _expanded = false), + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.purple, + foregroundColor: Colors.white, + elevation: 0, + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: const Text( + 'Apply', + style: TextStyle( + fontFamily: 'Poppins', + fontWeight: FontWeight.w600, + fontSize: 15, + ), + ), + ), + ), + ], + ], + ), + ), + ); + } +} + +class _FilterColumn extends StatelessWidget { + final String title; + final List children; + + const _FilterColumn({required this.title, required this.children}); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: const TextStyle( + fontFamily: 'Poppins', + fontWeight: FontWeight.w600, + fontSize: 13, + color: AppColors.darkText, + ), + ), + const SizedBox(height: 8), + ...children, + ], + ); + } +} + +class _FilterCheckboxRow extends StatelessWidget { + final String label; + final bool value; + final ValueChanged onChanged; + + const _FilterCheckboxRow({ + required this.label, + required this.value, + required this.onChanged, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 4), + child: InkWell( + onTap: () => onChanged(!value), + borderRadius: BorderRadius.circular(4), + child: Row( + children: [ + SizedBox( + width: 22, + height: 22, + child: Checkbox( + value: value, + onChanged: (v) => onChanged(v ?? false), + activeColor: AppColors.purple, + side: const BorderSide(color: AppColors.borderGray, width: 1.5), + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + visualDensity: VisualDensity.compact, + ), + ), + const SizedBox(width: 4), + Expanded( + child: Text( + label, + style: TextStyle( + fontFamily: 'Poppins', + fontSize: 12, + fontWeight: FontWeight.w500, + color: value ? AppColors.purple : AppColors.darkText, + ), + ), + ), + ], + ), + ), + ); + } +} + +class _CategoryChipsRow extends StatelessWidget { + const _CategoryChipsRow({ + required this.selectedIndex, + required this.onCategorySelected, + }); + + final int? selectedIndex; + final ValueChanged onCategorySelected; + + static const _chips = <_CategoryChipData>[ + _CategoryChipData(label: 'Food', iconAsset: 'assets/icons/burger.svg'), + _CategoryChipData(label: 'Swag', iconAsset: 'assets/icons/fund.svg'), + _CategoryChipData(label: 'Concerts', iconAsset: 'assets/icons/mic.svg'), + _CategoryChipData(label: 'Speakers', iconAsset: 'assets/icons/speaker.svg'), + ]; + + @override + Widget build(BuildContext context) { + return ClipPath( + clipper: const _CategoryChipsScrollClipper(), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + clipBehavior: Clip.none, + child: Padding( + padding: const EdgeInsets.fromLTRB(0, 4, 0, 14), + child: Row( + children: [ + for (var i = 0; i < _chips.length; i++) ...[ + _CategoryChip( + data: _chips[i], + selected: selectedIndex != null && i == selectedIndex, + onTap: () => onCategorySelected(i), + ), + const SizedBox(width: 10), + ], + ], + ), + ), + ), + ); + } +} + +class _CategoryChipsScrollClipper extends CustomClipper { + const _CategoryChipsScrollClipper(); + + @override + Path getClip(Size size) { + return Path() + ..addRect(Rect.fromLTRB(-16, -8, size.width, size.height + 20)); + } + + @override + bool shouldReclip(covariant CustomClipper oldClipper) => false; +} + +class _CategoryChipData { + final String label; + final String iconAsset; + + const _CategoryChipData({required this.label, required this.iconAsset}); +} + +class _CategoryChip extends StatelessWidget { + final _CategoryChipData data; + final bool selected; + final VoidCallback onTap; + + const _CategoryChip({ + required this.data, + required this.selected, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return TweenAnimationBuilder( + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOutCubic, + tween: Tween(end: selected ? 1 : 0), + builder: (_, t, __) { + final fg = Color.lerp(AppColors.mediumGray, AppColors.purple, t)!; + final bc = Color.lerp(Colors.transparent, AppColors.purple, t)!; + return Material( + color: Colors.transparent, + clipBehavior: Clip.none, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(18), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(18), + border: Border.all(color: bc, width: 1.5), + boxShadow: const [ + BoxShadow( + color: AppColors.black10, + blurRadius: 8, + offset: Offset(0, 3), + ), + ], + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + SvgPicture.asset( + data.iconAsset, + width: 16, + height: 16, + colorFilter: ColorFilter.mode(fg, BlendMode.srcIn), + ), + const SizedBox(width: 6), + Text( + data.label, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: fg, + ), + ), + ], + ), + ), + ), + ); + }, + ); + } +} + +class _RecenterButton extends StatelessWidget { + const _RecenterButton({required this.onTap}); + + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Material( + color: Colors.transparent, + child: InkResponse( + onTap: onTap, + radius: 26, + child: Container( + width: 46, + height: 46, + decoration: const BoxDecoration( + shape: BoxShape.circle, + color: Colors.white, + boxShadow: [ + BoxShadow( + color: AppColors.black20, + blurRadius: 12, + offset: Offset(0, 6), + ), + ], + ), + child: Center( + child: SvgPicture.asset( + 'assets/icons/maprecenter.svg', + width: 22, + height: 22, + colorFilter: const ColorFilter.mode( + AppColors.purple, + BlendMode.srcIn, + ), + ), + ), + ), + ), + ); + } +} diff --git a/game/pubspec.lock b/game/pubspec.lock index 1142b1aa..3bcf76c1 100644 --- a/game/pubspec.lock +++ b/game/pubspec.lock @@ -965,6 +965,38 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" + pointer_interceptor: + dependency: "direct main" + description: + name: pointer_interceptor + sha256: "57210410680379aea8b1b7ed6ae0c3ad349bfd56fe845b8ea934a53344b9d523" + url: "https://pub.dev" + source: hosted + version: "0.10.1+2" + pointer_interceptor_ios: + dependency: transitive + description: + name: pointer_interceptor_ios + sha256: "03c5fa5896080963ab4917eeffda8d28c90f22863a496fb5ba13bc10943e40e4" + url: "https://pub.dev" + source: hosted + version: "0.10.1+1" + pointer_interceptor_platform_interface: + dependency: transitive + description: + name: pointer_interceptor_platform_interface + sha256: "0597b0560e14354baeb23f8375cd612e8bd4841bf8306ecb71fcd0bb78552506" + url: "https://pub.dev" + source: hosted + version: "0.10.0+1" + pointer_interceptor_web: + dependency: transitive + description: + name: pointer_interceptor_web + sha256: "460b600e71de6fcea2b3d5f662c92293c049c4319e27f0829310e5a953b3ee2a" + url: "https://pub.dev" + source: hosted + version: "0.10.3" polylabel: dependency: transitive description: diff --git a/game/pubspec.yaml b/game/pubspec.yaml index 2a92fd6a..32a979e8 100644 --- a/game/pubspec.yaml +++ b/game/pubspec.yaml @@ -70,6 +70,7 @@ dependencies: device_preview: ^1.2.0 showcaseview: ^5.0.1 flutter_compass: ^0.8.0 + pointer_interceptor: ^0.10.1+2 dev_dependencies: flutter_test: From f72bdbb2ebe98844bbdde247f8ae7a0f6f023288 Mon Sep 17 00:00:00 2001 From: Jason Guo Date: Sun, 12 Apr 2026 21:24:25 -0400 Subject: [PATCH 02/22] how screen mod and setup --- .../navigation_page/search_filter_home.dart | 28 ++++++++++++++----- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/game/lib/navigation_page/search_filter_home.dart b/game/lib/navigation_page/search_filter_home.dart index 8b184e58..c0909fb8 100644 --- a/game/lib/navigation_page/search_filter_home.dart +++ b/game/lib/navigation_page/search_filter_home.dart @@ -5,6 +5,7 @@ import 'package:game/constants/constants.dart'; import 'package:game/journeys/journeys_page.dart'; import 'package:game/challenges/challenges_page.dart'; import 'package:game/journeys/filter_form.dart'; +import 'package:game/navigation_page/events_draggable_sheet.dart'; import 'package:game/navigation_page/home_navbar.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:flutter/services.dart'; @@ -180,14 +181,27 @@ class _SearchFilterBarState extends State ], ), ), - // HomeNavBar section - use Expanded instead of fixed height Container + // Home tab content with events bottom sheet over Challenges/Journeys Expanded( - child: HomeNavBar( - initialTab: widget.initialHomeTab, - difficulty: selectedDifficulty, - locations: selectedLocations, - categories: selectedCategories, - searchText: searchText, + child: Stack( + fit: StackFit.expand, + children: [ + Positioned.fill( + child: HomeNavBar( + initialTab: widget.initialHomeTab, + difficulty: selectedDifficulty, + locations: selectedLocations, + categories: selectedCategories, + searchText: searchText, + ), + ), + EventsDraggableSheet( + difficulty: selectedDifficulty, + locations: selectedLocations, + categories: selectedCategories, + searchText: searchText, + ), + ], ), ), ], From a188505891a62968a11a7c1522d5a71d73f5d950 Mon Sep 17 00:00:00 2001 From: Jason Guo Date: Sun, 12 Apr 2026 21:26:03 -0400 Subject: [PATCH 03/22] screen --- .../events_draggable_sheet.dart | 470 ++++++++++++++++++ 1 file changed, 470 insertions(+) create mode 100644 game/lib/navigation_page/events_draggable_sheet.dart diff --git a/game/lib/navigation_page/events_draggable_sheet.dart b/game/lib/navigation_page/events_draggable_sheet.dart new file mode 100644 index 00000000..a924804a --- /dev/null +++ b/game/lib/navigation_page/events_draggable_sheet.dart @@ -0,0 +1,470 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:game/api/game_api.dart'; +import 'package:game/api/game_client_dto.dart'; +import 'package:game/constants/constants.dart'; +import 'package:game/model/challenge_model.dart'; +import 'package:game/model/event_model.dart'; +import 'package:game/model/group_model.dart'; +import 'package:game/model/tracker_model.dart'; +import 'package:game/model/user_model.dart'; +import 'package:game/utils/utility_functions.dart'; +import 'package:provider/provider.dart'; +import 'package:velocity_x/velocity_x.dart'; + +/// Draggable bottom sheet listing upcoming challenge events (same pool and +/// filters as [ChallengesPage]), with name, host, and a placeholder GO button. +class EventsDraggableSheet extends StatefulWidget { + final String? difficulty; + final List? locations; + final List? categories; + final String? searchText; + + const EventsDraggableSheet({ + super.key, + this.difficulty, + this.locations, + this.categories, + this.searchText, + }); + + @override + State createState() => _EventsDraggableSheetState(); +} + +class _EventsDraggableSheetState extends State { + final DraggableScrollableController _sheetController = + DraggableScrollableController(); + + @override + void dispose() { + _sheetController.dispose(); + super.dispose(); + } + + String _hostLabel(EventDto event, UserModel userModel) { + final oid = event.initialOrganizationId; + if (oid != null && userModel.orgData.containsKey(oid)) { + return userModel.orgData[oid]?.name ?? 'Host'; + } + for (final org in userModel.orgData.values) { + if (org.events?.contains(event.id) ?? false) { + return org.name ?? 'Host'; + } + } + return 'Host'; + } + + String _perkLine(EventDto event) { + final d = event.description?.trim(); + if (d != null && d.isNotEmpty) { + final first = d.split(RegExp(r'\n')).first.trim(); + if (first.length <= 48) return first; + } + if (event.category != null) { + return friendlyCategory[event.category!] ?? 'Event'; + } + return 'Event'; + } + + String _timeLabel(EventDto event) { + try { + final end = HttpDate.parse(event.endTime ?? ''); + final diff = end.difference(DateTime.now()); + if (diff.isNegative) return 'ended'; + if (diff.inDays > 0) return 'in ${diff.inDays}d'; + if (diff.inHours > 0) return 'in ${diff.inHours} hours'; + if (diff.inMinutes > 0) return 'in ${diff.inMinutes} min'; + return 'soon'; + } catch (_) { + return ''; + } + } + + List _filteredEvents( + UserModel userModel, + EventModel eventModel, + TrackerModel trackerModel, + ChallengeModel challengeModel, + GroupModel groupModel, + ApiClient apiClient, + ) { + final allowedEventIds = userModel.getAvailableEventIds(); + final events = allowedEventIds + .map((id) => eventModel.getEventById(id)) + .filter((e) => e != null) + .map((e) => e!) + .toList(); + + final List out = []; + + for (final event in events) { + final tracker = trackerModel.trackerByEventId(event.id); + final numberCompleted = tracker?.prevChallenges.length ?? 0; + final complete = numberCompleted == (event.challenges?.length ?? 0); + DateTime endtime; + try { + endtime = HttpDate.parse(event.endTime ?? ''); + } catch (_) { + continue; + } + final timeTillExpire = endtime.difference(DateTime.now()); + if (event.isJourney == true) continue; + if (event.indexable == false) continue; + if (event.challenges == null || event.challenges!.isEmpty) continue; + final challenge = challengeModel.getChallengeById(event.challenges![0]); + if (challenge == null) continue; + + final challengeLocation = challenge.location?.name ?? ''; + final matches = eventMatchesFilters( + event: event, + difficulty: widget.difficulty, + locations: widget.locations, + categories: widget.categories, + searchText: widget.searchText, + challengeLocation: challengeLocation, + challengeName: challenge.name, + ); + + if (!complete && !timeTillExpire.isNegative && matches) { + out.add(event); + } else if (event.id == groupModel.curEventId) { + apiClient.serverApi?.setCurrentEvent(SetCurrentEventDto(eventId: "")); + } + } + + out.sort((a, b) { + try { + return HttpDate.parse(a.endTime ?? '') + .compareTo(HttpDate.parse(b.endTime ?? '')); + } catch (_) { + return 0; + } + }); + + return out; + } + + Future _collapseToPeek() async { + if (!_sheetController.isAttached) return; + await _sheetController.animateTo( + 0.26, + duration: const Duration(milliseconds: 280), + curve: Curves.easeOutCubic, + ); + } + + @override + Widget build(BuildContext context) { + return Positioned.fill( + child: DraggableScrollableSheet( + controller: _sheetController, + initialChildSize: 0.26, + minChildSize: 0.26, + maxChildSize: 1.0, + snap: true, + snapSizes: const [0.58, 1.0], + builder: (context, scrollController) { + return AnimatedBuilder( + animation: _sheetController, + builder: (context, _) { + final extent = + _sheetController.isAttached ? _sheetController.size : 0.26; + final showExpandedChrome = extent > 0.88; + + return Consumer6( + builder: ( + context, + userModel, + eventModel, + groupModel, + trackerModel, + challengeModel, + apiClient, + _, + ) { + final events = _filteredEvents( + userModel, + eventModel, + trackerModel, + challengeModel, + groupModel, + apiClient, + ); + final count = events.length; + + return ClipRRect( + borderRadius: const BorderRadius.vertical( + top: Radius.circular(20), + ), + child: Container( + decoration: BoxDecoration( + color: Colors.white, + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.12), + blurRadius: 12, + offset: const Offset(0, -2), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Center( + child: Container( + margin: const EdgeInsets.only(top: 8, bottom: 4), + width: 40, + height: 4, + decoration: BoxDecoration( + color: AppColors.dragHandle, + borderRadius: BorderRadius.circular(2), + ), + ), + ), + if (showExpandedChrome) + Padding( + padding: EdgeInsets.only( + left: 4, + right: 8, + top: 4, + bottom: 4, + ), + child: Row( + children: [ + TextButton( + onPressed: _collapseToPeek, + child: Text( + '< Exit', + style: TextStyle( + color: AppColors.grayText, + fontSize: 15, + fontFamily: 'Poppins', + ), + ), + ), + const Expanded( + child: Text( + 'Events', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + fontFamily: 'Poppins', + ), + ), + ), + const SizedBox(width: 64), + ], + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(20, 4, 20, 8), + child: Text( + '$count Events Happening Soon', + style: const TextStyle( + fontSize: 17, + fontWeight: FontWeight.w600, + fontFamily: 'Poppins', + color: AppColors.darkText, + ), + ), + ), + Expanded( + child: events.isEmpty + ? Center( + child: Text( + 'No events match your filters.', + style: TextStyle( + color: AppColors.grayText, + fontFamily: 'Poppins', + ), + ), + ) + : ListView.builder( + controller: scrollController, + padding: const EdgeInsets.fromLTRB( + 16, + 0, + 16, + 24, + ), + itemCount: events.length, + itemBuilder: (context, index) { + final event = events[index]; + final host = _hostLabel( + event, + userModel, + ); + final perk = _perkLine(event); + final timeStr = _timeLabel(event); + + return Padding( + padding: const EdgeInsets.only( + bottom: 12, + ), + child: _EventCard( + eventName: event.name ?? 'Event', + hostName: host, + perkLine: perk, + timeLabel: timeStr, + ), + ); + }, + ), + ), + ], + ), + ), + ); + }, + ); + }, + ); + }, + ), + ); + } +} + +class _EventCard extends StatelessWidget { + final String eventName; + final String hostName; + final String perkLine; + final String timeLabel; + + const _EventCard({ + required this.eventName, + required this.hostName, + required this.perkLine, + required this.timeLabel, + }); + + @override + Widget build(BuildContext context) { + return Material( + color: Colors.white, + elevation: 2, + shadowColor: Colors.black26, + borderRadius: BorderRadius.circular(14), + child: Padding( + padding: const EdgeInsets.all(14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + Icons.restaurant_rounded, + size: 20, + color: AppColors.purple, + ), + const SizedBox(width: 6), + Expanded( + child: Text( + perkLine, + style: const TextStyle( + fontSize: 13, + color: AppColors.purple, + fontWeight: FontWeight.w500, + fontFamily: 'Poppins', + ), + ), + ), + if (timeLabel.isNotEmpty) + Text( + timeLabel, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: AppColors.primaryRed, + fontFamily: 'Poppins', + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + eventName, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + fontFamily: 'Poppins', + color: AppColors.darkText, + ), + ), + const SizedBox(height: 10), + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: Row( + children: [ + Text( + 'Hosted by', + style: TextStyle( + fontSize: 12, + color: AppColors.grayText, + fontFamily: 'Poppins', + ), + ), + const SizedBox(width: 6), + Container( + width: 22, + height: 22, + decoration: const BoxDecoration( + color: AppColors.darkText, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 6), + Flexible( + child: Text( + hostName, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + fontFamily: 'Poppins', + color: AppColors.darkText, + ), + ), + ), + ], + ), + ), + const SizedBox(width: 8), + Material( + color: AppColors.purple, + borderRadius: BorderRadius.circular(10), + child: InkWell( + onTap: () {}, + borderRadius: BorderRadius.circular(10), + child: const Padding( + padding: EdgeInsets.symmetric( + horizontal: 18, + vertical: 10, + ), + child: Text( + 'GO!', + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.w700, + fontSize: 14, + fontFamily: 'Poppins', + ), + ), + ), + ), + ), + ], + ), + ], + ), + ), + ); + } +} From cf02e081079ff6ad78efe36d7102ccb82d54fade Mon Sep 17 00:00:00 2001 From: Jason Guo Date: Sun, 12 Apr 2026 21:27:34 -0400 Subject: [PATCH 04/22] dart format --- game/lib/navigation_page/events_draggable_sheet.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/game/lib/navigation_page/events_draggable_sheet.dart b/game/lib/navigation_page/events_draggable_sheet.dart index a924804a..95c6ca97 100644 --- a/game/lib/navigation_page/events_draggable_sheet.dart +++ b/game/lib/navigation_page/events_draggable_sheet.dart @@ -173,8 +173,8 @@ class _EventsDraggableSheetState extends State { _sheetController.isAttached ? _sheetController.size : 0.26; final showExpandedChrome = extent > 0.88; - return Consumer6( + return Consumer6( builder: ( context, userModel, From e52ff42c6dfa53dea0d47987091272fa3aa06763 Mon Sep 17 00:00:00 2001 From: Jason Guo Date: Sun, 12 Apr 2026 21:32:22 -0400 Subject: [PATCH 05/22] secondary sort byloc --- .../events_draggable_sheet.dart | 56 +++++++++++++++---- 1 file changed, 46 insertions(+), 10 deletions(-) diff --git a/game/lib/navigation_page/events_draggable_sheet.dart b/game/lib/navigation_page/events_draggable_sheet.dart index 95c6ca97..ab651f0a 100644 --- a/game/lib/navigation_page/events_draggable_sheet.dart +++ b/game/lib/navigation_page/events_draggable_sheet.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:game/api/game_api.dart'; import 'package:game/api/game_client_dto.dart'; +import 'package:game/api/geopoint.dart'; import 'package:game/constants/constants.dart'; import 'package:game/model/challenge_model.dart'; import 'package:game/model/event_model.dart'; @@ -37,6 +38,26 @@ class _EventsDraggableSheetState extends State { final DraggableScrollableController _sheetController = DraggableScrollableController(); + /// User location for distance tie-break (first challenge coords vs. user). + GeoPoint? _currentUserLocation; + + @override + void initState() { + super.initState(); + _loadUserLocation(); + } + + Future _loadUserLocation() async { + try { + final location = await GeoPoint.current(); + if (mounted) { + setState(() => _currentUserLocation = location); + } + } catch (e) { + print('Error loading user location for events sheet: $e'); + } + } + @override void dispose() { _sheetController.dispose(); @@ -97,7 +118,8 @@ class _EventsDraggableSheetState extends State { .map((e) => e!) .toList(); - final List out = []; + final List<({EventDto event, DateTime end, double? distanceMeters})> rows = + []; for (final event in events) { final tracker = trackerModel.trackerByEventId(event.id); @@ -128,22 +150,36 @@ class _EventsDraggableSheetState extends State { ); if (!complete && !timeTillExpire.isNegative && matches) { - out.add(event); + double? distanceMeters; + final userLoc = _currentUserLocation; + if (userLoc != null && + challenge.latF != null && + challenge.longF != null) { + try { + distanceMeters = userLoc.distanceTo( + GeoPoint(challenge.latF!, challenge.longF!, 0), + ); + } catch (_) { + distanceMeters = null; + } + } + rows.add((event: event, end: endtime, distanceMeters: distanceMeters)); } else if (event.id == groupModel.curEventId) { apiClient.serverApi?.setCurrentEvent(SetCurrentEventDto(eventId: "")); } } - out.sort((a, b) { - try { - return HttpDate.parse(a.endTime ?? '') - .compareTo(HttpDate.parse(b.endTime ?? '')); - } catch (_) { - return 0; - } + rows.sort((a, b) { + final byEnd = a.end.compareTo(b.end); + if (byEnd != 0) return byEnd; + // Secondary: nearer first; unknown distance last + if (a.distanceMeters == null && b.distanceMeters == null) return 0; + if (a.distanceMeters == null) return 1; + if (b.distanceMeters == null) return -1; + return a.distanceMeters!.compareTo(b.distanceMeters!); }); - return out; + return rows.map((r) => r.event).toList(); } Future _collapseToPeek() async { From 957478312c6dbb1d8a934d39b5180e354857a376 Mon Sep 17 00:00:00 2001 From: Selina Ge Date: Wed, 15 Apr 2026 17:56:39 -0400 Subject: [PATCH 06/22] widgetoptions + todo --- game/lib/navigation_page/bottom_navbar.dart | 13 +++++++------ game/lib/navigation_page/home_map_page.dart | 1 + 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/game/lib/navigation_page/bottom_navbar.dart b/game/lib/navigation_page/bottom_navbar.dart index a5b34a4d..1806de6b 100644 --- a/game/lib/navigation_page/bottom_navbar.dart +++ b/game/lib/navigation_page/bottom_navbar.dart @@ -64,12 +64,7 @@ class _BottomNavBarState extends State { fontSize: 30, fontWeight: FontWeight.bold, ); - static List _widgetOptions = [ - HomeMapPage(), - SearchFilterBar(), - GlobalLeaderboardWidget(), - ProfilePage(), - ]; + late final List _widgetOptions; void _onItemTapped(int index) { setState(() { @@ -80,6 +75,12 @@ class _BottomNavBarState extends State { @override void initState() { super.initState(); + _widgetOptions = [ + HomeMapPage(), + SearchFilterBar(), + GlobalLeaderboardWidget(), + ProfilePage(), + ]; } @override diff --git a/game/lib/navigation_page/home_map_page.dart b/game/lib/navigation_page/home_map_page.dart index 2369ade2..01448b41 100644 --- a/game/lib/navigation_page/home_map_page.dart +++ b/game/lib/navigation_page/home_map_page.dart @@ -136,6 +136,7 @@ ui.Size _pinRasterSize(String assetPath, double scaleMultiplier) { return ui.Size(vb.width * s, vb.height * s); } +// TODO: Replace with real event data from API const _kExamplePins = <_ExampleMapPin>[ _ExampleMapPin( id: 'e1', From b44bf39cf0ebba0ef7d2c8e37fb4c4e63cf8dbf4 Mon Sep 17 00:00:00 2001 From: Selina Ge Date: Sun, 19 Apr 2026 14:48:19 -0400 Subject: [PATCH 07/22] refactor home map page + add documentation --- .../home_map/category_chips.dart | 145 ++++ .../home_map/home_map_search_bar.dart | 387 ++++++++++ .../home_map/map_pin_utils.dart | 151 ++++ game/lib/navigation_page/home_map_page.dart | 687 ++---------------- 4 files changed, 724 insertions(+), 646 deletions(-) create mode 100644 game/lib/navigation_page/home_map/category_chips.dart create mode 100644 game/lib/navigation_page/home_map/home_map_search_bar.dart create mode 100644 game/lib/navigation_page/home_map/map_pin_utils.dart diff --git a/game/lib/navigation_page/home_map/category_chips.dart b/game/lib/navigation_page/home_map/category_chips.dart new file mode 100644 index 00000000..8c0a03f2 --- /dev/null +++ b/game/lib/navigation_page/home_map/category_chips.dart @@ -0,0 +1,145 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; + +import 'package:game/constants/constants.dart'; + +/** + * Category Chips - Horizontal filter chips for Home Map. + * + * This widget renders the category "pills" used to filter visible map pins. + * + * @param selectedIndex - Currently selected category index (or null for none). + * @param onCategorySelected - Callback when a category is tapped. + */ + +class CategoryChipsRow extends StatelessWidget { + const CategoryChipsRow({ + super.key, + required this.selectedIndex, + required this.onCategorySelected, + }); + + final int? selectedIndex; + final ValueChanged onCategorySelected; + + static const _chips = <_CategoryChipData>[ + _CategoryChipData(label: 'Food', iconAsset: 'assets/icons/burger.svg'), + _CategoryChipData(label: 'Swag', iconAsset: 'assets/icons/fund.svg'), + _CategoryChipData(label: 'Concerts', iconAsset: 'assets/icons/mic.svg'), + _CategoryChipData(label: 'Speakers', iconAsset: 'assets/icons/speaker.svg'), + ]; + + @override + Widget build(BuildContext context) { + return ClipPath( + clipper: const _CategoryChipsScrollClipper(), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + clipBehavior: Clip.none, + child: Padding( + padding: const EdgeInsets.fromLTRB(0, 4, 0, 14), + child: Row( + children: [ + for (var i = 0; i < _chips.length; i++) ...[ + _CategoryChip( + data: _chips[i], + selected: selectedIndex != null && i == selectedIndex, + onTap: () => onCategorySelected(i), + ), + const SizedBox(width: 10), + ], + ], + ), + ), + ), + ); + } +} + +class _CategoryChipsScrollClipper extends CustomClipper { + const _CategoryChipsScrollClipper(); + + @override + Path getClip(Size size) { + return Path() + ..addRect(Rect.fromLTRB(-16, -8, size.width, size.height + 20)); + } + + @override + bool shouldReclip(covariant CustomClipper oldClipper) => false; +} + +class _CategoryChipData { + final String label; + final String iconAsset; + + const _CategoryChipData({required this.label, required this.iconAsset}); +} + +class _CategoryChip extends StatelessWidget { + final _CategoryChipData data; + final bool selected; + final VoidCallback onTap; + + const _CategoryChip({ + required this.data, + required this.selected, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return TweenAnimationBuilder( + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOutCubic, + tween: Tween(end: selected ? 1 : 0), + builder: (_, t, __) { + final fg = Color.lerp(AppColors.mediumGray, AppColors.purple, t)!; + final bc = Color.lerp(Colors.transparent, AppColors.purple, t)!; + return Material( + color: Colors.transparent, + clipBehavior: Clip.none, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(18), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(18), + border: Border.all(color: bc, width: 1.5), + boxShadow: const [ + BoxShadow( + color: AppColors.black10, + blurRadius: 8, + offset: Offset(0, 3), + ), + ], + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + SvgPicture.asset( + data.iconAsset, + width: 16, + height: 16, + colorFilter: ColorFilter.mode(fg, BlendMode.srcIn), + ), + const SizedBox(width: 6), + Text( + data.label, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: fg, + ), + ), + ], + ), + ), + ), + ); + }, + ); + } +} diff --git a/game/lib/navigation_page/home_map/home_map_search_bar.dart b/game/lib/navigation_page/home_map/home_map_search_bar.dart new file mode 100644 index 00000000..2d49e3d1 --- /dev/null +++ b/game/lib/navigation_page/home_map/home_map_search_bar.dart @@ -0,0 +1,387 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; + +import 'package:game/constants/constants.dart'; + +/** + * Search Bar - Expandable Home Map search + filters UI. + * + * A self-contained widget that renders the Home Map search field and the + * expandable filter panel (distance/time/date). + * + * @remarks + * This widget currently manages its filter state locally. When we wire this to + * backend search/filtering, we can hoist state out via callbacks. + */ + +class HomeMapSearchBarExpandable extends StatefulWidget { + const HomeMapSearchBarExpandable({super.key}); + + @override + State createState() => + _HomeMapSearchBarExpandableState(); +} + +class _HomeMapSearchBarExpandableState + extends State { + // Local filter state + bool _expanded = false; + String? _distance = '5 min'; + String? _time; + String? _date; + + final FocusNode _searchFocus = FocusNode(); + + @override + void initState() { + super.initState(); + _searchFocus.addListener(_onSearchFocusChanged); + } + + void _onSearchFocusChanged() => setState(() {}); + + @override + void dispose() { + _searchFocus.removeListener(_onSearchFocusChanged); + _searchFocus.dispose(); + super.dispose(); + } + + void _toggleFilter() { + setState(() => _expanded = !_expanded); + } + + ColorFilter? get _filterIconColorFilter { + // Filter icon color priority: focus > expanded > idle. + if (_searchFocus.hasFocus) { + return const ColorFilter.mode(AppColors.grayText, BlendMode.srcIn); + } + if (_expanded) { + return const ColorFilter.mode(AppColors.purple, BlendMode.srcIn); + } + return const ColorFilter.mode(AppColors.black30, BlendMode.srcIn); + } + + @override + Widget build(BuildContext context) { + final searchFocused = _searchFocus.hasFocus; + final dH = MediaQuery.sizeOf(context).height; + final barH = dH * 0.055; + return AnimatedContainer( + duration: const Duration(milliseconds: 220), + curve: Curves.easeOutCubic, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(30), + border: Border.all( + width: 1, + color: searchFocused ? AppColors.lightGray : Colors.transparent, + ), + boxShadow: [ + BoxShadow( + color: searchFocused ? AppColors.black20 : AppColors.black10, + blurRadius: searchFocused ? 16 : 10, + offset: Offset(0, searchFocused ? 6 : 4), + ), + ], + ), + child: AnimatedSize( + duration: const Duration(milliseconds: 250), + curve: Curves.easeOutCubic, + alignment: Alignment.topCenter, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SizedBox( + height: barH, + width: double.infinity, + child: Stack( + clipBehavior: Clip.none, + children: [ + TextField( + focusNode: _searchFocus, + onTapOutside: (_) => + FocusManager.instance.primaryFocus?.unfocus(), + textAlignVertical: TextAlignVertical.center, + cursorWidth: 1.5, + style: const TextStyle( + fontSize: 12, + fontFamily: 'Poppins', + color: AppColors.grayText, + ), + cursorColor: AppColors.darkText, + decoration: InputDecoration( + border: InputBorder.none, + isDense: true, + contentPadding: EdgeInsets.zero, + prefixIcon: Icon( + Icons.search, + color: searchFocused + ? AppColors.darkText + : AppColors.black30, + size: 20, + ), + hintText: searchFocused + ? null + : 'Search a name, location, etc...', + hintStyle: const TextStyle( + fontSize: 12, + fontFamily: 'Poppins', + color: AppColors.black30, + height: 1.2, + ), + ), + ), + Positioned( + right: 0, + top: 0, + bottom: 0, + child: SizedBox( + width: 50, + height: 36, + child: Center( + child: InkResponse( + onTap: _toggleFilter, + radius: 22, + child: SvgPicture.asset( + 'assets/icons/Group 578.svg', + width: 32, + height: 32, + colorFilter: _filterIconColorFilter, + ), + ), + ), + ), + ), + ], + ), + ), + if (_expanded) ...[ + const Padding( + padding: EdgeInsets.symmetric(horizontal: 12), + child: Divider( + height: 1, + thickness: 1, + color: AppColors.lightGrayBorder, + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(12, 12, 12, 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: _FilterColumn( + title: 'Distance', + children: [ + _FilterCheckboxRow( + label: '5 min', + value: _distance == '5 min', + onChanged: (v) => + setState(() => _distance = v ? '5 min' : null), + ), + _FilterCheckboxRow( + label: '15 min', + value: _distance == '15 min', + onChanged: (v) => + setState(() => _distance = v ? '15 min' : null), + ), + _FilterCheckboxRow( + label: '20+ min', + value: _distance == '20+ min', + onChanged: (v) => setState( + () => _distance = v ? '20+ min' : null), + ), + ], + ), + ), + Expanded( + child: _FilterColumn( + title: 'Time', + children: [ + _FilterCheckboxRow( + label: 'Current', + value: _time == 'Current', + onChanged: (v) => + setState(() => _time = v ? 'Current' : null), + ), + _FilterCheckboxRow( + label: '30 mins', + value: _time == '30 mins', + onChanged: (v) => + setState(() => _time = v ? '30 mins' : null), + ), + _FilterCheckboxRow( + label: '1+ hour', + value: _time == '1+ hour', + onChanged: (v) => + setState(() => _time = v ? '1+ hour' : null), + ), + ], + ), + ), + Expanded( + child: _FilterColumn( + title: 'Date', + children: [ + _FilterCheckboxRow( + label: 'Today', + value: _date == 'Today', + onChanged: (v) => + setState(() => _date = v ? 'Today' : null), + ), + _FilterCheckboxRow( + label: 'Tomorrow', + value: _date == 'Tomorrow', + onChanged: (v) => + setState(() => _date = v ? 'Tomorrow' : null), + ), + Padding( + padding: const EdgeInsets.only(top: 2), + child: InkWell( + onTap: () => setState(() => _date = 'Custom'), + borderRadius: BorderRadius.circular(4), + child: Padding( + padding: + const EdgeInsets.symmetric(vertical: 4), + child: Row( + children: [ + Icon( + Icons.calendar_today_outlined, + size: 18, + color: _date == 'Custom' + ? AppColors.purple + : AppColors.grayText, + ), + const SizedBox(width: 6), + Text( + 'Custom', + style: TextStyle( + fontSize: 12, + fontFamily: 'Poppins', + fontWeight: FontWeight.w500, + color: _date == 'Custom' + ? AppColors.purple + : AppColors.darkText, + ), + ), + ], + ), + ), + ), + ), + ], + ), + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(12, 0, 12, 12), + child: ElevatedButton( + onPressed: () => setState(() => _expanded = false), + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.purple, + foregroundColor: Colors.white, + elevation: 0, + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: const Text( + 'Apply', + style: TextStyle( + fontFamily: 'Poppins', + fontWeight: FontWeight.w600, + fontSize: 15, + ), + ), + ), + ), + ], + ], + ), + ), + ); + } +} + +class _FilterColumn extends StatelessWidget { + final String title; + final List children; + + const _FilterColumn({required this.title, required this.children}); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: const TextStyle( + fontFamily: 'Poppins', + fontWeight: FontWeight.w600, + fontSize: 13, + color: AppColors.darkText, + ), + ), + const SizedBox(height: 8), + ...children, + ], + ); + } +} + +class _FilterCheckboxRow extends StatelessWidget { + final String label; + final bool value; + final ValueChanged onChanged; + + const _FilterCheckboxRow({ + required this.label, + required this.value, + required this.onChanged, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 4), + child: InkWell( + onTap: () => onChanged(!value), + borderRadius: BorderRadius.circular(4), + child: Row( + children: [ + SizedBox( + width: 22, + height: 22, + child: Checkbox( + value: value, + onChanged: (v) => onChanged(v ?? false), + activeColor: AppColors.purple, + side: const BorderSide(color: AppColors.borderGray, width: 1.5), + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + visualDensity: VisualDensity.compact, + ), + ), + const SizedBox(width: 4), + Expanded( + child: Text( + label, + style: TextStyle( + fontFamily: 'Poppins', + fontSize: 12, + fontWeight: FontWeight.w500, + color: value ? AppColors.purple : AppColors.darkText, + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/game/lib/navigation_page/home_map/map_pin_utils.dart b/game/lib/navigation_page/home_map/map_pin_utils.dart new file mode 100644 index 00000000..e2a5ecaa --- /dev/null +++ b/game/lib/navigation_page/home_map/map_pin_utils.dart @@ -0,0 +1,151 @@ +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter_svg/svg.dart' as svg; +import 'package:google_maps_flutter/google_maps_flutter.dart'; + +/** + * Map Pin Utilities - Icon rasterization + pin asset helpers. + * + * This file contains the shared utilities for converting SVG pin assets into + * `BitmapDescriptor`s (Google Maps markers) and the small helpers that map + * category/state → asset path/key. + * + * @remarks + * These utilities are used by `HomeMapPage` to build a cache of marker icons + * for multiple animation scale tiers (bounce animation). + */ + +enum EventPinState { + later, + soon, + now, +} + +// Placeholder pin model (temporary until API data). +class ExampleMapPin { + final String id; + final LatLng position; + final int categoryIndex; + final EventPinState state; + + const ExampleMapPin({ + required this.id, + required this.position, + required this.categoryIndex, + required this.state, + }); +} + +// Maps a pin state to its semantic color name in assets. +String pinStateColorName(EventPinState state) => switch (state) { + EventPinState.later => 'green', + EventPinState.soon => 'yellow', + EventPinState.now => 'red', + }; + +// Pin SVG asset path for a category + state. +String pinAssetPath(int categoryIndex, EventPinState state) { + final color = pinStateColorName(state); + final base = switch (categoryIndex) { + 0 => 'burger_pin', + 1 => 'fund_pin', + 2 => 'mic_pin', + 3 => 'speaker_pin', + _ => 'burger_pin', + }; + return 'assets/icons/${base}_$color.svg'; +} + +// Cache key for an unselected pin icon. +String pinIconKey(int categoryIndex, EventPinState state) => + '${categoryIndex}_${state.name}'; + +// Returns the selected-pin SVG asset path for a given state. +String selectedPinAssetPath(EventPinState state) => + 'assets/icons/selected_pin_${pinStateColorName(state)}.svg'; + +// Cache key for a selected pin icon. +String selectedPinIconKey(EventPinState state) => 'selected_${state.name}'; + +const Map _kPinSvgViewBox = { + 'burger_pin_green.svg': ui.Size(62, 58), + 'burger_pin_yellow.svg': ui.Size(66, 58), + 'burger_pin_red.svg': ui.Size(62, 58), + 'fund_pin_green.svg': ui.Size(62, 58), + 'fund_pin_yellow.svg': ui.Size(66, 58), + 'fund_pin_red.svg': ui.Size(66, 58), + 'mic_pin_green.svg': ui.Size(66, 58), + 'mic_pin_yellow.svg': ui.Size(66, 58), + 'mic_pin_red.svg': ui.Size(66, 58), + 'speaker_pin_green.svg': ui.Size(62, 58), + 'speaker_pin_yellow.svg': ui.Size(66, 58), + 'speaker_pin_red.svg': ui.Size(66, 58), + 'selected_pin_green.svg': ui.Size(52, 77), + 'selected_pin_yellow.svg': ui.Size(52, 77), + 'selected_pin_red.svg': ui.Size(52, 77), +}; + +//Computes the rasterization size for a pin SVG. +ui.Size pinRasterSize(String assetPath, double scaleMultiplier) { + final name = assetPath.split('/').last; + final vb = _kPinSvgViewBox[name] ?? const ui.Size(62, 58); + final selected = name.startsWith('selected_pin_'); + final refW = selected ? 52.0 : 62.0; + final refH = selected ? 77.0 : 58.0; + final targetW = selected ? 60.0 : 50.0; + final targetH = selected ? 71.0 : 53.0; + final sw = targetW / refW; + final sh = targetH / refH; + final refScale = selected ? (sw > sh ? sw : sh) : (sw < sh ? sw : sh); + final s = refScale * scaleMultiplier; + return ui.Size(vb.width * s, vb.height * s); +} + +// SVG asset → `BitmapDescriptor` (marker icon). +Future bitmapDescriptorFromSvgAsset( + BuildContext context, + String assetPath, { + required double width, + required double height, +}) async { + final dpr = MediaQuery.devicePixelRatioOf(context); + final loader = svg.SvgAssetLoader(assetPath); + final svg.PictureInfo info = await svg.vg.loadPicture(loader, context); + + ui.Picture? composed; + ui.Image? image; + try { + final src = info.size; + if (src.width <= 0 || src.height <= 0) { + throw StateError( + 'bitmapDescriptorFromSvgAsset: invalid PictureInfo.size'); + } + + final outW = (width * dpr).round().clamp(1, 4096); + final outH = (height * dpr).round().clamp(1, 4096); + + final recorder = ui.PictureRecorder(); + final canvas = ui.Canvas(recorder); + canvas.scale(outW / src.width, outH / src.height); + canvas.drawPicture(info.picture); + composed = recorder.endRecording(); + + image = await composed.toImage(outW, outH); + final byteData = await image.toByteData(format: ui.ImageByteFormat.png); + if (byteData == null) { + throw StateError( + 'bitmapDescriptorFromSvgAsset: toByteData returned null'); + } + return BitmapDescriptor.bytes( + byteData.buffer.asUint8List(), + width: width, + height: height, + bitmapScaling: MapBitmapScaling.auto, + ); + } finally { + image?.dispose(); + composed?.dispose(); + info.picture.dispose(); + } +} diff --git a/game/lib/navigation_page/home_map_page.dart b/game/lib/navigation_page/home_map_page.dart index 01448b41..7ae591f1 100644 --- a/game/lib/navigation_page/home_map_page.dart +++ b/game/lib/navigation_page/home_map_page.dart @@ -11,182 +11,69 @@ import 'package:game/constants/constants.dart'; import 'package:geolocator/geolocator.dart'; import 'package:flutter_compass/flutter_compass.dart'; -Future _bitmapDescriptorFromSvgAsset( - BuildContext context, - String assetPath, { - double width = 50, - double height = 53, -}) async { - final dpr = MediaQuery.devicePixelRatioOf(context); - final loader = SvgAssetLoader(assetPath); - final PictureInfo info = await vg.loadPicture(loader, context); - ui.Picture? composed; - ui.Image? image; - try { - final src = info.size; - if (src.width <= 0 || src.height <= 0) { - throw StateError( - '_bitmapDescriptorFromSvgAsset: invalid PictureInfo.size'); - } - - final outW = (width * dpr).round().clamp(1, 4096); - final outH = (height * dpr).round().clamp(1, 4096); - - final recorder = ui.PictureRecorder(); - final canvas = ui.Canvas(recorder); - canvas.scale(outW / src.width, outH / src.height); - canvas.drawPicture(info.picture); - composed = recorder.endRecording(); - - image = await composed.toImage(outW, outH); - final byteData = await image.toByteData(format: ui.ImageByteFormat.png); - if (byteData == null) { - throw StateError( - '_bitmapDescriptorFromSvgAsset: toByteData returned null'); - } - return BitmapDescriptor.bytes( - byteData.buffer.asUint8List(), - width: width, - height: height, - bitmapScaling: MapBitmapScaling.auto, - ); - } finally { - image?.dispose(); - composed?.dispose(); - info.picture.dispose(); - } -} - -enum EventPinState { - later, - soon, - now, -} - -class _ExampleMapPin { - final String id; - final LatLng position; - final int categoryIndex; - final EventPinState state; - - const _ExampleMapPin({ - required this.id, - required this.position, - required this.categoryIndex, - required this.state, - }); -} - -String _pinStateColorName(EventPinState state) => switch (state) { - EventPinState.later => 'green', - EventPinState.soon => 'yellow', - EventPinState.now => 'red', - }; - -String _pinAssetPath(int categoryIndex, EventPinState state) { - final color = _pinStateColorName(state); - final base = switch (categoryIndex) { - 0 => 'burger_pin', - 1 => 'fund_pin', - 2 => 'mic_pin', - 3 => 'speaker_pin', - _ => 'burger_pin', - }; - return 'assets/icons/${base}_$color.svg'; -} - -String _pinIconKey(int categoryIndex, EventPinState state) => - '${categoryIndex}_${state.name}'; - -String _selectedPinAssetPath(EventPinState state) => - 'assets/icons/selected_pin_${_pinStateColorName(state)}.svg'; - -String _selectedPinIconKey(EventPinState state) => 'selected_${state.name}'; - -const Map _kPinSvgViewBox = { - 'burger_pin_green.svg': ui.Size(62, 58), - 'burger_pin_yellow.svg': ui.Size(66, 58), - 'burger_pin_red.svg': ui.Size(62, 58), - 'fund_pin_green.svg': ui.Size(62, 58), - 'fund_pin_yellow.svg': ui.Size(66, 58), - 'fund_pin_red.svg': ui.Size(66, 58), - 'mic_pin_green.svg': ui.Size(66, 58), - 'mic_pin_yellow.svg': ui.Size(66, 58), - 'mic_pin_red.svg': ui.Size(66, 58), - 'speaker_pin_green.svg': ui.Size(62, 58), - 'speaker_pin_yellow.svg': ui.Size(66, 58), - 'speaker_pin_red.svg': ui.Size(66, 58), - 'selected_pin_green.svg': ui.Size(52, 77), - 'selected_pin_yellow.svg': ui.Size(52, 77), - 'selected_pin_red.svg': ui.Size(52, 77), -}; - -ui.Size _pinRasterSize(String assetPath, double scaleMultiplier) { - final name = assetPath.split('/').last; - final vb = _kPinSvgViewBox[name] ?? const ui.Size(62, 58); - final selected = name.startsWith('selected_pin_'); - final refW = selected ? 52.0 : 62.0; - final refH = selected ? 77.0 : 58.0; - final targetW = selected ? 60.0 : 50.0; - final targetH = selected ? 71.0 : 53.0; - final sw = targetW / refW; - final sh = targetH / refH; - final refScale = selected ? (sw > sh ? sw : sh) : (sw < sh ? sw : sh); - final s = refScale * scaleMultiplier; - return ui.Size(vb.width * s, vb.height * s); -} +import 'package:game/navigation_page/home_map/category_chips.dart'; +import 'package:game/navigation_page/home_map/home_map_search_bar.dart'; +import 'package:game/navigation_page/home_map/map_pin_utils.dart'; + +/** + * Home Map Page - Primary "Home" tab map experience. + * + * This page owns Google Maps setup, location/compass subscriptions, and the + * pin marker animation/cache. UI overlay widgets (search + category chips) + * are split into dedicated files under `navigation_page/home_map/`. + */ // TODO: Replace with real event data from API -const _kExamplePins = <_ExampleMapPin>[ - _ExampleMapPin( +const _kExamplePins = [ + ExampleMapPin( id: 'e1', position: LatLng(42.4482, -76.4880), categoryIndex: 0, state: EventPinState.later, ), - _ExampleMapPin( + ExampleMapPin( id: 'e2', position: LatLng(42.4465, -76.4865), categoryIndex: 0, state: EventPinState.soon, ), - _ExampleMapPin( + ExampleMapPin( id: 'e3', position: LatLng(42.4475, -76.4890), categoryIndex: 0, state: EventPinState.now, ), - _ExampleMapPin( + ExampleMapPin( id: 'e4', position: LatLng(42.4490, -76.4870), categoryIndex: 1, state: EventPinState.later, ), - _ExampleMapPin( + ExampleMapPin( id: 'e5', position: LatLng(42.4455, -76.4855), categoryIndex: 1, state: EventPinState.now, ), - _ExampleMapPin( + ExampleMapPin( id: 'e6', position: LatLng(42.4470, -76.4900), categoryIndex: 2, state: EventPinState.soon, ), - _ExampleMapPin( + ExampleMapPin( id: 'e7', position: LatLng(42.4485, -76.4860), categoryIndex: 2, state: EventPinState.later, ), - _ExampleMapPin( + ExampleMapPin( id: 'e8', position: LatLng(42.4460, -76.4885), categoryIndex: 3, state: EventPinState.now, ), - _ExampleMapPin( + ExampleMapPin( id: 'e9', position: LatLng(42.4495, -76.4865), categoryIndex: 3, @@ -203,14 +90,17 @@ class HomeMapPage extends StatefulWidget { class _HomeMapPageState extends State with SingleTickerProviderStateMixin { + // Overlay state (category selection / pin selection) int? _selectedCategoryIndex; String? _selectedMapPinId; + // Pin icon cache for animated bounce tiers List>? _pinIconsByScale; late final AnimationController _pinBounceController; + // Discrete scale multipliers for the bounce animation. static const List _scaleMultipliers = [ 0.91, 0.935, @@ -225,6 +115,7 @@ class _HomeMapPageState extends State static const LatLng _mapDefaultCenter = LatLng(42.447, -76.4875); + // Initial camera bias static const double _kInitialCameraNorthOffsetLat = 0.001; static LatLng _cameraTargetBiasedNorth(LatLng userPosition) => LatLng( @@ -237,6 +128,7 @@ class _HomeMapPageState extends State static const double _kHomeMapZoom = 16; + // Google Map controller + live sensors final Completer _mapCompleter = Completer(); GeoPoint? _currentLocation; @@ -247,8 +139,10 @@ class _HomeMapPageState extends State static const int _kUserLocationMarkerLogicalPx = 72; double _compassHeading = 0; + // When true, the camera keeps tracking user updates. bool _followUserCamera = false; + // Marker bounce animation double _bounceScale(double t) { final x = t.clamp(0.0, 1.0); const med = 0.94; @@ -318,6 +212,7 @@ class _HomeMapPageState extends State ), ); + // Animate the map to the home camera config. Future _animateHomeCameraTo( LatLng target, { required bool followUser, @@ -391,6 +286,7 @@ class _HomeMapPageState extends State : LatLng(_currentLocation!.lat, _currentLocation!.long); return { ...pins, + // Current user marker. Marker( markerId: const MarkerId('currentLocation'), icon: _currentLocationIcon, @@ -435,7 +331,7 @@ class _HomeMapPageState extends State _pinBounceController.forward(from: 0); } - List<_ExampleMapPin> _pinsMatchingFilter() { + List _pinsMatchingFilter() { return [ for (final pin in _kExamplePins) if (_selectedCategoryIndex == null || @@ -450,8 +346,8 @@ class _HomeMapPageState extends State String key, double scaleMul, ) async { - final sz = _pinRasterSize(path, scaleMul); - tierMap[key] = await _bitmapDescriptorFromSvgAsset( + final sz = pinRasterSize(path, scaleMul); + tierMap[key] = await bitmapDescriptorFromSvgAsset( context, path, width: sz.width, @@ -468,22 +364,22 @@ class _HomeMapPageState extends State final tierMap = {}; for (var cat = 0; cat < 4; cat++) { for (final state in EventPinState.values) { - final path = _pinAssetPath(cat, state); + final path = pinAssetPath(cat, state); await _putPinBitmapInTier( tierMap, path, - _pinIconKey(cat, state), + pinIconKey(cat, state), m, ); } } for (final state in EventPinState.values) { if (!mounted) return; - final path = _selectedPinAssetPath(state); + final path = selectedPinAssetPath(state); await _putPinBitmapInTier( tierMap, path, - _selectedPinIconKey(state), + selectedPinIconKey(state), m, ); } @@ -516,8 +412,8 @@ class _HomeMapPageState extends State markerId: MarkerId('${pin.id}_$tier'), position: pin.position, icon: icons[_selectedMapPinId == pin.id - ? _selectedPinIconKey(pin.state) - : _pinIconKey(pin.categoryIndex, pin.state)]!, + ? selectedPinIconKey(pin.state) + : pinIconKey(pin.categoryIndex, pin.state)]!, anchor: _selectedMapPinId == pin.id ? const Offset(0.5, 0.96) : const Offset(0.5, 0.52), @@ -605,9 +501,9 @@ class _HomeMapOverlay extends StatelessWidget { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - const _HomeMapSearchBarExpandable(), + const HomeMapSearchBarExpandable(), SizedBox(height: dH * 0.01), - _CategoryChipsRow( + CategoryChipsRow( selectedIndex: selectedCategoryIndex, onCategorySelected: onCategorySelected, ), @@ -628,507 +524,6 @@ class _HomeMapOverlay extends StatelessWidget { } } -class _HomeMapSearchBarExpandable extends StatefulWidget { - const _HomeMapSearchBarExpandable(); - - @override - State<_HomeMapSearchBarExpandable> createState() => - _HomeMapSearchBarExpandableState(); -} - -class _HomeMapSearchBarExpandableState - extends State<_HomeMapSearchBarExpandable> { - bool _expanded = false; - String? _distance = '5 min'; - String? _time; - String? _date; - - final FocusNode _searchFocus = FocusNode(); - - @override - void initState() { - super.initState(); - _searchFocus.addListener(_onSearchFocusChanged); - } - - void _onSearchFocusChanged() => setState(() {}); - - @override - void dispose() { - _searchFocus.removeListener(_onSearchFocusChanged); - _searchFocus.dispose(); - super.dispose(); - } - - void _toggleFilter() { - setState(() => _expanded = !_expanded); - } - - ColorFilter? get _filterIconColorFilter { - if (_searchFocus.hasFocus) { - return const ColorFilter.mode(AppColors.grayText, BlendMode.srcIn); - } - if (_expanded) { - return const ColorFilter.mode(AppColors.purple, BlendMode.srcIn); - } - return const ColorFilter.mode(AppColors.black30, BlendMode.srcIn); - } - - @override - Widget build(BuildContext context) { - final searchFocused = _searchFocus.hasFocus; - final dH = MediaQuery.sizeOf(context).height; - final barH = dH * 0.055; - return AnimatedContainer( - duration: const Duration(milliseconds: 220), - curve: Curves.easeOutCubic, - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(30), - border: Border.all( - width: 1, - color: searchFocused ? AppColors.lightGray : Colors.transparent, - ), - boxShadow: [ - BoxShadow( - color: searchFocused ? AppColors.black20 : AppColors.black10, - blurRadius: searchFocused ? 16 : 10, - offset: Offset(0, searchFocused ? 6 : 4), - ), - ], - ), - child: AnimatedSize( - duration: const Duration(milliseconds: 250), - curve: Curves.easeOutCubic, - alignment: Alignment.topCenter, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - SizedBox( - height: barH, - width: double.infinity, - child: Stack( - clipBehavior: Clip.none, - children: [ - TextField( - focusNode: _searchFocus, - onTapOutside: (_) => - FocusManager.instance.primaryFocus?.unfocus(), - textAlignVertical: TextAlignVertical.center, - cursorWidth: 1.5, - style: const TextStyle( - fontSize: 12, - fontFamily: 'Poppins', - color: AppColors.grayText, - ), - cursorColor: AppColors.darkText, - decoration: InputDecoration( - border: InputBorder.none, - isDense: true, - contentPadding: EdgeInsets.zero, - prefixIcon: Icon( - Icons.search, - color: searchFocused - ? AppColors.darkText - : AppColors.black30, - size: 20, - ), - hintText: searchFocused - ? null - : 'Search a name, location, etc...', - hintStyle: const TextStyle( - fontSize: 12, - fontFamily: 'Poppins', - color: AppColors.black30, - height: 1.2, - ), - ), - ), - Positioned( - right: 0, - top: 0, - bottom: 0, - child: SizedBox( - width: 50, - height: 36, - child: Center( - child: InkResponse( - onTap: _toggleFilter, - radius: 22, - child: SvgPicture.asset( - 'assets/icons/Group 578.svg', - width: 32, - height: 32, - colorFilter: _filterIconColorFilter, - ), - ), - ), - ), - ), - ], - ), - ), - if (_expanded) ...[ - const Padding( - padding: EdgeInsets.symmetric(horizontal: 12), - child: Divider( - height: 1, - thickness: 1, - color: AppColors.lightGrayBorder, - ), - ), - Padding( - padding: const EdgeInsets.fromLTRB(12, 12, 12, 8), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: _FilterColumn( - title: 'Distance', - children: [ - _FilterCheckboxRow( - label: '5 min', - value: _distance == '5 min', - onChanged: (v) => - setState(() => _distance = v ? '5 min' : null), - ), - _FilterCheckboxRow( - label: '15 min', - value: _distance == '15 min', - onChanged: (v) => - setState(() => _distance = v ? '15 min' : null), - ), - _FilterCheckboxRow( - label: '20+ min', - value: _distance == '20+ min', - onChanged: (v) => setState( - () => _distance = v ? '20+ min' : null), - ), - ], - ), - ), - Expanded( - child: _FilterColumn( - title: 'Time', - children: [ - _FilterCheckboxRow( - label: 'Current', - value: _time == 'Current', - onChanged: (v) => - setState(() => _time = v ? 'Current' : null), - ), - _FilterCheckboxRow( - label: '30 mins', - value: _time == '30 mins', - onChanged: (v) => - setState(() => _time = v ? '30 mins' : null), - ), - _FilterCheckboxRow( - label: '1+ hour', - value: _time == '1+ hour', - onChanged: (v) => - setState(() => _time = v ? '1+ hour' : null), - ), - ], - ), - ), - Expanded( - child: _FilterColumn( - title: 'Date', - children: [ - _FilterCheckboxRow( - label: 'Today', - value: _date == 'Today', - onChanged: (v) => - setState(() => _date = v ? 'Today' : null), - ), - _FilterCheckboxRow( - label: 'Tomorrow', - value: _date == 'Tomorrow', - onChanged: (v) => - setState(() => _date = v ? 'Tomorrow' : null), - ), - Padding( - padding: const EdgeInsets.only(top: 2), - child: InkWell( - onTap: () => setState(() => _date = 'Custom'), - borderRadius: BorderRadius.circular(4), - child: Padding( - padding: - const EdgeInsets.symmetric(vertical: 4), - child: Row( - children: [ - Icon( - Icons.calendar_today_outlined, - size: 18, - color: _date == 'Custom' - ? AppColors.purple - : AppColors.grayText, - ), - const SizedBox(width: 6), - Text( - 'Custom', - style: TextStyle( - fontSize: 12, - fontFamily: 'Poppins', - fontWeight: FontWeight.w500, - color: _date == 'Custom' - ? AppColors.purple - : AppColors.darkText, - ), - ), - ], - ), - ), - ), - ), - ], - ), - ), - ], - ), - ), - Padding( - padding: const EdgeInsets.fromLTRB(12, 0, 12, 12), - child: ElevatedButton( - onPressed: () => setState(() => _expanded = false), - style: ElevatedButton.styleFrom( - backgroundColor: AppColors.purple, - foregroundColor: Colors.white, - elevation: 0, - padding: const EdgeInsets.symmetric(vertical: 14), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - ), - child: const Text( - 'Apply', - style: TextStyle( - fontFamily: 'Poppins', - fontWeight: FontWeight.w600, - fontSize: 15, - ), - ), - ), - ), - ], - ], - ), - ), - ); - } -} - -class _FilterColumn extends StatelessWidget { - final String title; - final List children; - - const _FilterColumn({required this.title, required this.children}); - - @override - Widget build(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - title, - style: const TextStyle( - fontFamily: 'Poppins', - fontWeight: FontWeight.w600, - fontSize: 13, - color: AppColors.darkText, - ), - ), - const SizedBox(height: 8), - ...children, - ], - ); - } -} - -class _FilterCheckboxRow extends StatelessWidget { - final String label; - final bool value; - final ValueChanged onChanged; - - const _FilterCheckboxRow({ - required this.label, - required this.value, - required this.onChanged, - }); - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.only(bottom: 4), - child: InkWell( - onTap: () => onChanged(!value), - borderRadius: BorderRadius.circular(4), - child: Row( - children: [ - SizedBox( - width: 22, - height: 22, - child: Checkbox( - value: value, - onChanged: (v) => onChanged(v ?? false), - activeColor: AppColors.purple, - side: const BorderSide(color: AppColors.borderGray, width: 1.5), - materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, - visualDensity: VisualDensity.compact, - ), - ), - const SizedBox(width: 4), - Expanded( - child: Text( - label, - style: TextStyle( - fontFamily: 'Poppins', - fontSize: 12, - fontWeight: FontWeight.w500, - color: value ? AppColors.purple : AppColors.darkText, - ), - ), - ), - ], - ), - ), - ); - } -} - -class _CategoryChipsRow extends StatelessWidget { - const _CategoryChipsRow({ - required this.selectedIndex, - required this.onCategorySelected, - }); - - final int? selectedIndex; - final ValueChanged onCategorySelected; - - static const _chips = <_CategoryChipData>[ - _CategoryChipData(label: 'Food', iconAsset: 'assets/icons/burger.svg'), - _CategoryChipData(label: 'Swag', iconAsset: 'assets/icons/fund.svg'), - _CategoryChipData(label: 'Concerts', iconAsset: 'assets/icons/mic.svg'), - _CategoryChipData(label: 'Speakers', iconAsset: 'assets/icons/speaker.svg'), - ]; - - @override - Widget build(BuildContext context) { - return ClipPath( - clipper: const _CategoryChipsScrollClipper(), - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - clipBehavior: Clip.none, - child: Padding( - padding: const EdgeInsets.fromLTRB(0, 4, 0, 14), - child: Row( - children: [ - for (var i = 0; i < _chips.length; i++) ...[ - _CategoryChip( - data: _chips[i], - selected: selectedIndex != null && i == selectedIndex, - onTap: () => onCategorySelected(i), - ), - const SizedBox(width: 10), - ], - ], - ), - ), - ), - ); - } -} - -class _CategoryChipsScrollClipper extends CustomClipper { - const _CategoryChipsScrollClipper(); - - @override - Path getClip(Size size) { - return Path() - ..addRect(Rect.fromLTRB(-16, -8, size.width, size.height + 20)); - } - - @override - bool shouldReclip(covariant CustomClipper oldClipper) => false; -} - -class _CategoryChipData { - final String label; - final String iconAsset; - - const _CategoryChipData({required this.label, required this.iconAsset}); -} - -class _CategoryChip extends StatelessWidget { - final _CategoryChipData data; - final bool selected; - final VoidCallback onTap; - - const _CategoryChip({ - required this.data, - required this.selected, - required this.onTap, - }); - - @override - Widget build(BuildContext context) { - return TweenAnimationBuilder( - duration: const Duration(milliseconds: 300), - curve: Curves.easeInOutCubic, - tween: Tween(end: selected ? 1 : 0), - builder: (_, t, __) { - final fg = Color.lerp(AppColors.mediumGray, AppColors.purple, t)!; - final bc = Color.lerp(Colors.transparent, AppColors.purple, t)!; - return Material( - color: Colors.transparent, - clipBehavior: Clip.none, - child: InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(18), - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(18), - border: Border.all(color: bc, width: 1.5), - boxShadow: const [ - BoxShadow( - color: AppColors.black10, - blurRadius: 8, - offset: Offset(0, 3), - ), - ], - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - SvgPicture.asset( - data.iconAsset, - width: 16, - height: 16, - colorFilter: ColorFilter.mode(fg, BlendMode.srcIn), - ), - const SizedBox(width: 6), - Text( - data.label, - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w500, - color: fg, - ), - ), - ], - ), - ), - ), - ); - }, - ); - } -} - class _RecenterButton extends StatelessWidget { const _RecenterButton({required this.onTap}); From 75c674d2436c3fdcfdd2216804ae6ad7871d5da1 Mon Sep 17 00:00:00 2001 From: Selina Ge Date: Mon, 20 Apr 2026 19:30:04 -0400 Subject: [PATCH 08/22] correct red/green selected pin svgs --- game/assets/icons/selected_fund_pin_green.svg | 25 ++++++++++++++++ game/assets/icons/selected_fund_pin_red.svg | 25 ++++++++++++++++ game/assets/icons/selected_mic_pin_green.svg | 25 ++++++++++++++++ game/assets/icons/selected_mic_pin_red.svg | 25 ++++++++++++++++ .../icons/selected_speaker_pin_green.svg | 30 +++++++++++++++++++ .../assets/icons/selected_speaker_pin_red.svg | 30 +++++++++++++++++++ .../home_map/map_pin_utils.dart | 25 +++++++++++++--- 7 files changed, 181 insertions(+), 4 deletions(-) create mode 100644 game/assets/icons/selected_fund_pin_green.svg create mode 100644 game/assets/icons/selected_fund_pin_red.svg create mode 100644 game/assets/icons/selected_mic_pin_green.svg create mode 100644 game/assets/icons/selected_mic_pin_red.svg create mode 100644 game/assets/icons/selected_speaker_pin_green.svg create mode 100644 game/assets/icons/selected_speaker_pin_red.svg diff --git a/game/assets/icons/selected_fund_pin_green.svg b/game/assets/icons/selected_fund_pin_green.svg new file mode 100644 index 00000000..41cd538d --- /dev/null +++ b/game/assets/icons/selected_fund_pin_green.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/game/assets/icons/selected_fund_pin_red.svg b/game/assets/icons/selected_fund_pin_red.svg new file mode 100644 index 00000000..4d6b6f00 --- /dev/null +++ b/game/assets/icons/selected_fund_pin_red.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/game/assets/icons/selected_mic_pin_green.svg b/game/assets/icons/selected_mic_pin_green.svg new file mode 100644 index 00000000..38a23d64 --- /dev/null +++ b/game/assets/icons/selected_mic_pin_green.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/game/assets/icons/selected_mic_pin_red.svg b/game/assets/icons/selected_mic_pin_red.svg new file mode 100644 index 00000000..03fbd16e --- /dev/null +++ b/game/assets/icons/selected_mic_pin_red.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/game/assets/icons/selected_speaker_pin_green.svg b/game/assets/icons/selected_speaker_pin_green.svg new file mode 100644 index 00000000..03a8cf55 --- /dev/null +++ b/game/assets/icons/selected_speaker_pin_green.svg @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/game/assets/icons/selected_speaker_pin_red.svg b/game/assets/icons/selected_speaker_pin_red.svg new file mode 100644 index 00000000..d713c56d --- /dev/null +++ b/game/assets/icons/selected_speaker_pin_red.svg @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/game/lib/navigation_page/home_map/map_pin_utils.dart b/game/lib/navigation_page/home_map/map_pin_utils.dart index e2a5ecaa..fc8081d7 100644 --- a/game/lib/navigation_page/home_map/map_pin_utils.dart +++ b/game/lib/navigation_page/home_map/map_pin_utils.dart @@ -62,11 +62,22 @@ String pinIconKey(int categoryIndex, EventPinState state) => '${categoryIndex}_${state.name}'; // Returns the selected-pin SVG asset path for a given state. -String selectedPinAssetPath(EventPinState state) => - 'assets/icons/selected_pin_${pinStateColorName(state)}.svg'; +String selectedPinAssetPath(int categoryIndex, EventPinState state) { + if (state == EventPinState.soon) { + return 'assets/icons/selected_pin_yellow.svg'; + } + final color = pinStateColorName(state); + return switch (categoryIndex) { + 1 => 'assets/icons/selected_fund_pin_$color.svg', + 2 => 'assets/icons/selected_mic_pin_$color.svg', + 3 => 'assets/icons/selected_speaker_pin_$color.svg', + _ => 'assets/icons/selected_pin_$color.svg', + }; +} // Cache key for a selected pin icon. -String selectedPinIconKey(EventPinState state) => 'selected_${state.name}'; +String selectedPinIconKey(int categoryIndex, EventPinState state) => + 'selected_${categoryIndex}_${state.name}'; const Map _kPinSvgViewBox = { 'burger_pin_green.svg': ui.Size(62, 58), @@ -84,13 +95,19 @@ const Map _kPinSvgViewBox = { 'selected_pin_green.svg': ui.Size(52, 77), 'selected_pin_yellow.svg': ui.Size(52, 77), 'selected_pin_red.svg': ui.Size(52, 77), + 'selected_fund_pin_green.svg': ui.Size(64, 78), + 'selected_fund_pin_red.svg': ui.Size(64, 78), + 'selected_mic_pin_green.svg': ui.Size(64, 78), + 'selected_mic_pin_red.svg': ui.Size(64, 78), + 'selected_speaker_pin_green.svg': ui.Size(64, 78), + 'selected_speaker_pin_red.svg': ui.Size(64, 78), }; //Computes the rasterization size for a pin SVG. ui.Size pinRasterSize(String assetPath, double scaleMultiplier) { final name = assetPath.split('/').last; final vb = _kPinSvgViewBox[name] ?? const ui.Size(62, 58); - final selected = name.startsWith('selected_pin_'); + final selected = name.startsWith('selected_'); final refW = selected ? 52.0 : 62.0; final refH = selected ? 77.0 : 58.0; final targetW = selected ? 60.0 : 50.0; From 39678022052333a945597fa3fc1647c5a13acd72 Mon Sep 17 00:00:00 2001 From: Jason Guo Date: Wed, 22 Apr 2026 01:21:10 -0400 Subject: [PATCH 09/22] fixed draggable area --- .../events_draggable_sheet.dart | 85 +++++++++---------- 1 file changed, 40 insertions(+), 45 deletions(-) diff --git a/game/lib/navigation_page/events_draggable_sheet.dart b/game/lib/navigation_page/events_draggable_sheet.dart index ab651f0a..77e71820 100644 --- a/game/lib/navigation_page/events_draggable_sheet.dart +++ b/game/lib/navigation_page/events_draggable_sheet.dart @@ -246,8 +246,9 @@ class _EventsDraggableSheetState extends State { ), ], ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, + child: ListView( + controller: scrollController, + padding: const EdgeInsets.only(bottom: 24), children: [ Center( child: Container( @@ -262,7 +263,7 @@ class _EventsDraggableSheetState extends State { ), if (showExpandedChrome) Padding( - padding: EdgeInsets.only( + padding: const EdgeInsets.only( left: 4, right: 8, top: 4, @@ -308,49 +309,43 @@ class _EventsDraggableSheetState extends State { ), ), ), - Expanded( - child: events.isEmpty - ? Center( - child: Text( - 'No events match your filters.', - style: TextStyle( - color: AppColors.grayText, - fontFamily: 'Poppins', - ), - ), - ) - : ListView.builder( - controller: scrollController, - padding: const EdgeInsets.fromLTRB( - 16, - 0, - 16, - 24, - ), - itemCount: events.length, - itemBuilder: (context, index) { - final event = events[index]; - final host = _hostLabel( - event, - userModel, - ); - final perk = _perkLine(event); - final timeStr = _timeLabel(event); + if (events.isEmpty) + Padding( + padding: const EdgeInsets.fromLTRB( + 20, + 12, + 20, + 24, + ), + child: Text( + 'No events match your filters.', + style: TextStyle( + color: AppColors.grayText, + fontFamily: 'Poppins', + ), + ), + ) + else + ...events.map((event) { + final host = _hostLabel(event, userModel); + final perk = _perkLine(event); + final timeStr = _timeLabel(event); - return Padding( - padding: const EdgeInsets.only( - bottom: 12, - ), - child: _EventCard( - eventName: event.name ?? 'Event', - hostName: host, - perkLine: perk, - timeLabel: timeStr, - ), - ); - }, - ), - ), + return Padding( + padding: const EdgeInsets.fromLTRB( + 16, + 0, + 16, + 12, + ), + child: _EventCard( + eventName: event.name ?? 'Event', + hostName: host, + perkLine: perk, + timeLabel: timeStr, + ), + ); + }), ], ), ), From f0026d3113c2a44eb6c76bf7a721bcfd4e4dc37b Mon Sep 17 00:00:00 2001 From: Jason Guo Date: Wed, 22 Apr 2026 01:23:46 -0400 Subject: [PATCH 10/22] moved api call out + guards --- .../events_draggable_sheet.dart | 40 +++++++++++++++++-- 1 file changed, 36 insertions(+), 4 deletions(-) diff --git a/game/lib/navigation_page/events_draggable_sheet.dart b/game/lib/navigation_page/events_draggable_sheet.dart index 77e71820..10dfe403 100644 --- a/game/lib/navigation_page/events_draggable_sheet.dart +++ b/game/lib/navigation_page/events_draggable_sheet.dart @@ -37,6 +37,8 @@ class EventsDraggableSheet extends StatefulWidget { class _EventsDraggableSheetState extends State { final DraggableScrollableController _sheetController = DraggableScrollableController(); + bool _clearingCurrentEvent = false; + String? _lastRequestedClearEventId; /// User location for distance tie-break (first challenge coords vs. user). GeoPoint? _currentUserLocation; @@ -109,7 +111,6 @@ class _EventsDraggableSheetState extends State { TrackerModel trackerModel, ChallengeModel challengeModel, GroupModel groupModel, - ApiClient apiClient, ) { final allowedEventIds = userModel.getAvailableEventIds(); final events = allowedEventIds @@ -164,8 +165,6 @@ class _EventsDraggableSheetState extends State { } } rows.add((event: event, end: endtime, distanceMeters: distanceMeters)); - } else if (event.id == groupModel.curEventId) { - apiClient.serverApi?.setCurrentEvent(SetCurrentEventDto(eventId: "")); } } @@ -182,6 +181,39 @@ class _EventsDraggableSheetState extends State { return rows.map((r) => r.event).toList(); } + void _maybeClearCurrentEvent( + GroupModel groupModel, + List filteredEvents, + ApiClient apiClient, + ) { + final currentEventId = groupModel.curEventId; + if (currentEventId == null || currentEventId.isEmpty) { + _lastRequestedClearEventId = null; + return; + } + + final stillValid = filteredEvents.any((event) => event.id == currentEventId); + if (stillValid) { + _lastRequestedClearEventId = null; + return; + } + + if (_clearingCurrentEvent || _lastRequestedClearEventId == currentEventId) { + return; + } + + _lastRequestedClearEventId = currentEventId; + _clearingCurrentEvent = true; + + WidgetsBinding.instance.addPostFrameCallback((_) async { + try { + await apiClient.serverApi?.setCurrentEvent(SetCurrentEventDto(eventId: "")); + } finally { + _clearingCurrentEvent = false; + } + }); + } + Future _collapseToPeek() async { if (!_sheetController.isAttached) return; await _sheetController.animateTo( @@ -227,8 +259,8 @@ class _EventsDraggableSheetState extends State { trackerModel, challengeModel, groupModel, - apiClient, ); + _maybeClearCurrentEvent(groupModel, events, apiClient); final count = events.length; return ClipRRect( From d83c4639e61367cb2fc9636e1139e31ae66732cc Mon Sep 17 00:00:00 2001 From: Jason Guo Date: Wed, 22 Apr 2026 01:24:30 -0400 Subject: [PATCH 11/22] pt2 --- game/lib/navigation_page/events_draggable_sheet.dart | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/game/lib/navigation_page/events_draggable_sheet.dart b/game/lib/navigation_page/events_draggable_sheet.dart index 10dfe403..362f0560 100644 --- a/game/lib/navigation_page/events_draggable_sheet.dart +++ b/game/lib/navigation_page/events_draggable_sheet.dart @@ -192,7 +192,8 @@ class _EventsDraggableSheetState extends State { return; } - final stillValid = filteredEvents.any((event) => event.id == currentEventId); + final stillValid = + filteredEvents.any((event) => event.id == currentEventId); if (stillValid) { _lastRequestedClearEventId = null; return; @@ -207,7 +208,8 @@ class _EventsDraggableSheetState extends State { WidgetsBinding.instance.addPostFrameCallback((_) async { try { - await apiClient.serverApi?.setCurrentEvent(SetCurrentEventDto(eventId: "")); + await apiClient.serverApi + ?.setCurrentEvent(SetCurrentEventDto(eventId: "")); } finally { _clearingCurrentEvent = false; } From 3a60ca3f9b42cf235bc3c202832560c590ecfb00 Mon Sep 17 00:00:00 2001 From: Selina Ge Date: Wed, 22 Apr 2026 12:52:01 -0400 Subject: [PATCH 12/22] make chips dynamic + update chip style --- game/assets/icons/burger.svg | 4 +- game/assets/icons/fund.svg | 4 +- game/assets/icons/mic.svg | 2 +- game/assets/icons/speaker.svg | 4 +- .../home_map/category_chips.dart | 43 +++++++--------- .../home_map/home_map_categories.dart | 51 +++++++++++++++++++ .../home_map/map_pin_utils.dart | 22 ++++---- game/lib/navigation_page/home_map_page.dart | 28 ++++++---- 8 files changed, 103 insertions(+), 55 deletions(-) create mode 100644 game/lib/navigation_page/home_map/home_map_categories.dart diff --git a/game/assets/icons/burger.svg b/game/assets/icons/burger.svg index 2d25d521..2fc4aaf2 100644 --- a/game/assets/icons/burger.svg +++ b/game/assets/icons/burger.svg @@ -1,3 +1,3 @@ - - + + diff --git a/game/assets/icons/fund.svg b/game/assets/icons/fund.svg index a95156b2..614b87bf 100644 --- a/game/assets/icons/fund.svg +++ b/game/assets/icons/fund.svg @@ -1,3 +1,3 @@ - - + + diff --git a/game/assets/icons/mic.svg b/game/assets/icons/mic.svg index 5324fe85..1143b6af 100644 --- a/game/assets/icons/mic.svg +++ b/game/assets/icons/mic.svg @@ -1,3 +1,3 @@ - + diff --git a/game/assets/icons/speaker.svg b/game/assets/icons/speaker.svg index 20decd46..e8d6f00b 100644 --- a/game/assets/icons/speaker.svg +++ b/game/assets/icons/speaker.svg @@ -1,3 +1,3 @@ - - + + diff --git a/game/lib/navigation_page/home_map/category_chips.dart b/game/lib/navigation_page/home_map/category_chips.dart index 8c0a03f2..5fead8f4 100644 --- a/game/lib/navigation_page/home_map/category_chips.dart +++ b/game/lib/navigation_page/home_map/category_chips.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:game/constants/constants.dart'; +import 'package:game/navigation_page/home_map/home_map_categories.dart'; /** * Category Chips - Horizontal filter chips for Home Map. @@ -17,17 +18,12 @@ class CategoryChipsRow extends StatelessWidget { super.key, required this.selectedIndex, required this.onCategorySelected, + this.categories = homeMapCategories, }); final int? selectedIndex; final ValueChanged onCategorySelected; - - static const _chips = <_CategoryChipData>[ - _CategoryChipData(label: 'Food', iconAsset: 'assets/icons/burger.svg'), - _CategoryChipData(label: 'Swag', iconAsset: 'assets/icons/fund.svg'), - _CategoryChipData(label: 'Concerts', iconAsset: 'assets/icons/mic.svg'), - _CategoryChipData(label: 'Speakers', iconAsset: 'assets/icons/speaker.svg'), - ]; + final List categories; @override Widget build(BuildContext context) { @@ -40,9 +36,10 @@ class CategoryChipsRow extends StatelessWidget { padding: const EdgeInsets.fromLTRB(0, 4, 0, 14), child: Row( children: [ - for (var i = 0; i < _chips.length; i++) ...[ + for (var i = 0; i < categories.length; i++) ...[ _CategoryChip( - data: _chips[i], + label: categories[i].label, + iconAsset: categories[i].chipIconAsset, selected: selectedIndex != null && i == selectedIndex, onTap: () => onCategorySelected(i), ), @@ -69,20 +66,15 @@ class _CategoryChipsScrollClipper extends CustomClipper { bool shouldReclip(covariant CustomClipper oldClipper) => false; } -class _CategoryChipData { +class _CategoryChip extends StatelessWidget { final String label; final String iconAsset; - - const _CategoryChipData({required this.label, required this.iconAsset}); -} - -class _CategoryChip extends StatelessWidget { - final _CategoryChipData data; final bool selected; final VoidCallback onTap; const _CategoryChip({ - required this.data, + required this.label, + required this.iconAsset, required this.selected, required this.onTap, }); @@ -98,16 +90,19 @@ class _CategoryChip extends StatelessWidget { final bc = Color.lerp(Colors.transparent, AppColors.purple, t)!; return Material( color: Colors.transparent, - clipBehavior: Clip.none, + clipBehavior: Clip.antiAlias, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), child: InkWell( onTap: onTap, - borderRadius: BorderRadius.circular(18), + borderRadius: BorderRadius.circular(16), child: Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), decoration: BoxDecoration( color: Colors.white, - borderRadius: BorderRadius.circular(18), - border: Border.all(color: bc, width: 1.5), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: bc, width: 2), boxShadow: const [ BoxShadow( color: AppColors.black10, @@ -120,17 +115,17 @@ class _CategoryChip extends StatelessWidget { mainAxisSize: MainAxisSize.min, children: [ SvgPicture.asset( - data.iconAsset, + iconAsset, width: 16, height: 16, colorFilter: ColorFilter.mode(fg, BlendMode.srcIn), ), const SizedBox(width: 6), Text( - data.label, + label, style: TextStyle( fontSize: 12, - fontWeight: FontWeight.w500, + fontWeight: selected ? FontWeight.w700 : FontWeight.w400, color: fg, ), ), diff --git a/game/lib/navigation_page/home_map/home_map_categories.dart b/game/lib/navigation_page/home_map/home_map_categories.dart new file mode 100644 index 00000000..551038a7 --- /dev/null +++ b/game/lib/navigation_page/home_map/home_map_categories.dart @@ -0,0 +1,51 @@ +/// Home map category definitions. +/// +/// Update [homeMapCategories] to change: +/// - the chip label + icon +/// - the pin icon asset families used by the map markers +/// +/// Category indices used throughout the map correspond to this list order. +class HomeMapCategory { + final String label; + final String chipIconAsset; + + /// Base name for unselected pins without `_color.svg` suffix + final String pinBaseAsset; + + /// Base name for selected pins without `_color.svg` suffix + final String selectedPinBaseAsset; + + const HomeMapCategory({ + required this.label, + required this.chipIconAsset, + required this.pinBaseAsset, + required this.selectedPinBaseAsset, + }); +} + +const homeMapCategories = [ + HomeMapCategory( + label: 'Food', + chipIconAsset: 'assets/icons/burger.svg', + pinBaseAsset: 'burger_pin', + selectedPinBaseAsset: 'selected_pin', + ), + HomeMapCategory( + label: 'Swag', + chipIconAsset: 'assets/icons/fund.svg', + pinBaseAsset: 'fund_pin', + selectedPinBaseAsset: 'selected_fund_pin', + ), + HomeMapCategory( + label: 'Concerts', + chipIconAsset: 'assets/icons/mic.svg', + pinBaseAsset: 'mic_pin', + selectedPinBaseAsset: 'selected_mic_pin', + ), + HomeMapCategory( + label: 'Speakers', + chipIconAsset: 'assets/icons/speaker.svg', + pinBaseAsset: 'speaker_pin', + selectedPinBaseAsset: 'selected_speaker_pin', + ), +]; diff --git a/game/lib/navigation_page/home_map/map_pin_utils.dart b/game/lib/navigation_page/home_map/map_pin_utils.dart index fc8081d7..f877f1ac 100644 --- a/game/lib/navigation_page/home_map/map_pin_utils.dart +++ b/game/lib/navigation_page/home_map/map_pin_utils.dart @@ -3,6 +3,7 @@ import 'dart:ui' as ui; import 'package:flutter/material.dart'; import 'package:flutter_svg/svg.dart' as svg; import 'package:google_maps_flutter/google_maps_flutter.dart'; +import 'package:game/navigation_page/home_map/home_map_categories.dart'; /** * Map Pin Utilities - Icon rasterization + pin asset helpers. @@ -47,13 +48,9 @@ String pinStateColorName(EventPinState state) => switch (state) { // Pin SVG asset path for a category + state. String pinAssetPath(int categoryIndex, EventPinState state) { final color = pinStateColorName(state); - final base = switch (categoryIndex) { - 0 => 'burger_pin', - 1 => 'fund_pin', - 2 => 'mic_pin', - 3 => 'speaker_pin', - _ => 'burger_pin', - }; + final base = (categoryIndex >= 0 && categoryIndex < homeMapCategories.length) + ? homeMapCategories[categoryIndex].pinBaseAsset + : homeMapCategories.first.pinBaseAsset; return 'assets/icons/${base}_$color.svg'; } @@ -67,12 +64,11 @@ String selectedPinAssetPath(int categoryIndex, EventPinState state) { return 'assets/icons/selected_pin_yellow.svg'; } final color = pinStateColorName(state); - return switch (categoryIndex) { - 1 => 'assets/icons/selected_fund_pin_$color.svg', - 2 => 'assets/icons/selected_mic_pin_$color.svg', - 3 => 'assets/icons/selected_speaker_pin_$color.svg', - _ => 'assets/icons/selected_pin_$color.svg', - }; + final base = + (categoryIndex >= 0 && categoryIndex < homeMapCategories.length) + ? homeMapCategories[categoryIndex].selectedPinBaseAsset + : homeMapCategories.first.selectedPinBaseAsset; + return 'assets/icons/${base}_$color.svg'; } // Cache key for a selected pin icon. diff --git a/game/lib/navigation_page/home_map_page.dart b/game/lib/navigation_page/home_map_page.dart index 7ae591f1..ba55ac22 100644 --- a/game/lib/navigation_page/home_map_page.dart +++ b/game/lib/navigation_page/home_map_page.dart @@ -12,6 +12,7 @@ import 'package:geolocator/geolocator.dart'; import 'package:flutter_compass/flutter_compass.dart'; import 'package:game/navigation_page/home_map/category_chips.dart'; +import 'package:game/navigation_page/home_map/home_map_categories.dart'; import 'package:game/navigation_page/home_map/home_map_search_bar.dart'; import 'package:game/navigation_page/home_map/map_pin_utils.dart'; @@ -362,7 +363,7 @@ class _HomeMapPageState extends State for (final m in _scaleMultipliers) { if (!mounted) return; final tierMap = {}; - for (var cat = 0; cat < 4; cat++) { + for (var cat = 0; cat < homeMapCategories.length; cat++) { for (final state in EventPinState.values) { final path = pinAssetPath(cat, state); await _putPinBitmapInTier( @@ -373,15 +374,20 @@ class _HomeMapPageState extends State ); } } - for (final state in EventPinState.values) { - if (!mounted) return; - final path = selectedPinAssetPath(state); - await _putPinBitmapInTier( - tierMap, - path, - selectedPinIconKey(state), - m, - ); + final selectedByPath = {}; + for (var cat = 0; cat < homeMapCategories.length; cat++) { + for (final state in EventPinState.values) { + if (!mounted) return; + final path = selectedPinAssetPath(cat, state); + final key = selectedPinIconKey(cat, state); + final cached = selectedByPath[path]; + if (cached != null) { + tierMap[key] = cached; + } else { + await _putPinBitmapInTier(tierMap, path, key, m); + selectedByPath[path] = tierMap[key]!; + } + } } byScale.add(tierMap); } @@ -412,7 +418,7 @@ class _HomeMapPageState extends State markerId: MarkerId('${pin.id}_$tier'), position: pin.position, icon: icons[_selectedMapPinId == pin.id - ? selectedPinIconKey(pin.state) + ? selectedPinIconKey(pin.categoryIndex, pin.state) : pinIconKey(pin.categoryIndex, pin.state)]!, anchor: _selectedMapPinId == pin.id ? const Offset(0.5, 0.96) From e7b437211c81d1c18630d801161b19553a67f634 Mon Sep 17 00:00:00 2001 From: Selina Ge Date: Wed, 22 Apr 2026 13:40:52 -0400 Subject: [PATCH 13/22] add yellow selected pins + fix styling --- .../assets/icons/selected_fund_pin_yellow.svg | 25 +++++ game/assets/icons/selected_mic_pin_yellow.svg | 25 +++++ .../icons/selected_speaker_pin_yellow.svg | 30 ++++++ .../home_map/category_chips.dart | 92 +++++++++---------- .../home_map/home_map_categories.dart | 12 +-- .../home_map/home_map_search_bar.dart | 50 ++++++++-- .../home_map/map_pin_utils.dart | 13 ++- 7 files changed, 178 insertions(+), 69 deletions(-) create mode 100644 game/assets/icons/selected_fund_pin_yellow.svg create mode 100644 game/assets/icons/selected_mic_pin_yellow.svg create mode 100644 game/assets/icons/selected_speaker_pin_yellow.svg diff --git a/game/assets/icons/selected_fund_pin_yellow.svg b/game/assets/icons/selected_fund_pin_yellow.svg new file mode 100644 index 00000000..ed474f20 --- /dev/null +++ b/game/assets/icons/selected_fund_pin_yellow.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/game/assets/icons/selected_mic_pin_yellow.svg b/game/assets/icons/selected_mic_pin_yellow.svg new file mode 100644 index 00000000..bd1b7c4c --- /dev/null +++ b/game/assets/icons/selected_mic_pin_yellow.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/game/assets/icons/selected_speaker_pin_yellow.svg b/game/assets/icons/selected_speaker_pin_yellow.svg new file mode 100644 index 00000000..e29438c7 --- /dev/null +++ b/game/assets/icons/selected_speaker_pin_yellow.svg @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/game/lib/navigation_page/home_map/category_chips.dart b/game/lib/navigation_page/home_map/category_chips.dart index 5fead8f4..d9261b30 100644 --- a/game/lib/navigation_page/home_map/category_chips.dart +++ b/game/lib/navigation_page/home_map/category_chips.dart @@ -27,45 +27,29 @@ class CategoryChipsRow extends StatelessWidget { @override Widget build(BuildContext context) { - return ClipPath( - clipper: const _CategoryChipsScrollClipper(), - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - clipBehavior: Clip.none, - child: Padding( - padding: const EdgeInsets.fromLTRB(0, 4, 0, 14), - child: Row( - children: [ - for (var i = 0; i < categories.length; i++) ...[ - _CategoryChip( - label: categories[i].label, - iconAsset: categories[i].chipIconAsset, - selected: selectedIndex != null && i == selectedIndex, - onTap: () => onCategorySelected(i), - ), - const SizedBox(width: 10), - ], + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + clipBehavior: Clip.none, + child: Padding( + padding: const EdgeInsets.fromLTRB(0, 4, 0, 14), + child: Row( + children: [ + for (var i = 0; i < categories.length; i++) ...[ + _CategoryChip( + label: categories[i].label, + iconAsset: categories[i].chipIconAsset, + selected: selectedIndex != null && i == selectedIndex, + onTap: () => onCategorySelected(i), + ), + const SizedBox(width: 10), ], - ), + ], ), ), ); } } -class _CategoryChipsScrollClipper extends CustomClipper { - const _CategoryChipsScrollClipper(); - - @override - Path getClip(Size size) { - return Path() - ..addRect(Rect.fromLTRB(-16, -8, size.width, size.height + 20)); - } - - @override - bool shouldReclip(covariant CustomClipper oldClipper) => false; -} - class _CategoryChip extends StatelessWidget { final String label; final String iconAsset; @@ -89,8 +73,10 @@ class _CategoryChip extends StatelessWidget { final fg = Color.lerp(AppColors.mediumGray, AppColors.purple, t)!; final bc = Color.lerp(Colors.transparent, AppColors.purple, t)!; return Material( - color: Colors.transparent, - clipBehavior: Clip.antiAlias, + color: Colors.white, + elevation: 6, + shadowColor: AppColors.black20, + clipBehavior: Clip.none, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(16), ), @@ -100,16 +86,8 @@ class _CategoryChip extends StatelessWidget { child: Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), decoration: BoxDecoration( - color: Colors.white, borderRadius: BorderRadius.circular(16), border: Border.all(color: bc, width: 2), - boxShadow: const [ - BoxShadow( - color: AppColors.black10, - blurRadius: 8, - offset: Offset(0, 3), - ), - ], ), child: Row( mainAxisSize: MainAxisSize.min, @@ -121,13 +99,29 @@ class _CategoryChip extends StatelessWidget { colorFilter: ColorFilter.mode(fg, BlendMode.srcIn), ), const SizedBox(width: 6), - Text( - label, - style: TextStyle( - fontSize: 12, - fontWeight: selected ? FontWeight.w700 : FontWeight.w400, - color: fg, - ), + Stack( + alignment: Alignment.centerLeft, + children: [ + Opacity( + opacity: 0, + child: Text( + label, + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w700, + ), + ), + ), + Text( + label, + style: TextStyle( + fontSize: 12, + fontWeight: + selected ? FontWeight.w700 : FontWeight.w400, + color: fg, + ), + ), + ], ), ], ), diff --git a/game/lib/navigation_page/home_map/home_map_categories.dart b/game/lib/navigation_page/home_map/home_map_categories.dart index 551038a7..0a3c527e 100644 --- a/game/lib/navigation_page/home_map/home_map_categories.dart +++ b/game/lib/navigation_page/home_map/home_map_categories.dart @@ -30,12 +30,6 @@ const homeMapCategories = [ pinBaseAsset: 'burger_pin', selectedPinBaseAsset: 'selected_pin', ), - HomeMapCategory( - label: 'Swag', - chipIconAsset: 'assets/icons/fund.svg', - pinBaseAsset: 'fund_pin', - selectedPinBaseAsset: 'selected_fund_pin', - ), HomeMapCategory( label: 'Concerts', chipIconAsset: 'assets/icons/mic.svg', @@ -48,4 +42,10 @@ const homeMapCategories = [ pinBaseAsset: 'speaker_pin', selectedPinBaseAsset: 'selected_speaker_pin', ), + HomeMapCategory( + label: 'Fundraisers', + chipIconAsset: 'assets/icons/fund.svg', + pinBaseAsset: 'fund_pin', + selectedPinBaseAsset: 'selected_fund_pin', + ), ]; diff --git a/game/lib/navigation_page/home_map/home_map_search_bar.dart b/game/lib/navigation_page/home_map/home_map_search_bar.dart index 2d49e3d1..b6e477a1 100644 --- a/game/lib/navigation_page/home_map/home_map_search_bar.dart +++ b/game/lib/navigation_page/home_map/home_map_search_bar.dart @@ -358,13 +358,49 @@ class _FilterCheckboxRow extends StatelessWidget { SizedBox( width: 22, height: 22, - child: Checkbox( - value: value, - onChanged: (v) => onChanged(v ?? false), - activeColor: AppColors.purple, - side: const BorderSide(color: AppColors.borderGray, width: 1.5), - materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, - visualDensity: VisualDensity.compact, + child: Stack( + alignment: Alignment.center, + children: [ + Checkbox( + value: value, + onChanged: (v) => onChanged(v ?? false), + checkColor: Colors.transparent, + fillColor: WidgetStateProperty.resolveWith( + (states) => states.contains(WidgetState.selected) + ? const Color(0x4C865C7F) + : Colors.white, + ), + side: WidgetStateBorderSide.resolveWith( + (states) => BorderSide( + color: states.contains(WidgetState.selected) + ? AppColors.purple + : AppColors.borderGray, + width: states.contains(WidgetState.selected) ? 2 : 1.5, + ), + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(4), + ), + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + visualDensity: VisualDensity.compact, + ), + if (value) + const Stack( + alignment: Alignment.center, + children: [ + Icon( + Icons.check_rounded, + size: 16, + color: AppColors.purple, + ), + Icon( + Icons.check_rounded, + size: 14, + color: AppColors.purple, + ), + ], + ), + ], ), ), const SizedBox(width: 4), diff --git a/game/lib/navigation_page/home_map/map_pin_utils.dart b/game/lib/navigation_page/home_map/map_pin_utils.dart index f877f1ac..4081138d 100644 --- a/game/lib/navigation_page/home_map/map_pin_utils.dart +++ b/game/lib/navigation_page/home_map/map_pin_utils.dart @@ -60,14 +60,10 @@ String pinIconKey(int categoryIndex, EventPinState state) => // Returns the selected-pin SVG asset path for a given state. String selectedPinAssetPath(int categoryIndex, EventPinState state) { - if (state == EventPinState.soon) { - return 'assets/icons/selected_pin_yellow.svg'; - } + final base = (categoryIndex >= 0 && categoryIndex < homeMapCategories.length) + ? homeMapCategories[categoryIndex].selectedPinBaseAsset + : homeMapCategories.first.selectedPinBaseAsset; final color = pinStateColorName(state); - final base = - (categoryIndex >= 0 && categoryIndex < homeMapCategories.length) - ? homeMapCategories[categoryIndex].selectedPinBaseAsset - : homeMapCategories.first.selectedPinBaseAsset; return 'assets/icons/${base}_$color.svg'; } @@ -92,10 +88,13 @@ const Map _kPinSvgViewBox = { 'selected_pin_yellow.svg': ui.Size(52, 77), 'selected_pin_red.svg': ui.Size(52, 77), 'selected_fund_pin_green.svg': ui.Size(64, 78), + 'selected_fund_pin_yellow.svg': ui.Size(64, 78), 'selected_fund_pin_red.svg': ui.Size(64, 78), 'selected_mic_pin_green.svg': ui.Size(64, 78), + 'selected_mic_pin_yellow.svg': ui.Size(64, 78), 'selected_mic_pin_red.svg': ui.Size(64, 78), 'selected_speaker_pin_green.svg': ui.Size(64, 78), + 'selected_speaker_pin_yellow.svg': ui.Size(64, 78), 'selected_speaker_pin_red.svg': ui.Size(64, 78), }; From 65c58d3fc64b9b8886cd347537c90a6e85723fd1 Mon Sep 17 00:00:00 2001 From: Jason Guo Date: Mon, 27 Apr 2026 15:47:23 -0400 Subject: [PATCH 14/22] campus event instead of event (challenges) --- game/lib/api/game_client_dto.dart | 24 +- .../events_draggable_sheet.dart | 229 +++++++----------- 2 files changed, 96 insertions(+), 157 deletions(-) diff --git a/game/lib/api/game_client_dto.dart b/game/lib/api/game_client_dto.dart index 123d9f56..d54fa525 100644 --- a/game/lib/api/game_client_dto.dart +++ b/game/lib/api/game_client_dto.dart @@ -1112,8 +1112,8 @@ class CampusEventDto { allDay = fields["allDay"]; locationName = fields["locationName"]; address = fields.containsKey('address') ? (fields["address"]) : null; - latitude = fields["latitude"]; - longitude = fields["longitude"]; + latitude = (fields["latitude"] as num).toDouble(); + longitude = (fields["longitude"] as num).toDouble(); categories = fields["categories"] .map( (dynamic val) => CampusEventCategoriesDto.values.byName(val)) @@ -1196,8 +1196,8 @@ class CampusEventDto { late bool allDay; late String locationName; late String? address; - late int latitude; - late int longitude; + late double latitude; + late double longitude; late List categories; late List tags; late CampusEventSourceDto source; @@ -1416,8 +1416,8 @@ class UpsertCampusEventDto { allDay = fields.containsKey('allDay') ? (fields["allDay"]) : null; locationName = fields["locationName"]; address = fields.containsKey('address') ? (fields["address"]) : null; - latitude = fields["latitude"]; - longitude = fields["longitude"]; + latitude = (fields["latitude"] as num).toDouble(); + longitude = (fields["longitude"] as num).toDouble(); checkInRadius = fields.containsKey('checkInRadius') ? (fields["checkInRadius"]) : null; categories = fields["categories"] @@ -1520,8 +1520,8 @@ class UpsertCampusEventDto { late bool? allDay; late String locationName; late String? address; - late int latitude; - late int longitude; + late double latitude; + late double longitude; late int? checkInRadius; late List categories; late List tags; @@ -1941,8 +1941,8 @@ class LocationCheckInDto { LocationCheckInDto.fromJson(Map fields) { campusEventId = fields["campusEventId"]; - latitude = fields["latitude"]; - longitude = fields["longitude"]; + latitude = (fields["latitude"] as num).toDouble(); + longitude = (fields["longitude"] as num).toDouble(); } void partialUpdate(LocationCheckInDto other) { @@ -1958,8 +1958,8 @@ class LocationCheckInDto { }); late String campusEventId; - late int latitude; - late int longitude; + late double latitude; + late double longitude; } class QrCodeCheckInDto { diff --git a/game/lib/navigation_page/events_draggable_sheet.dart b/game/lib/navigation_page/events_draggable_sheet.dart index 362f0560..7c77bf56 100644 --- a/game/lib/navigation_page/events_draggable_sheet.dart +++ b/game/lib/navigation_page/events_draggable_sheet.dart @@ -1,16 +1,10 @@ import 'dart:io'; import 'package:flutter/material.dart'; -import 'package:game/api/game_api.dart'; import 'package:game/api/game_client_dto.dart'; import 'package:game/api/geopoint.dart'; import 'package:game/constants/constants.dart'; -import 'package:game/model/challenge_model.dart'; -import 'package:game/model/event_model.dart'; -import 'package:game/model/group_model.dart'; -import 'package:game/model/tracker_model.dart'; -import 'package:game/model/user_model.dart'; -import 'package:game/utils/utility_functions.dart'; +import 'package:game/model/campus_event_model.dart'; import 'package:provider/provider.dart'; import 'package:velocity_x/velocity_x.dart'; @@ -37,8 +31,6 @@ class EventsDraggableSheet extends StatefulWidget { class _EventsDraggableSheetState extends State { final DraggableScrollableController _sheetController = DraggableScrollableController(); - bool _clearingCurrentEvent = false; - String? _lastRequestedClearEventId; /// User location for distance tie-break (first challenge coords vs. user). GeoPoint? _currentUserLocation; @@ -47,6 +39,10 @@ class _EventsDraggableSheetState extends State { void initState() { super.initState(); _loadUserLocation(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + context.read().requestCampusEvents(limit: 50); + }); } Future _loadUserLocation() async { @@ -66,156 +62,112 @@ class _EventsDraggableSheetState extends State { super.dispose(); } - String _hostLabel(EventDto event, UserModel userModel) { - final oid = event.initialOrganizationId; - if (oid != null && userModel.orgData.containsKey(oid)) { - return userModel.orgData[oid]?.name ?? 'Host'; - } - for (final org in userModel.orgData.values) { - if (org.events?.contains(event.id) ?? false) { - return org.name ?? 'Host'; - } + String _hostLabel(CampusEventDto event) { + final organizer = event.organizerName?.trim(); + if (organizer != null && organizer.isNotEmpty) { + return organizer; } - return 'Host'; + return 'Cornell'; } - String _perkLine(EventDto event) { + String _perkLine(CampusEventDto event) { final d = event.description?.trim(); if (d != null && d.isNotEmpty) { final first = d.split(RegExp(r'\n')).first.trim(); if (first.length <= 48) return first; } - if (event.category != null) { - return friendlyCategory[event.category!] ?? 'Event'; + if (event.tags.isNotEmpty) { + return event.tags.first; } - return 'Event'; + return event.locationName; } - String _timeLabel(EventDto event) { + String _timeLabel(CampusEventDto event) { + final end = _parseCampusEventTime(event.endTime); + if (end == null) return ''; + final diff = end.difference(DateTime.now()); + if (diff.isNegative) return 'ended'; + if (diff.inDays > 0) return 'in ${diff.inDays}d'; + if (diff.inHours > 0) return 'in ${diff.inHours} hours'; + if (diff.inMinutes > 0) return 'in ${diff.inMinutes} min'; + return 'soon'; + } + + DateTime? _parseCampusEventTime(String? raw) { + if (raw == null || raw.trim().isEmpty) return null; + final trimmed = raw.trim(); + final iso = DateTime.tryParse(trimmed); + if (iso != null) return iso.toLocal(); try { - final end = HttpDate.parse(event.endTime ?? ''); - final diff = end.difference(DateTime.now()); - if (diff.isNegative) return 'ended'; - if (diff.inDays > 0) return 'in ${diff.inDays}d'; - if (diff.inHours > 0) return 'in ${diff.inHours} hours'; - if (diff.inMinutes > 0) return 'in ${diff.inMinutes} min'; - return 'soon'; + return HttpDate.parse(trimmed).toLocal(); } catch (_) { - return ''; + return null; } } - List _filteredEvents( - UserModel userModel, - EventModel eventModel, - TrackerModel trackerModel, - ChallengeModel challengeModel, - GroupModel groupModel, - ) { - final allowedEventIds = userModel.getAvailableEventIds(); - final events = allowedEventIds - .map((id) => eventModel.getEventById(id)) - .filter((e) => e != null) - .map((e) => e!) - .toList(); + bool _matchesCampusEventFilters(CampusEventDto event) { + final matchesLocation = widget.locations == null || + widget.locations!.isEmpty || + widget.locations!.contains(event.locationName); + + final matchesCategory = widget.categories == null || + widget.categories!.isEmpty || + event.categories.any((c) => widget.categories!.contains(c.name)); + + final searchTerm = widget.searchText?.trim().toLowerCase() ?? ''; + final matchesSearch = searchTerm.isEmpty || + event.title.toLowerCase().contains(searchTerm) || + event.description.toLowerCase().contains(searchTerm) || + event.locationName.toLowerCase().contains(searchTerm) || + event.tags.any((tag) => tag.toLowerCase().contains(searchTerm)); - final List<({EventDto event, DateTime end, double? distanceMeters})> rows = - []; + return matchesLocation && matchesCategory && matchesSearch; + } + + List _filteredEvents(CampusEventModel campusEventModel) { + final events = campusEventModel.currentList?.events ?? + campusEventModel.allCachedEvents; + final rows = <({CampusEventDto event, DateTime start, double? distance})>[]; for (final event in events) { - final tracker = trackerModel.trackerByEventId(event.id); - final numberCompleted = tracker?.prevChallenges.length ?? 0; - final complete = numberCompleted == (event.challenges?.length ?? 0); - DateTime endtime; - try { - endtime = HttpDate.parse(event.endTime ?? ''); - } catch (_) { + final start = _parseCampusEventTime(event.startTime); + final end = _parseCampusEventTime(event.endTime); + if (start == null || end == null) { + continue; + } + if (end.isBefore(DateTime.now())) { + continue; + } + if (!_matchesCampusEventFilters(event)) { continue; } - final timeTillExpire = endtime.difference(DateTime.now()); - if (event.isJourney == true) continue; - if (event.indexable == false) continue; - if (event.challenges == null || event.challenges!.isEmpty) continue; - final challenge = challengeModel.getChallengeById(event.challenges![0]); - if (challenge == null) continue; - - final challengeLocation = challenge.location?.name ?? ''; - final matches = eventMatchesFilters( - event: event, - difficulty: widget.difficulty, - locations: widget.locations, - categories: widget.categories, - searchText: widget.searchText, - challengeLocation: challengeLocation, - challengeName: challenge.name, - ); - if (!complete && !timeTillExpire.isNegative && matches) { - double? distanceMeters; - final userLoc = _currentUserLocation; - if (userLoc != null && - challenge.latF != null && - challenge.longF != null) { - try { - distanceMeters = userLoc.distanceTo( - GeoPoint(challenge.latF!, challenge.longF!, 0), - ); - } catch (_) { - distanceMeters = null; - } + double? distanceMeters; + final userLoc = _currentUserLocation; + if (userLoc != null) { + try { + distanceMeters = userLoc.distanceTo( + GeoPoint(event.latitude.toDouble(), event.longitude.toDouble(), 0), + ); + } catch (_) { + distanceMeters = null; } - rows.add((event: event, end: endtime, distanceMeters: distanceMeters)); } + rows.add((event: event, start: start, distance: distanceMeters)); } rows.sort((a, b) { - final byEnd = a.end.compareTo(b.end); - if (byEnd != 0) return byEnd; - // Secondary: nearer first; unknown distance last - if (a.distanceMeters == null && b.distanceMeters == null) return 0; - if (a.distanceMeters == null) return 1; - if (b.distanceMeters == null) return -1; - return a.distanceMeters!.compareTo(b.distanceMeters!); + final byStart = a.start.compareTo(b.start); + if (byStart != 0) return byStart; + if (a.distance == null && b.distance == null) return 0; + if (a.distance == null) return 1; + if (b.distance == null) return -1; + return a.distance!.compareTo(b.distance!); }); return rows.map((r) => r.event).toList(); } - void _maybeClearCurrentEvent( - GroupModel groupModel, - List filteredEvents, - ApiClient apiClient, - ) { - final currentEventId = groupModel.curEventId; - if (currentEventId == null || currentEventId.isEmpty) { - _lastRequestedClearEventId = null; - return; - } - - final stillValid = - filteredEvents.any((event) => event.id == currentEventId); - if (stillValid) { - _lastRequestedClearEventId = null; - return; - } - - if (_clearingCurrentEvent || _lastRequestedClearEventId == currentEventId) { - return; - } - - _lastRequestedClearEventId = currentEventId; - _clearingCurrentEvent = true; - - WidgetsBinding.instance.addPostFrameCallback((_) async { - try { - await apiClient.serverApi - ?.setCurrentEvent(SetCurrentEventDto(eventId: "")); - } finally { - _clearingCurrentEvent = false; - } - }); - } - Future _collapseToPeek() async { if (!_sheetController.isAttached) return; await _sheetController.animateTo( @@ -243,26 +195,13 @@ class _EventsDraggableSheetState extends State { _sheetController.isAttached ? _sheetController.size : 0.26; final showExpandedChrome = extent > 0.88; - return Consumer6( + return Consumer( builder: ( context, - userModel, - eventModel, - groupModel, - trackerModel, - challengeModel, - apiClient, + campusEventModel, _, ) { - final events = _filteredEvents( - userModel, - eventModel, - trackerModel, - challengeModel, - groupModel, - ); - _maybeClearCurrentEvent(groupModel, events, apiClient); + final events = _filteredEvents(campusEventModel); final count = events.length; return ClipRRect( @@ -361,7 +300,7 @@ class _EventsDraggableSheetState extends State { ) else ...events.map((event) { - final host = _hostLabel(event, userModel); + final host = _hostLabel(event); final perk = _perkLine(event); final timeStr = _timeLabel(event); @@ -373,7 +312,7 @@ class _EventsDraggableSheetState extends State { 12, ), child: _EventCard( - eventName: event.name ?? 'Event', + eventName: event.title, hostName: host, perkLine: perk, timeLabel: timeStr, From 4fac5270c77693e959c623287856a70119a8e56c Mon Sep 17 00:00:00 2001 From: Jason Guo Date: Mon, 27 Apr 2026 17:13:37 -0400 Subject: [PATCH 15/22] pulled selina branch into mine --- game/lib/navigation_page/home_map_page.dart | 2 ++ game/lib/navigation_page/home_navbar.dart | 10 +++++++ .../navigation_page/search_filter_home.dart | 30 ++++--------------- 3 files changed, 18 insertions(+), 24 deletions(-) diff --git a/game/lib/navigation_page/home_map_page.dart b/game/lib/navigation_page/home_map_page.dart index ba55ac22..8b4b351c 100644 --- a/game/lib/navigation_page/home_map_page.dart +++ b/game/lib/navigation_page/home_map_page.dart @@ -11,6 +11,7 @@ import 'package:game/constants/constants.dart'; import 'package:geolocator/geolocator.dart'; import 'package:flutter_compass/flutter_compass.dart'; +import 'package:game/navigation_page/events_draggable_sheet.dart'; import 'package:game/navigation_page/home_map/category_chips.dart'; import 'package:game/navigation_page/home_map/home_map_categories.dart'; import 'package:game/navigation_page/home_map/home_map_search_bar.dart'; @@ -472,6 +473,7 @@ class _HomeMapPageState extends State }, onRecenter: _recenterOnUser, ), + const EventsDraggableSheet(), ], ), ); diff --git a/game/lib/navigation_page/home_navbar.dart b/game/lib/navigation_page/home_navbar.dart index 01592213..9d1681e0 100644 --- a/game/lib/navigation_page/home_navbar.dart +++ b/game/lib/navigation_page/home_navbar.dart @@ -27,9 +27,11 @@ class HomeNavBar extends StatefulWidget { List? myCategories; String? mySearchText; final int initialTab; + final ValueChanged? onTabChanged; HomeNavBar({ Key? key, this.initialTab = 0, + this.onTabChanged, String? difficulty, List? locations, List? categories, @@ -58,6 +60,7 @@ class _HomeNavbarState extends State with TickerProviderStateMixin { vsync: this, initialIndex: widget.initialTab, ); + _tabController.addListener(_handleTabChange); // Onboarding: Register showcase scope for highlighting Journeys tab (step 2) // Hot restart fix: unregister old instance if exists @@ -79,10 +82,16 @@ class _HomeNavbarState extends State with TickerProviderStateMixin { @override void dispose() { _removeBearOverlay(); + _tabController.removeListener(_handleTabChange); _tabController.dispose(); super.dispose(); } + void _handleTabChange() { + if (_tabController.indexIsChanging) return; + widget.onTabChanged?.call(_tabController.index); + } + void _showBearOverlay() { _removeBearOverlay(); // Remove existing if any @@ -102,6 +111,7 @@ class _HomeNavbarState extends State with TickerProviderStateMixin { Provider.of(context, listen: false).completeStep2(); // Onboarding: Switch to Journeys tab (index 1) to trigger step 3 explanation overlay _tabController.animateTo(1); + widget.onTabChanged?.call(1); }, ), ); diff --git a/game/lib/navigation_page/search_filter_home.dart b/game/lib/navigation_page/search_filter_home.dart index c0909fb8..cb04602d 100644 --- a/game/lib/navigation_page/search_filter_home.dart +++ b/game/lib/navigation_page/search_filter_home.dart @@ -1,11 +1,6 @@ -import 'dart:io'; - import 'package:flutter/material.dart'; import 'package:game/constants/constants.dart'; -import 'package:game/journeys/journeys_page.dart'; -import 'package:game/challenges/challenges_page.dart'; import 'package:game/journeys/filter_form.dart'; -import 'package:game/navigation_page/events_draggable_sheet.dart'; import 'package:game/navigation_page/home_navbar.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:flutter/services.dart'; @@ -183,25 +178,12 @@ class _SearchFilterBarState extends State ), // Home tab content with events bottom sheet over Challenges/Journeys Expanded( - child: Stack( - fit: StackFit.expand, - children: [ - Positioned.fill( - child: HomeNavBar( - initialTab: widget.initialHomeTab, - difficulty: selectedDifficulty, - locations: selectedLocations, - categories: selectedCategories, - searchText: searchText, - ), - ), - EventsDraggableSheet( - difficulty: selectedDifficulty, - locations: selectedLocations, - categories: selectedCategories, - searchText: searchText, - ), - ], + child: HomeNavBar( + initialTab: widget.initialHomeTab, + difficulty: selectedDifficulty, + locations: selectedLocations, + categories: selectedCategories, + searchText: searchText, ), ), ], From 93807f3951a1ba40c46162aa51142f183a93a900 Mon Sep 17 00:00:00 2001 From: Jason Guo Date: Mon, 27 Apr 2026 17:19:49 -0400 Subject: [PATCH 16/22] replace mock data with real events --- game/lib/navigation_page/home_map_page.dart | 177 ++++++++++++-------- 1 file changed, 104 insertions(+), 73 deletions(-) diff --git a/game/lib/navigation_page/home_map_page.dart b/game/lib/navigation_page/home_map_page.dart index 8b4b351c..b0eb37b1 100644 --- a/game/lib/navigation_page/home_map_page.dart +++ b/game/lib/navigation_page/home_map_page.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:io'; import 'dart:ui' as ui; import 'package:flutter/material.dart'; @@ -6,10 +7,13 @@ import 'package:flutter/services.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:google_maps_flutter/google_maps_flutter.dart'; import 'package:pointer_interceptor/pointer_interceptor.dart'; +import 'package:provider/provider.dart'; +import 'package:game/api/game_client_dto.dart'; import 'package:game/api/geopoint.dart'; import 'package:game/constants/constants.dart'; import 'package:geolocator/geolocator.dart'; import 'package:flutter_compass/flutter_compass.dart'; +import 'package:game/model/campus_event_model.dart'; import 'package:game/navigation_page/events_draggable_sheet.dart'; import 'package:game/navigation_page/home_map/category_chips.dart'; @@ -25,63 +29,19 @@ import 'package:game/navigation_page/home_map/map_pin_utils.dart'; * are split into dedicated files under `navigation_page/home_map/`. */ -// TODO: Replace with real event data from API -const _kExamplePins = [ - ExampleMapPin( - id: 'e1', - position: LatLng(42.4482, -76.4880), - categoryIndex: 0, - state: EventPinState.later, - ), - ExampleMapPin( - id: 'e2', - position: LatLng(42.4465, -76.4865), - categoryIndex: 0, - state: EventPinState.soon, - ), - ExampleMapPin( - id: 'e3', - position: LatLng(42.4475, -76.4890), - categoryIndex: 0, - state: EventPinState.now, - ), - ExampleMapPin( - id: 'e4', - position: LatLng(42.4490, -76.4870), - categoryIndex: 1, - state: EventPinState.later, - ), - ExampleMapPin( - id: 'e5', - position: LatLng(42.4455, -76.4855), - categoryIndex: 1, - state: EventPinState.now, - ), - ExampleMapPin( - id: 'e6', - position: LatLng(42.4470, -76.4900), - categoryIndex: 2, - state: EventPinState.soon, - ), - ExampleMapPin( - id: 'e7', - position: LatLng(42.4485, -76.4860), - categoryIndex: 2, - state: EventPinState.later, - ), - ExampleMapPin( - id: 'e8', - position: LatLng(42.4460, -76.4885), - categoryIndex: 3, - state: EventPinState.now, - ), - ExampleMapPin( - id: 'e9', - position: LatLng(42.4495, -76.4865), - categoryIndex: 3, - state: EventPinState.soon, - ), -]; +class _CampusEventMapPin { + final String id; + final LatLng position; + final int categoryIndex; + final EventPinState state; + + const _CampusEventMapPin({ + required this.id, + required this.position, + required this.categoryIndex, + required this.state, + }); +} class HomeMapPage extends StatefulWidget { const HomeMapPage({super.key}); @@ -281,8 +241,81 @@ class _HomeMapPageState extends State ); } - Set _mergedMarkersForMap() { - final pins = _markersForSelectedCategory(); + DateTime? _parseEventTime(String? raw) { + if (raw == null || raw.trim().isEmpty) return null; + final trimmed = raw.trim(); + final iso = DateTime.tryParse(trimmed); + if (iso != null) return iso.toLocal(); + try { + return HttpDate.parse(trimmed).toLocal(); + } catch (_) { + return null; + } + } + + EventPinState _pinStateForEvent(CampusEventDto event) { + final now = DateTime.now(); + final start = _parseEventTime(event.startTime); + final end = _parseEventTime(event.endTime); + if (start != null && end != null && start.isBefore(now) && end.isAfter(now)) { + return EventPinState.now; + } + if (start == null) return EventPinState.later; + final diff = start.difference(now); + if (!diff.isNegative && diff <= const Duration(hours: 3)) { + return EventPinState.soon; + } + return EventPinState.later; + } + + int _mapCategoryIndex(CampusEventDto event) { + final category = event.categories.isNotEmpty + ? event.categories.first + : CampusEventCategoriesDto.OTHER; + switch (category) { + case CampusEventCategoriesDto.SOCIAL: + case CampusEventCategoriesDto.CULTURAL: + return 1; // Concerts + case CampusEventCategoriesDto.ARTS: + case CampusEventCategoriesDto.ACADEMIC: + case CampusEventCategoriesDto.CAREER: + return 2; // Speakers + case CampusEventCategoriesDto.COMMUNITY: + case CampusEventCategoriesDto.OTHER: + return 3; // Fundraisers + case CampusEventCategoriesDto.ATHLETIC: + case CampusEventCategoriesDto.WELLNESS: + return 0; // Food bucket (fallback) + } + } + + List _activeCampusEvents(CampusEventModel campusEventModel) { + return campusEventModel.currentList?.events ?? campusEventModel.allCachedEvents; + } + + List<_CampusEventMapPin> _pinsMatchingFilter(List events) { + final now = DateTime.now(); + final pins = <_CampusEventMapPin>[]; + for (final event in events) { + final end = _parseEventTime(event.endTime); + if (end != null && end.isBefore(now)) continue; + + final pin = _CampusEventMapPin( + id: event.id, + position: LatLng(event.latitude, event.longitude), + categoryIndex: _mapCategoryIndex(event), + state: _pinStateForEvent(event), + ); + if (_selectedCategoryIndex == null || + _selectedCategoryIndex == pin.categoryIndex) { + pins.add(pin); + } + } + return pins; + } + + Set _mergedMarkersForMap(List events) { + final pins = _markersForSelectedCategory(events); final at = _currentLocation == null ? _mapDefaultCenter : LatLng(_currentLocation!.lat, _currentLocation!.long); @@ -314,7 +347,11 @@ class _HomeMapPageState extends State setState(() => _compassHeading = event.heading!); } }); - WidgetsBinding.instance.addPostFrameCallback((_) => _loadPinIcons()); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + context.read().requestCampusEvents(limit: 50); + _loadPinIcons(); + }); } @override @@ -327,21 +364,13 @@ class _HomeMapPageState extends State void _runPinBounce() { if (_pinIconsByScale == null) return; - final pins = _pinsMatchingFilter(); + final campusEventModel = context.read(); + final pins = _pinsMatchingFilter(_activeCampusEvents(campusEventModel)); if (pins.isEmpty) return; _pinBounceController.duration = const Duration(milliseconds: 300); _pinBounceController.forward(from: 0); } - List _pinsMatchingFilter() { - return [ - for (final pin in _kExamplePins) - if (_selectedCategoryIndex == null || - pin.categoryIndex == _selectedCategoryIndex) - pin, - ]; - } - Future _putPinBitmapInTier( Map tierMap, String path, @@ -404,8 +433,8 @@ class _HomeMapPageState extends State } } - Set _markersForSelectedCategory() { - final pins = _pinsMatchingFilter(); + Set _markersForSelectedCategory(List events) { + final pins = _pinsMatchingFilter(events); if (pins.isEmpty) return {}; final byScale = _pinIconsByScale; if (byScale == null) return {}; @@ -436,6 +465,8 @@ class _HomeMapPageState extends State @override Widget build(BuildContext context) { + final campusEventModel = context.watch(); + final campusEvents = _activeCampusEvents(campusEventModel); return Scaffold( body: Stack( children: [ @@ -453,7 +484,7 @@ class _HomeMapPageState extends State target: _mapInitialCameraTarget, zoom: _kHomeMapZoom, ), - markers: _mergedMarkersForMap(), + markers: _mergedMarkersForMap(campusEvents), onTap: (_) { FocusManager.instance.primaryFocus?.unfocus(); final hadSelection = _selectedMapPinId != null; From ca11749e01cb52d22218d330c025d9066b29a397 Mon Sep 17 00:00:00 2001 From: Jason Guo Date: Mon, 27 Apr 2026 17:30:06 -0400 Subject: [PATCH 17/22] go functionality and popup --- .../events_draggable_sheet.dart | 384 ++++++++++++++++-- game/lib/navigation_page/home_map_page.dart | 65 ++- 2 files changed, 404 insertions(+), 45 deletions(-) diff --git a/game/lib/navigation_page/events_draggable_sheet.dart b/game/lib/navigation_page/events_draggable_sheet.dart index 7c77bf56..5b240a6f 100644 --- a/game/lib/navigation_page/events_draggable_sheet.dart +++ b/game/lib/navigation_page/events_draggable_sheet.dart @@ -15,6 +15,10 @@ class EventsDraggableSheet extends StatefulWidget { final List? locations; final List? categories; final String? searchText; + final ValueChanged? onGoToEvent; + final String? selectedEventId; + final String? routedEventId; + final VoidCallback? onClearSelection; const EventsDraggableSheet({ super.key, @@ -22,6 +26,10 @@ class EventsDraggableSheet extends StatefulWidget { this.locations, this.categories, this.searchText, + this.onGoToEvent, + this.selectedEventId, + this.routedEventId, + this.onClearSelection, }); @override @@ -93,6 +101,63 @@ class _EventsDraggableSheetState extends State { return 'soon'; } + String _formatHourMinute(DateTime dt) { + final hour12 = dt.hour % 12 == 0 ? 12 : dt.hour % 12; + final minute = dt.minute == 0 ? '' : ':${dt.minute.toString().padLeft(2, '0')}'; + final ampm = dt.hour >= 12 ? 'pm' : 'am'; + return '$hour12$minute$ampm'; + } + + String _timeRangeLabel(CampusEventDto event) { + final start = _parseCampusEventTime(event.startTime); + final end = _parseCampusEventTime(event.endTime); + if (start == null || end == null) return ''; + final startHour = start.hour % 12 == 0 ? 12 : start.hour % 12; + final endHour = end.hour % 12 == 0 ? 12 : end.hour % 12; + final endSuffix = end.hour >= 12 ? 'pm' : 'am'; + if (start.minute == 0 && + end.minute == 0 && + (start.hour >= 12) == (end.hour >= 12)) { + return '$startHour-$endHour$endSuffix'; + } + return '${_formatHourMinute(start)}-${_formatHourMinute(end)}'; + } + + double? _distanceMetersToEvent(CampusEventDto event) { + final userLoc = _currentUserLocation; + if (userLoc == null) return null; + try { + return userLoc.distanceTo( + GeoPoint(event.latitude.toDouble(), event.longitude.toDouble(), 0), + ); + } catch (_) { + return null; + } + } + + String _walkMinutesLabel(CampusEventDto event) { + final distanceMeters = _distanceMetersToEvent(event); + if (distanceMeters == null) return ''; + const walkingMetersPerMinute = 84.0; // ~1.4 m/s + final min = (distanceMeters / walkingMetersPerMinute).round().clamp(1, 999); + return '$min min'; + } + + String _milesLabel(CampusEventDto event) { + final distanceMeters = _distanceMetersToEvent(event); + if (distanceMeters == null) return ''; + final miles = distanceMeters / 1609.344; + return '${miles.toStringAsFixed(1)} mi'; + } + + CampusEventDto? _eventById(List events, String? id) { + if (id == null) return null; + for (final event in events) { + if (event.id == id) return event; + } + return null; + } + DateTime? _parseCampusEventTime(String? raw) { if (raw == null || raw.trim().isEmpty) return null; final trimmed = raw.trim(); @@ -201,8 +266,21 @@ class _EventsDraggableSheetState extends State { campusEventModel, _, ) { + final allEvents = campusEventModel.currentList?.events ?? + campusEventModel.allCachedEvents; final events = _filteredEvents(campusEventModel); final count = events.length; + final selectedEvent = _eventById( + allEvents, + widget.selectedEventId, + ); + final routedEvent = _eventById( + allEvents, + widget.routedEventId, + ); + final focusedEvent = routedEvent ?? selectedEvent; + final isRoutedView = + focusedEvent != null && routedEvent?.id == focusedEvent.id; return ClipRRect( borderRadius: const BorderRadius.vertical( @@ -234,7 +312,7 @@ class _EventsDraggableSheetState extends State { ), ), ), - if (showExpandedChrome) + if (showExpandedChrome && focusedEvent == null) Padding( padding: const EdgeInsets.only( left: 4, @@ -270,55 +348,76 @@ class _EventsDraggableSheetState extends State { ], ), ), - Padding( - padding: const EdgeInsets.fromLTRB(20, 4, 20, 8), - child: Text( - '$count Events Happening Soon', - style: const TextStyle( - fontSize: 17, - fontWeight: FontWeight.w600, - fontFamily: 'Poppins', - color: AppColors.darkText, - ), - ), - ), - if (events.isEmpty) + if (focusedEvent == null) ...[ Padding( - padding: const EdgeInsets.fromLTRB( - 20, - 12, - 20, - 24, - ), + padding: const EdgeInsets.fromLTRB(20, 4, 20, 8), child: Text( - 'No events match your filters.', - style: TextStyle( - color: AppColors.grayText, + '$count Events Happening Soon', + style: const TextStyle( + fontSize: 17, + fontWeight: FontWeight.w600, fontFamily: 'Poppins', + color: AppColors.darkText, ), ), - ) - else - ...events.map((event) { - final host = _hostLabel(event); - final perk = _perkLine(event); - final timeStr = _timeLabel(event); - - return Padding( + ), + if (events.isEmpty) + Padding( padding: const EdgeInsets.fromLTRB( - 16, - 0, - 16, + 20, 12, + 20, + 24, ), - child: _EventCard( - eventName: event.title, - hostName: host, - perkLine: perk, - timeLabel: timeStr, + child: Text( + 'No events match your filters.', + style: TextStyle( + color: AppColors.grayText, + fontFamily: 'Poppins', + ), ), - ); - }), + ) + else + ...events.map((event) { + final host = _hostLabel(event); + final perk = _perkLine(event); + final timeStr = _timeLabel(event); + + return Padding( + padding: const EdgeInsets.fromLTRB( + 16, + 0, + 16, + 12, + ), + child: _EventCard( + onGo: () => widget.onGoToEvent?.call(event), + eventName: event.title, + hostName: host, + perkLine: perk, + timeLabel: timeStr, + ), + ); + }), + ] else ...[ + Padding( + padding: const EdgeInsets.fromLTRB(16, 4, 16, 0), + child: _FocusedEventCard( + event: focusedEvent, + isRoutedView: isRoutedView, + walkLabel: _walkMinutesLabel(focusedEvent), + milesLabel: _milesLabel(focusedEvent), + timeRangeLabel: _timeRangeLabel(focusedEvent), + hostLabel: _hostLabel(focusedEvent), + onGo: isRoutedView + ? null + : () => widget.onGoToEvent?.call( + focusedEvent, + ), + onClose: widget.onClearSelection, + ), + ), + ], ], ), ), @@ -334,12 +433,14 @@ class _EventsDraggableSheetState extends State { } class _EventCard extends StatelessWidget { + final VoidCallback onGo; final String eventName; final String hostName; final String perkLine; final String timeLabel; const _EventCard({ + required this.onGo, required this.eventName, required this.hostName, required this.perkLine, @@ -370,6 +471,8 @@ class _EventCard extends StatelessWidget { Expanded( child: Text( perkLine, + maxLines: 1, + overflow: TextOverflow.ellipsis, style: const TextStyle( fontSize: 13, color: AppColors.purple, @@ -393,6 +496,8 @@ class _EventCard extends StatelessWidget { const SizedBox(height: 8), Text( eventName, + maxLines: 1, + overflow: TextOverflow.ellipsis, style: const TextStyle( fontSize: 18, fontWeight: FontWeight.w600, @@ -445,7 +550,7 @@ class _EventCard extends StatelessWidget { color: AppColors.purple, borderRadius: BorderRadius.circular(10), child: InkWell( - onTap: () {}, + onTap: onGo, borderRadius: BorderRadius.circular(10), child: const Padding( padding: EdgeInsets.symmetric( @@ -472,3 +577,198 @@ class _EventCard extends StatelessWidget { ); } } + +class _FocusedEventCard extends StatelessWidget { + final CampusEventDto event; + final bool isRoutedView; + final String walkLabel; + final String milesLabel; + final String timeRangeLabel; + final String hostLabel; + final VoidCallback? onGo; + final VoidCallback? onClose; + + const _FocusedEventCard({ + required this.event, + required this.isRoutedView, + required this.walkLabel, + required this.milesLabel, + required this.timeRangeLabel, + required this.hostLabel, + this.onGo, + this.onClose, + }); + + @override + Widget build(BuildContext context) { + return Material( + color: Colors.white, + elevation: 2, + shadowColor: Colors.black26, + borderRadius: BorderRadius.circular(14), + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 14, 16, 14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: isRoutedView + ? Row( + children: [ + const Icon(Icons.directions_walk, size: 21), + const SizedBox(width: 8), + Text( + [walkLabel, milesLabel] + .where((s) => s.isNotEmpty) + .join(' \u2022 '), + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.w700, + fontFamily: 'Poppins', + color: AppColors.darkText, + ), + ), + ], + ) + : Text( + event.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 22, + fontWeight: FontWeight.w700, + fontFamily: 'Poppins', + color: AppColors.darkText, + ), + ), + ), + IconButton( + onPressed: onClose, + icon: const Icon( + Icons.cancel, + size: 36, + color: AppColors.black30, + ), + ), + ], + ), + if (!isRoutedView) ...[ + const SizedBox(height: 4), + Row( + children: [ + const Icon(Icons.directions_walk, size: 20), + const SizedBox(width: 6), + Text( + walkLabel, + style: const TextStyle(fontSize: 17, fontFamily: 'Poppins'), + ), + const SizedBox(width: 20), + const Icon(Icons.location_on_outlined, size: 20), + const SizedBox(width: 6), + Expanded( + child: Text( + event.locationName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 17, + fontFamily: 'Poppins', + ), + ), + ), + const SizedBox(width: 10), + const Icon(Icons.access_time, size: 20), + const SizedBox(width: 6), + Text( + timeRangeLabel, + style: const TextStyle(fontSize: 17, fontFamily: 'Poppins'), + ), + ], + ), + ], + const SizedBox(height: 10), + Text( + event.description, + maxLines: isRoutedView ? 2 : 3, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 16, + height: 1.4, + fontFamily: 'Poppins', + color: AppColors.darkText, + ), + ), + if (isRoutedView) ...[ + const SizedBox(height: 8), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Text( + 'Hosted by', + style: TextStyle( + fontSize: 19, + color: AppColors.grayText, + fontFamily: 'Poppins', + ), + ), + const SizedBox(width: 6), + Container( + width: 18, + height: 18, + decoration: const BoxDecoration( + color: AppColors.black30, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 6), + Flexible( + child: Text( + hostLabel, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 19, + fontFamily: 'Poppins', + color: AppColors.darkText, + ), + ), + ), + ], + ), + ] else ...[ + const SizedBox(height: 12), + SizedBox( + width: double.infinity, + child: Material( + color: AppColors.primaryRed, + borderRadius: BorderRadius.circular(14), + child: InkWell( + onTap: onGo, + borderRadius: BorderRadius.circular(14), + child: const Padding( + padding: EdgeInsets.symmetric(vertical: 13), + child: Center( + child: Text( + 'GO!', + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.w700, + fontSize: 42 / 2, + fontFamily: 'Poppins', + ), + ), + ), + ), + ), + ), + ), + ], + ], + ), + ), + ); + } +} diff --git a/game/lib/navigation_page/home_map_page.dart b/game/lib/navigation_page/home_map_page.dart index b0eb37b1..3c144b43 100644 --- a/game/lib/navigation_page/home_map_page.dart +++ b/game/lib/navigation_page/home_map_page.dart @@ -56,6 +56,7 @@ class _HomeMapPageState extends State int? _selectedCategoryIndex; String? _selectedMapPinId; + String? _goToEventId; // Pin icon cache for animated bounce tiers List>? _pinIconsByScale; @@ -200,6 +201,16 @@ class _HomeMapPageState extends State await _animateHomeCameraTo(focus, followUser: false); } + Future _goToEvent(CampusEventDto event) async { + setState(() { + _selectedCategoryIndex = null; + _selectedMapPinId = event.id; + _goToEventId = event.id; + }); + await _animateCameraToBiased(LatLng(event.latitude, event.longitude)); + _runPinBounce(); + } + Future _startHomePositionStream() async { if (_positionSubscription != null) return; final GoogleMapController controller = await _mapCompleter.future; @@ -334,6 +345,37 @@ class _HomeMapPageState extends State }; } + CampusEventDto? _selectedGoToEvent(List events) { + final id = _goToEventId; + if (id == null) return null; + for (final event in events) { + if (event.id == id) return event; + } + return null; + } + + Set _routePolyline(List events) { + final selectedEvent = _selectedGoToEvent(events); + final userLoc = _currentLocation; + if (selectedEvent == null || userLoc == null) return const {}; + return { + Polyline( + polylineId: const PolylineId('event_route'), + color: AppColors.purple, + width: 5, + geodesic: true, + patterns: [ + PatternItem.dash(20), + PatternItem.gap(12), + ], + points: [ + LatLng(userLoc.lat, userLoc.long), + LatLng(selectedEvent.latitude, selectedEvent.longitude), + ], + ), + }; + } + @override void initState() { super.initState(); @@ -455,7 +497,10 @@ class _HomeMapPageState extends State : const Offset(0.5, 0.52), zIndex: _selectedMapPinId == pin.id ? 3 : 2, onTap: () { - setState(() => _selectedMapPinId = pin.id); + setState(() { + _selectedMapPinId = pin.id; + _goToEventId = null; + }); unawaited(_animateCameraToBiased(pin.position)); _runPinBounce(); }, @@ -485,10 +530,14 @@ class _HomeMapPageState extends State zoom: _kHomeMapZoom, ), markers: _mergedMarkersForMap(campusEvents), + polylines: _routePolyline(campusEvents), onTap: (_) { FocusManager.instance.primaryFocus?.unfocus(); final hadSelection = _selectedMapPinId != null; - setState(() => _selectedMapPinId = null); + setState(() { + _selectedMapPinId = null; + _goToEventId = null; + }); if (hadSelection) _runPinBounce(); }, ), @@ -504,7 +553,17 @@ class _HomeMapPageState extends State }, onRecenter: _recenterOnUser, ), - const EventsDraggableSheet(), + EventsDraggableSheet( + onGoToEvent: _goToEvent, + selectedEventId: _selectedMapPinId, + routedEventId: _goToEventId, + onClearSelection: () { + setState(() { + _selectedMapPinId = null; + _goToEventId = null; + }); + }, + ), ], ), ); From 5e057f70275ecb3fb5947fc44e10e1630ab31888 Mon Sep 17 00:00:00 2001 From: Jason Guo Date: Mon, 27 Apr 2026 17:39:31 -0400 Subject: [PATCH 18/22] move tbd loc events to bottom --- .../events_draggable_sheet.dart | 20 +++++++++++++++++++ game/lib/navigation_page/home_map_page.dart | 12 +++++++++++ 2 files changed, 32 insertions(+) diff --git a/game/lib/navigation_page/events_draggable_sheet.dart b/game/lib/navigation_page/events_draggable_sheet.dart index 5b240a6f..2192caf5 100644 --- a/game/lib/navigation_page/events_draggable_sheet.dart +++ b/game/lib/navigation_page/events_draggable_sheet.dart @@ -189,12 +189,26 @@ class _EventsDraggableSheetState extends State { return matchesLocation && matchesCategory && matchesSearch; } + bool _hasPhysicalLocation(CampusEventDto event) { + final location = event.locationName.trim(); + final hasNamedLocation = location.isNotEmpty && + location.toLowerCase() != 'virtual' && + location.toLowerCase() != 'online'; + final hasCoords = event.latitude.isFinite && + event.longitude.isFinite && + !(event.latitude == 0 && event.longitude == 0); + return hasNamedLocation && hasCoords; + } + List _filteredEvents(CampusEventModel campusEventModel) { final events = campusEventModel.currentList?.events ?? campusEventModel.allCachedEvents; final rows = <({CampusEventDto event, DateTime start, double? distance})>[]; for (final event in events) { + if (!_hasPhysicalLocation(event)) { + continue; + } final start = _parseCampusEventTime(event.startTime); final end = _parseCampusEventTime(event.endTime); if (start == null || end == null) { @@ -222,6 +236,12 @@ class _EventsDraggableSheetState extends State { } rows.sort((a, b) { + final aIsTbd = a.event.locationName.trim().toUpperCase() == 'TBD'; + final bIsTbd = b.event.locationName.trim().toUpperCase() == 'TBD'; + if (aIsTbd != bIsTbd) { + return aIsTbd ? 1 : -1; + } + final byStart = a.start.compareTo(b.start); if (byStart != 0) return byStart; if (a.distance == null && b.distance == null) return 0; diff --git a/game/lib/navigation_page/home_map_page.dart b/game/lib/navigation_page/home_map_page.dart index 3c144b43..fe65200a 100644 --- a/game/lib/navigation_page/home_map_page.dart +++ b/game/lib/navigation_page/home_map_page.dart @@ -304,10 +304,22 @@ class _HomeMapPageState extends State return campusEventModel.currentList?.events ?? campusEventModel.allCachedEvents; } + bool _hasPhysicalLocation(CampusEventDto event) { + final location = event.locationName.trim(); + final hasNamedLocation = location.isNotEmpty && + location.toLowerCase() != 'virtual' && + location.toLowerCase() != 'online'; + final hasCoords = event.latitude.isFinite && + event.longitude.isFinite && + !(event.latitude == 0 && event.longitude == 0); + return hasNamedLocation && hasCoords; + } + List<_CampusEventMapPin> _pinsMatchingFilter(List events) { final now = DateTime.now(); final pins = <_CampusEventMapPin>[]; for (final event in events) { + if (!_hasPhysicalLocation(event)) continue; final end = _parseEventTime(event.endTime); if (end != null && end.isBefore(now)) continue; From 5039cb8228bd71a939bc73c08333ad51a04f538e Mon Sep 17 00:00:00 2001 From: Jason Guo Date: Mon, 27 Apr 2026 17:43:09 -0400 Subject: [PATCH 19/22] fix "go" screen for hitting go non map --- .../events_draggable_sheet.dart | 40 ++++++++++++++++++- 1 file changed, 38 insertions(+), 2 deletions(-) diff --git a/game/lib/navigation_page/events_draggable_sheet.dart b/game/lib/navigation_page/events_draggable_sheet.dart index 2192caf5..8a5c40f6 100644 --- a/game/lib/navigation_page/events_draggable_sheet.dart +++ b/game/lib/navigation_page/events_draggable_sheet.dart @@ -70,6 +70,38 @@ class _EventsDraggableSheetState extends State { super.dispose(); } + @override + void didUpdateWidget(EventsDraggableSheet oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.routedEventId != null && + widget.routedEventId != oldWidget.routedEventId) { + // After GO, keep navigation card compact; do not let sheet fill the screen. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + _snapSheetForRouteView(); + }); + } + } + + Future _snapSheetForRouteView() async { + if (!_sheetController.isAttached) return; + final current = _sheetController.size; + const target = 0.38; + if (current > 0.48) { + await _sheetController.animateTo( + target, + duration: const Duration(milliseconds: 300), + curve: Curves.easeOutCubic, + ); + } else { + await _sheetController.animateTo( + target, + duration: const Duration(milliseconds: 200), + curve: Curves.easeOutCubic, + ); + } + } + String _hostLabel(CampusEventDto event) { final organizer = event.organizerName?.trim(); if (organizer != null && organizer.isNotEmpty) { @@ -264,14 +296,18 @@ class _EventsDraggableSheetState extends State { @override Widget build(BuildContext context) { + final inRouteMode = widget.routedEventId != null; + // Post-GO: cap height so the sheet never expands to the full page. + const routeMax = 0.52; + const routeSnap = [0.32, 0.42, routeMax]; return Positioned.fill( child: DraggableScrollableSheet( controller: _sheetController, initialChildSize: 0.26, minChildSize: 0.26, - maxChildSize: 1.0, + maxChildSize: inRouteMode ? routeMax : 1.0, snap: true, - snapSizes: const [0.58, 1.0], + snapSizes: inRouteMode ? routeSnap : const [0.58, 1.0], builder: (context, scrollController) { return AnimatedBuilder( animation: _sheetController, From 815babde69b4bac60acde970323be707435f4a93 Mon Sep 17 00:00:00 2001 From: Jason Guo Date: Mon, 27 Apr 2026 19:18:59 -0400 Subject: [PATCH 20/22] gcal integration --- .../events_draggable_sheet.dart | 109 +++++++++++++++--- game/lib/utils/google_calendar_utils.dart | 75 ++++++++++++ 2 files changed, 165 insertions(+), 19 deletions(-) create mode 100644 game/lib/utils/google_calendar_utils.dart diff --git a/game/lib/navigation_page/events_draggable_sheet.dart b/game/lib/navigation_page/events_draggable_sheet.dart index 8a5c40f6..6286f2e4 100644 --- a/game/lib/navigation_page/events_draggable_sheet.dart +++ b/game/lib/navigation_page/events_draggable_sheet.dart @@ -5,6 +5,7 @@ import 'package:game/api/game_client_dto.dart'; import 'package:game/api/geopoint.dart'; import 'package:game/constants/constants.dart'; import 'package:game/model/campus_event_model.dart'; +import 'package:game/utils/google_calendar_utils.dart'; import 'package:provider/provider.dart'; import 'package:velocity_x/velocity_x.dart'; @@ -190,6 +191,37 @@ class _EventsDraggableSheetState extends State { return null; } + bool _showAddToGoogleCalendar(CampusEventDto event) { + final start = _parseCampusEventTime(event.startTime); + if (start == null) return false; + return start.isAfter(DateTime.now().add(const Duration(hours: 1))); + } + + String _locationLineForCalendar(CampusEventDto event) { + final addr = event.address?.trim(); + if (addr != null && addr.isNotEmpty) { + return '${event.locationName}, $addr'; + } + return event.locationName; + } + + Future _openCampusEventInGoogleCalendar(CampusEventDto event) async { + final start = _parseCampusEventTime(event.startTime); + if (start == null) return; + final endParsed = _parseCampusEventTime(event.endTime); + final end = (endParsed != null && endParsed.isAfter(start)) + ? endParsed + : start.add(const Duration(hours: 1)); + await openGoogleCalendarCreateEvent( + title: event.title, + location: _locationLineForCalendar(event), + start: start, + end: end, + details: event.description, + allDay: event.allDay, + ); + } + DateTime? _parseCampusEventTime(String? raw) { if (raw == null || raw.trim().isEmpty) return null; final trimmed = raw.trim(); @@ -465,6 +497,12 @@ class _EventsDraggableSheetState extends State { milesLabel: _milesLabel(focusedEvent), timeRangeLabel: _timeRangeLabel(focusedEvent), hostLabel: _hostLabel(focusedEvent), + showAddToGoogleCalendar: + _showAddToGoogleCalendar(focusedEvent), + onAddToGoogleCalendar: () => + _openCampusEventInGoogleCalendar( + focusedEvent, + ), onGo: isRoutedView ? null : () => widget.onGoToEvent?.call( @@ -641,16 +679,20 @@ class _FocusedEventCard extends StatelessWidget { final String milesLabel; final String timeRangeLabel; final String hostLabel; + final bool showAddToGoogleCalendar; + final Future Function() onAddToGoogleCalendar; final VoidCallback? onGo; final VoidCallback? onClose; - const _FocusedEventCard({ + _FocusedEventCard({ required this.event, required this.isRoutedView, required this.walkLabel, required this.milesLabel, required this.timeRangeLabel, required this.hostLabel, + required this.showAddToGoogleCalendar, + required this.onAddToGoogleCalendar, this.onGo, this.onClose, }); @@ -796,31 +838,60 @@ class _FocusedEventCard extends StatelessWidget { ), ] else ...[ const SizedBox(height: 12), - SizedBox( - width: double.infinity, - child: Material( - color: AppColors.primaryRed, - borderRadius: BorderRadius.circular(14), - child: InkWell( - onTap: onGo, + if (showAddToGoogleCalendar) + SizedBox( + width: double.infinity, + child: Material( + color: AppColors.green, borderRadius: BorderRadius.circular(14), - child: const Padding( - padding: EdgeInsets.symmetric(vertical: 13), - child: Center( - child: Text( - 'GO!', - style: TextStyle( - color: Colors.white, - fontWeight: FontWeight.w700, - fontSize: 42 / 2, - fontFamily: 'Poppins', + child: InkWell( + onTap: () { + onAddToGoogleCalendar(); + }, + borderRadius: BorderRadius.circular(14), + child: const Padding( + padding: EdgeInsets.symmetric(vertical: 13), + child: Center( + child: Text( + 'Add to GCal', + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.w700, + fontSize: 42 / 2, + fontFamily: 'Poppins', + ), + ), + ), + ), + ), + ), + ) + else + SizedBox( + width: double.infinity, + child: Material( + color: AppColors.primaryRed, + borderRadius: BorderRadius.circular(14), + child: InkWell( + onTap: onGo, + borderRadius: BorderRadius.circular(14), + child: const Padding( + padding: EdgeInsets.symmetric(vertical: 13), + child: Center( + child: Text( + 'GO!', + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.w700, + fontSize: 42 / 2, + fontFamily: 'Poppins', + ), ), ), ), ), ), ), - ), ], ], ), diff --git a/game/lib/utils/google_calendar_utils.dart b/game/lib/utils/google_calendar_utils.dart new file mode 100644 index 00000000..7f2af13a --- /dev/null +++ b/game/lib/utils/google_calendar_utils.dart @@ -0,0 +1,75 @@ +import 'package:url_launcher/url_launcher.dart'; + +String _gcalUtcSegment(DateTime dt) { + final u = dt.toUtc(); + String two(int n) => n.toString().padLeft(2, '0'); + return '${u.year.toString().padLeft(4, '0')}' + '${two(u.month)}' + '${two(u.day)}' + 'T' + '${two(u.hour)}' + '${two(u.minute)}' + '${two(u.second)}' + 'Z'; +} + +String _gcalAllDayYmd(DateTime local) { + final d = DateTime(local.year, local.month, local.day); + String two(int n) => n.toString().padLeft(2, '0'); + return '${d.year.toString().padLeft(4, '0')}${two(d.month)}${two(d.day)}'; +} + +/// Builds the Google Calendar "create event" URL (`action=TEMPLATE`). +Uri googleCalendarCreateUri({ + required String title, + required String location, + required DateTime start, + required DateTime end, + String? details, + required bool allDay, +}) { + final String dates; + if (allDay) { + final startDay = DateTime(start.year, start.month, start.day); + var endDay = DateTime(end.year, end.month, end.day); + if (endDay.isBefore(startDay)) endDay = startDay; + final exclusiveEnd = endDay.add(const Duration(days: 1)); + dates = '${_gcalAllDayYmd(startDay)}/${_gcalAllDayYmd(exclusiveEnd)}'; + } else { + var endAdj = end.isAfter(start) ? end : start.add(const Duration(hours: 1)); + dates = '${_gcalUtcSegment(start)}/${_gcalUtcSegment(endAdj)}'; + } + + final params = { + 'action': 'TEMPLATE', + 'text': title, + 'dates': dates, + 'location': location, + }; + final d = details?.trim(); + if (d != null && d.isNotEmpty) { + params['details'] = d; + } + + return Uri.https('calendar.google.com', '/calendar/render', params); +} + +/// Opens Google Calendar (app or browser) with a prefilled new event. +Future openGoogleCalendarCreateEvent({ + required String title, + required String location, + required DateTime start, + required DateTime end, + String? details, + required bool allDay, +}) { + final uri = googleCalendarCreateUri( + title: title, + location: location, + start: start, + end: end, + details: details, + allDay: allDay, + ); + return launchUrl(uri, mode: LaunchMode.externalApplication); +} From 08d62e3177662059c52bbfa20ef9c42b81f403a8 Mon Sep 17 00:00:00 2001 From: Jason Guo Date: Mon, 27 Apr 2026 19:46:19 -0400 Subject: [PATCH 21/22] dart format lib --- game/lib/navigation_page/events_draggable_sheet.dart | 11 ++++++----- game/lib/navigation_page/home_map_page.dart | 8 ++++++-- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/game/lib/navigation_page/events_draggable_sheet.dart b/game/lib/navigation_page/events_draggable_sheet.dart index 6286f2e4..4fd21576 100644 --- a/game/lib/navigation_page/events_draggable_sheet.dart +++ b/game/lib/navigation_page/events_draggable_sheet.dart @@ -136,7 +136,8 @@ class _EventsDraggableSheetState extends State { String _formatHourMinute(DateTime dt) { final hour12 = dt.hour % 12 == 0 ? 12 : dt.hour % 12; - final minute = dt.minute == 0 ? '' : ':${dt.minute.toString().padLeft(2, '0')}'; + final minute = + dt.minute == 0 ? '' : ':${dt.minute.toString().padLeft(2, '0')}'; final ampm = dt.hour >= 12 ? 'pm' : 'am'; return '$hour12$minute$ampm'; } @@ -367,8 +368,8 @@ class _EventsDraggableSheetState extends State { widget.routedEventId, ); final focusedEvent = routedEvent ?? selectedEvent; - final isRoutedView = - focusedEvent != null && routedEvent?.id == focusedEvent.id; + final isRoutedView = focusedEvent != null && + routedEvent?.id == focusedEvent.id; return ClipRRect( borderRadius: const BorderRadius.vertical( @@ -501,8 +502,8 @@ class _EventsDraggableSheetState extends State { _showAddToGoogleCalendar(focusedEvent), onAddToGoogleCalendar: () => _openCampusEventInGoogleCalendar( - focusedEvent, - ), + focusedEvent, + ), onGo: isRoutedView ? null : () => widget.onGoToEvent?.call( diff --git a/game/lib/navigation_page/home_map_page.dart b/game/lib/navigation_page/home_map_page.dart index fe65200a..0736532c 100644 --- a/game/lib/navigation_page/home_map_page.dart +++ b/game/lib/navigation_page/home_map_page.dart @@ -268,7 +268,10 @@ class _HomeMapPageState extends State final now = DateTime.now(); final start = _parseEventTime(event.startTime); final end = _parseEventTime(event.endTime); - if (start != null && end != null && start.isBefore(now) && end.isAfter(now)) { + if (start != null && + end != null && + start.isBefore(now) && + end.isAfter(now)) { return EventPinState.now; } if (start == null) return EventPinState.later; @@ -301,7 +304,8 @@ class _HomeMapPageState extends State } List _activeCampusEvents(CampusEventModel campusEventModel) { - return campusEventModel.currentList?.events ?? campusEventModel.allCachedEvents; + return campusEventModel.currentList?.events ?? + campusEventModel.allCachedEvents; } bool _hasPhysicalLocation(CampusEventDto event) { From 0cb40ef79a1ef0e900e210b6eafbbcf17dd4dddf Mon Sep 17 00:00:00 2001 From: Jason Guo Date: Thu, 30 Apr 2026 18:19:11 -0400 Subject: [PATCH 22/22] dart doc comments --- .../events_draggable_sheet.dart | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/game/lib/navigation_page/events_draggable_sheet.dart b/game/lib/navigation_page/events_draggable_sheet.dart index 4fd21576..bd240b31 100644 --- a/game/lib/navigation_page/events_draggable_sheet.dart +++ b/game/lib/navigation_page/events_draggable_sheet.dart @@ -54,6 +54,7 @@ class _EventsDraggableSheetState extends State { }); } + /// Loads the user's latest location to compute walking time and distance. Future _loadUserLocation() async { try { final location = await GeoPoint.current(); @@ -84,6 +85,7 @@ class _EventsDraggableSheetState extends State { } } + /// Keeps the sheet compact after routing starts from an event. Future _snapSheetForRouteView() async { if (!_sheetController.isAttached) return; final current = _sheetController.size; @@ -103,6 +105,7 @@ class _EventsDraggableSheetState extends State { } } + /// Returns the organizer label shown on event cards. String _hostLabel(CampusEventDto event) { final organizer = event.organizerName?.trim(); if (organizer != null && organizer.isNotEmpty) { @@ -111,6 +114,7 @@ class _EventsDraggableSheetState extends State { return 'Cornell'; } + /// Picks a short highlight line shown at the top of each list card. String _perkLine(CampusEventDto event) { final d = event.description?.trim(); if (d != null && d.isNotEmpty) { @@ -123,6 +127,7 @@ class _EventsDraggableSheetState extends State { return event.locationName; } + /// Formats a relative countdown to event end time (e.g. `in 20 min`). String _timeLabel(CampusEventDto event) { final end = _parseCampusEventTime(event.endTime); if (end == null) return ''; @@ -142,6 +147,7 @@ class _EventsDraggableSheetState extends State { return '$hour12$minute$ampm'; } + /// Formats the start-end time range for the focused event popup. String _timeRangeLabel(CampusEventDto event) { final start = _parseCampusEventTime(event.startTime); final end = _parseCampusEventTime(event.endTime); @@ -157,6 +163,7 @@ class _EventsDraggableSheetState extends State { return '${_formatHourMinute(start)}-${_formatHourMinute(end)}'; } + /// Computes straight-line distance from the user to an event in meters. double? _distanceMetersToEvent(CampusEventDto event) { final userLoc = _currentUserLocation; if (userLoc == null) return null; @@ -169,6 +176,7 @@ class _EventsDraggableSheetState extends State { } } + /// Converts event distance into an estimated walking duration label. String _walkMinutesLabel(CampusEventDto event) { final distanceMeters = _distanceMetersToEvent(event); if (distanceMeters == null) return ''; @@ -177,6 +185,7 @@ class _EventsDraggableSheetState extends State { return '$min min'; } + /// Converts event distance into a one-decimal miles label. String _milesLabel(CampusEventDto event) { final distanceMeters = _distanceMetersToEvent(event); if (distanceMeters == null) return ''; @@ -184,6 +193,7 @@ class _EventsDraggableSheetState extends State { return '${miles.toStringAsFixed(1)} mi'; } + /// Finds a loaded event by id from the current data set. CampusEventDto? _eventById(List events, String? id) { if (id == null) return null; for (final event in events) { @@ -192,6 +202,7 @@ class _EventsDraggableSheetState extends State { return null; } + /// Shows "Add to GCal" only for events starting at least an hour ahead. bool _showAddToGoogleCalendar(CampusEventDto event) { final start = _parseCampusEventTime(event.startTime); if (start == null) return false; @@ -206,6 +217,7 @@ class _EventsDraggableSheetState extends State { return event.locationName; } + /// Opens Google Calendar prefilled with the focused event details. Future _openCampusEventInGoogleCalendar(CampusEventDto event) async { final start = _parseCampusEventTime(event.startTime); if (start == null) return; @@ -223,6 +235,7 @@ class _EventsDraggableSheetState extends State { ); } + /// Parses backend event time values from ISO8601 or HTTP date strings. DateTime? _parseCampusEventTime(String? raw) { if (raw == null || raw.trim().isEmpty) return null; final trimmed = raw.trim(); @@ -235,6 +248,7 @@ class _EventsDraggableSheetState extends State { } } + /// Applies location, category, and search filters from the parent page. bool _matchesCampusEventFilters(CampusEventDto event) { final matchesLocation = widget.locations == null || widget.locations!.isEmpty || @@ -254,6 +268,7 @@ class _EventsDraggableSheetState extends State { return matchesLocation && matchesCategory && matchesSearch; } + /// Excludes virtual/TBD events and rows without usable coordinates. bool _hasPhysicalLocation(CampusEventDto event) { final location = event.locationName.trim(); final hasNamedLocation = location.isNotEmpty && @@ -265,6 +280,7 @@ class _EventsDraggableSheetState extends State { return hasNamedLocation && hasCoords; } + /// Returns upcoming, map-routable events sorted by start then distance. List _filteredEvents(CampusEventModel campusEventModel) { final events = campusEventModel.currentList?.events ?? campusEventModel.allCachedEvents; @@ -527,6 +543,7 @@ class _EventsDraggableSheetState extends State { } } +/// Compact event row shown in the default list view. class _EventCard extends StatelessWidget { final VoidCallback onGo; final String eventName; @@ -673,6 +690,10 @@ class _EventCard extends StatelessWidget { } } +/// Expanded popup card displayed when an event is focused/selected. +/// +/// This is the detail popup users see after opening an event from the map/list. +/// It shows travel/time details, description, and either routing or calendar CTA. class _FocusedEventCard extends StatelessWidget { final CampusEventDto event; final bool isRoutedView;