From cf7e0b0a3bb183ab2243bfa039a20f3dcdc0138a Mon Sep 17 00:00:00 2001 From: Selina Ge Date: Sun, 12 Apr 2026 10:45:01 -0400 Subject: [PATCH 1/6] 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 957478312c6dbb1d8a934d39b5180e354857a376 Mon Sep 17 00:00:00 2001 From: Selina Ge Date: Wed, 15 Apr 2026 17:56:39 -0400 Subject: [PATCH 2/6] 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 3/6] 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 4/6] 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 3a60ca3f9b42cf235bc3c202832560c590ecfb00 Mon Sep 17 00:00:00 2001 From: Selina Ge Date: Wed, 22 Apr 2026 12:52:01 -0400 Subject: [PATCH 5/6] 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 6/6] 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), };