From 415a33d868c52b7870d4878f55bb70a7f671ba9d Mon Sep 17 00:00:00 2001 From: Doug Borg Date: Tue, 3 Mar 2026 16:09:19 -0700 Subject: [PATCH 1/3] Improve camera node rendering: zoom scaling, wedge cones, clustering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add flutter_map_marker_cluster for clustering camera markers at zoom <= 13 - Replace donut-shaped direction cones with wedge/pie shapes emanating from camera center, with smoother arcs (36 points/90° vs 12/45°) - Zoom-gate static camera cones to only appear at zoom >= 14 (session cones remain always visible during add/edit) - Scale camera marker diameter with zoom level (8–28px range, 18px at z15) - Move user location marker out of node markers into separate layer to avoid it being clustered with camera nodes Co-Authored-By: Claude Opus 4.6 --- lib/dev_config.dart | 23 +++ lib/widgets/camera_icon.dart | 9 +- lib/widgets/cluster_icon.dart | 31 ++++ lib/widgets/map/direction_cones.dart | 76 +++++---- lib/widgets/map/marker_layer_builder.dart | 178 +++++++++++++--------- lib/widgets/map/node_markers.dart | 32 ++-- lib/widgets/map_view.dart | 6 +- pubspec.lock | 32 +++- pubspec.yaml | 1 + 9 files changed, 242 insertions(+), 146 deletions(-) create mode 100644 lib/widgets/cluster_icon.dart diff --git a/lib/dev_config.dart b/lib/dev_config.dart index 44294c69..7d5b5dbd 100644 --- a/lib/dev_config.dart +++ b/lib/dev_config.dart @@ -163,8 +163,31 @@ const int kMaxReasonableTileCount = 20000; const int kAbsoluteMaxTileCount = 50000; const int kAbsoluteMaxZoom = 23; +// Direction cone zoom gating +const int kDirectionConeMinZoomLevel = 14; + +// Direction cone arc smoothness +const int kDirectionConeArcPoints = 36; // points per 90 degrees +const int kDirectionConeMinArcPoints = 12; // minimum for narrow FOVs + // Node icon configuration const double kNodeIconDiameter = 18.0; + +// Node marker zoom scaling +const double kNodeIconReferenceZoom = 15.0; +const double kNodeIconScalePerZoom = 2.0; // px per zoom level +const double kNodeIconMinDiameter = 8.0; +const double kNodeIconMaxDiameter = 28.0; + +/// Returns marker diameter scaled by current zoom level +double getScaledNodeDiameter(double zoom) { + final scaled = kNodeIconDiameter + (zoom - kNodeIconReferenceZoom) * kNodeIconScalePerZoom; + return scaled.clamp(kNodeIconMinDiameter, kNodeIconMaxDiameter); +} + +// Clustering +const int kNodeClusterMaxZoomLevel = 13; // clustering active at zoom <= 13; disabled at zoom >= 14 +const double kClusterIconDiameter = 32.0; const double _kNodeRingThicknessBase = 2.5; const double kNodeDotOpacity = 0.3; // Opacity for the grey dot interior const Color kNodeRingColorReal = Color(0xFF3036F0); // Real nodes from OSM - blue diff --git a/lib/widgets/camera_icon.dart b/lib/widgets/camera_icon.dart index 0637936d..4a258c74 100644 --- a/lib/widgets/camera_icon.dart +++ b/lib/widgets/camera_icon.dart @@ -13,8 +13,9 @@ enum CameraIconType { /// Simple camera icon with grey dot and colored ring class CameraIcon extends StatelessWidget { final CameraIconType type; - - const CameraIcon({super.key, required this.type}); + final double diameter; + + const CameraIcon({super.key, required this.type, this.diameter = kNodeIconDiameter}); Color get _ringColor { switch (type) { @@ -36,8 +37,8 @@ class CameraIcon extends StatelessWidget { @override Widget build(BuildContext context) { return Container( - width: kNodeIconDiameter, - height: kNodeIconDiameter, + width: diameter, + height: diameter, decoration: BoxDecoration( shape: BoxShape.circle, color: _ringColor.withValues(alpha: kNodeDotOpacity), diff --git a/lib/widgets/cluster_icon.dart b/lib/widgets/cluster_icon.dart new file mode 100644 index 00000000..490763f8 --- /dev/null +++ b/lib/widgets/cluster_icon.dart @@ -0,0 +1,31 @@ +import 'package:flutter/material.dart'; +import '../dev_config.dart'; + +/// Cluster icon showing a blue circle with white count text +class ClusterIcon extends StatelessWidget { + final int count; + + const ClusterIcon({super.key, required this.count}); + + @override + Widget build(BuildContext context) { + return Container( + width: kClusterIconDiameter, + height: kClusterIconDiameter, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: kNodeRingColorReal.withValues(alpha: 0.7), + border: Border.all(color: kNodeRingColorReal, width: 2), + ), + alignment: Alignment.center, + child: Text( + count.toString(), + style: const TextStyle( + color: Colors.white, + fontSize: 13, + fontWeight: FontWeight.bold, + ), + ), + ); + } +} diff --git a/lib/widgets/map/direction_cones.dart b/lib/widgets/map/direction_cones.dart index 5c38aed8..9b9dfa0f 100644 --- a/lib/widgets/map/direction_cones.dart +++ b/lib/widgets/map/direction_cones.dart @@ -86,18 +86,21 @@ class DirectionConesBuilder { } // Add cones for cameras with direction (but exclude camera being edited) - for (final node in cameras) { - if (_isValidCameraWithDirection(node) && - (editSession == null || node.id != editSession.originalNode.id)) { - // Build a cone for each direction+fov pair - for (final directionFov in node.directionFovPairs) { - overlays.add(_buildConeWithFov( - node.coord, - directionFov.centerDegrees, - directionFov.fovDegrees, - zoom, - context: context, - )); + // Only show at sufficient zoom where direction is meaningful + if (zoom >= kDirectionConeMinZoomLevel) { + for (final node in cameras) { + if (_isValidCameraWithDirection(node) && + (editSession == null || node.id != editSession.originalNode.id)) { + // Build a cone for each direction+fov pair + for (final directionFov in node.directionFovPairs) { + overlays.add(_buildConeWithFov( + node.coord, + directionFov.centerDegrees, + directionFov.fovDegrees, + zoom, + context: context, + )); + } } } } @@ -136,6 +139,7 @@ class DirectionConesBuilder { } /// Internal cone building method that handles the actual rendering + /// Builds a wedge/pie shape emanating from the camera center static Polygon _buildConeInternal({ required LatLng origin, required double bearingDeg, @@ -157,20 +161,17 @@ class DirectionConesBuilder { isActiveDirection: isActiveDirection, ); } - - // Calculate pixel-based radii - final outerRadiusPx = kNodeIconDiameter + (kNodeIconDiameter * kDirectionConeBaseLength); - final innerRadiusPx = kNodeIconDiameter + (2 * getNodeRingThickness(context)); - + + // Calculate pixel-based outer radius using scaled marker diameter + final diameter = getScaledNodeDiameter(zoom); + final outerRadiusPx = diameter + (diameter * kDirectionConeBaseLength); + // Convert pixels to coordinate distances with zoom scaling final pixelToCoordinate = 0.00001 * math.pow(2, 15 - zoom); final outerRadius = outerRadiusPx * pixelToCoordinate; - final innerRadius = innerRadiusPx * pixelToCoordinate; - - // Number of points for the outer arc (within our directional range) - // Scale arc points based on FOV width for better rendering - final baseArcPoints = 12; - final arcPoints = math.max(6, (baseArcPoints * halfAngleDeg / 45).round()); + + // Smooth arc: scale points by FOV width + final arcPoints = math.max(kDirectionConeMinArcPoints, (kDirectionConeArcPoints * halfAngleDeg / 90).round()); LatLng project(double deg, double distance) { final rad = deg * math.pi / 180; @@ -180,20 +181,13 @@ class DirectionConesBuilder { return LatLng(origin.latitude + dLat, origin.longitude + dLon); } - // Build outer arc points only within our directional sector - final points = []; - - // Add outer arc points from left to right (counterclockwise for proper polygon winding) + // Build wedge/pie shape: origin → outer arc → auto-closes back to origin + final points = [origin]; + for (int i = 0; i <= arcPoints; i++) { final angle = bearingDeg - halfAngleDeg + (i * 2 * halfAngleDeg / arcPoints); points.add(project(angle, outerRadius)); } - - // Add inner arc points from right to left (to close the donut shape) - for (int i = arcPoints; i >= 0; i--) { - final angle = bearingDeg - halfAngleDeg + (i * 2 * halfAngleDeg / arcPoints); - points.add(project(angle, innerRadius)); - } // Adjust opacity based on direction state double opacity = kDirectionConeOpacity; @@ -210,7 +204,6 @@ class DirectionConesBuilder { } /// Build a full circle for 360-degree FOV cases - /// Returns just the outer circle - we'll handle the donut effect differently static Polygon _buildFullCircle({ required LatLng origin, required double zoom, @@ -218,14 +211,14 @@ class DirectionConesBuilder { bool isSession = false, bool isActiveDirection = true, }) { - // Calculate pixel-based radii - final outerRadiusPx = kNodeIconDiameter + (kNodeIconDiameter * kDirectionConeBaseLength); - + // Calculate pixel-based radius using scaled marker diameter + final diameter = getScaledNodeDiameter(zoom); + final outerRadiusPx = diameter + (diameter * kDirectionConeBaseLength); + // Convert pixels to coordinate distances with zoom scaling final pixelToCoordinate = 0.00001 * math.pow(2, 15 - zoom); final outerRadius = outerRadiusPx * pixelToCoordinate; - - // Create simple filled circle - no donut complexity + const int circlePoints = 60; final points = []; @@ -236,9 +229,8 @@ class DirectionConesBuilder { distance * math.sin(rad) / math.cos(origin.latitude * math.pi / 180); return LatLng(origin.latitude + dLat, origin.longitude + dLon); } - - // Add outer circle points - simple complete circle - for (int i = 0; i <= circlePoints; i++) { // Note: <= to ensure closure + + for (int i = 0; i < circlePoints; i++) { final angle = (i * 360.0 / circlePoints) % 360.0; points.add(project(angle, outerRadius)); } diff --git a/lib/widgets/map/marker_layer_builder.dart b/lib/widgets/map/marker_layer_builder.dart index 26528b40..92b4c870 100644 --- a/lib/widgets/map/marker_layer_builder.dart +++ b/lib/widgets/map/marker_layer_builder.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_map_animations/flutter_map_animations.dart'; +import 'package:flutter_map_marker_cluster/flutter_map_marker_cluster.dart'; import 'package:latlong2/latlong.dart'; import '../../models/osm_node.dart'; @@ -8,6 +9,7 @@ import '../../models/suspected_location.dart'; import '../../app_state.dart'; import '../../dev_config.dart'; import '../camera_icon.dart'; +import '../cluster_icon.dart'; import '../provisional_pin.dart'; import 'node_markers.dart'; import 'suspected_location_markers.dart'; @@ -44,8 +46,10 @@ class LocationPin extends StatelessWidget { /// session markers, navigation pins, and route visualization. class MarkerLayerBuilder { - /// Build complete marker layers for the map - static Widget buildMarkerLayers({ + /// Build complete marker layers for the map. + /// Returns a list of widgets: a cluster layer for node markers and + /// a regular MarkerLayer for all other markers. + static List buildMarkerLayers({ required List nodesToRender, required AnimatedMapController mapController, required AppState appState, @@ -58,85 +62,111 @@ class MarkerLayerBuilder { required Function(OsmNode)? onNodeTap, required Function(SuspectedLocation)? onSuspectedLocationTap, }) { - return LayoutBuilder( - builder: (context, constraints) { - - // Determine if nodes should be dimmed and/or disabled - final shouldDimNodes = appState.selectedSuspectedLocation != null || - appState.isInSearchMode || - appState.showingOverview; - - // Disable node interactions when navigation is in conflicting state - final shouldDisableNodeTaps = appState.isInSearchMode || appState.showingOverview; - - final markers = NodeMarkersBuilder.buildNodeMarkers( - nodes: nodesToRender, + // Determine if nodes should be dimmed and/or disabled + final shouldDimNodes = appState.selectedSuspectedLocation != null || + appState.isInSearchMode || + appState.showingOverview; + + // Disable node interactions when navigation is in conflicting state + final shouldDisableNodeTaps = appState.isInSearchMode || appState.showingOverview; + + final markers = NodeMarkersBuilder.buildNodeMarkers( + nodes: nodesToRender, + mapController: mapController.mapController, + zoom: currentZoom, + selectedNodeId: selectedNodeId, + onNodeTap: onNodeTap, + shouldDim: shouldDimNodes, + enabled: !shouldDisableNodeTaps, + ); + + // User location marker (separate from node markers for clustering) + final userLocationMarkers = [ + if (userLocation != null) + Marker( + point: userLocation, + width: 16, + height: 16, + child: const Icon(Icons.my_location, color: Colors.blue), + ), + ]; + + // Build suspected location markers (respect same zoom and count limits as nodes) + final suspectedLocationMarkers = []; + if (appState.suspectedLocationsEnabled && mapBounds != null && + currentZoom >= (appState.uploadMode == UploadMode.sandbox ? kOsmApiMinZoomLevel : kNodeMinZoomLevel)) { + final suspectedLocations = appState.getSuspectedLocationsInBoundsSync( + north: mapBounds.north, + south: mapBounds.south, + east: mapBounds.east, + west: mapBounds.west, + ); + + // Apply same node count limit as surveillance nodes + final maxNodes = appState.maxNodes; + final limitedSuspectedLocations = suspectedLocations.take(maxNodes).toList(); + + // Filter out suspected locations that are too close to real nodes + final filteredSuspectedLocations = _filterSuspectedLocationsByProximity( + suspectedLocations: limitedSuspectedLocations, + realNodes: nodesToRender, + minDistance: appState.suspectedLocationMinDistance, + ); + + suspectedLocationMarkers.addAll( + SuspectedLocationMarkersBuilder.buildSuspectedLocationMarkers( + locations: filteredSuspectedLocations, mapController: mapController.mapController, - userLocation: userLocation, - selectedNodeId: selectedNodeId, - onNodeTap: onNodeTap, // Keep the original callback - shouldDim: shouldDimNodes, - enabled: !shouldDisableNodeTaps, // Use enabled parameter instead - ); + selectedLocationId: appState.selectedSuspectedLocation?.ticketNo, + onLocationTap: onSuspectedLocationTap, + shouldDimAll: shouldDisableNodeTaps, + enabled: !shouldDisableNodeTaps, + ), + ); + } - // Build suspected location markers (respect same zoom and count limits as nodes) - final suspectedLocationMarkers = []; - if (appState.suspectedLocationsEnabled && mapBounds != null && - currentZoom >= (appState.uploadMode == UploadMode.sandbox ? kOsmApiMinZoomLevel : kNodeMinZoomLevel)) { - final suspectedLocations = appState.getSuspectedLocationsInBoundsSync( - north: mapBounds.north, - south: mapBounds.south, - east: mapBounds.east, - west: mapBounds.west, - ); - - // Apply same node count limit as surveillance nodes - final maxNodes = appState.maxNodes; - final limitedSuspectedLocations = suspectedLocations.take(maxNodes).toList(); - - // Filter out suspected locations that are too close to real nodes - final filteredSuspectedLocations = _filterSuspectedLocationsByProximity( - suspectedLocations: limitedSuspectedLocations, - realNodes: nodesToRender, - minDistance: appState.suspectedLocationMinDistance, - ); - - suspectedLocationMarkers.addAll( - SuspectedLocationMarkersBuilder.buildSuspectedLocationMarkers( - locations: filteredSuspectedLocations, - mapController: mapController.mapController, - selectedLocationId: appState.selectedSuspectedLocation?.ticketNo, - onLocationTap: onSuspectedLocationTap, // Keep the original callback - shouldDimAll: shouldDisableNodeTaps, - enabled: !shouldDisableNodeTaps, // Use enabled parameter instead - ), - ); - } + // Build center marker for add/edit sessions + final centerMarkers = _buildSessionMarkers( + mapController: mapController, + session: session, + editSession: editSession, + ); - // Build center marker for add/edit sessions - final centerMarkers = _buildSessionMarkers( - mapController: mapController, - session: session, - editSession: editSession, - ); + // Build provisional pin for navigation/search mode + final navigationMarkers = _buildNavigationMarkers(appState); - // Build provisional pin for navigation/search mode - final navigationMarkers = _buildNavigationMarkers(appState); + // Build start/end pins for route visualization + final routeMarkers = _buildRouteMarkers(appState); - // Build start/end pins for route visualization - final routeMarkers = _buildRouteMarkers(appState); + // Node markers go into cluster layer + final clusterLayer = MarkerClusterLayerWidget( + options: MarkerClusterLayerOptions( + markers: markers, + maxClusterRadius: 80, + disableClusteringAtZoom: kNodeClusterMaxZoomLevel, + zoomToBoundsOnClick: true, + spiderfyCluster: false, + centerMarkerOnClick: false, + markerChildBehavior: true, // Let NodeMapMarker handle its own gestures + size: Size(kClusterIconDiameter, kClusterIconDiameter), + builder: (context, clusterMarkers) { + return ClusterIcon(count: clusterMarkers.length); + }, + ), + ); - return MarkerLayer( - markers: [ - ...suspectedLocationMarkers, - ...markers, - ...centerMarkers, - ...navigationMarkers, - ...routeMarkers, - ] - ); - }, + // All other markers stay in a regular layer + final otherMarkersLayer = MarkerLayer( + markers: [ + ...suspectedLocationMarkers, + ...userLocationMarkers, + ...centerMarkers, + ...navigationMarkers, + ...routeMarkers, + ], ); + + return [clusterLayer, otherMarkersLayer]; } /// Build center markers for add/edit sessions diff --git a/lib/widgets/map/node_markers.dart b/lib/widgets/map/node_markers.dart index 7c386bef..e522a62e 100644 --- a/lib/widgets/map/node_markers.dart +++ b/lib/widgets/map/node_markers.dart @@ -1,7 +1,6 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_map/flutter_map.dart'; -import 'package:latlong2/latlong.dart'; import '../../dev_config.dart'; import '../../models/osm_node.dart'; @@ -14,12 +13,14 @@ class NodeMapMarker extends StatefulWidget { final MapController mapController; final void Function(OsmNode)? onNodeTap; final bool enabled; - + final double diameter; + const NodeMapMarker({ - required this.node, - required this.mapController, + required this.node, + required this.mapController, this.onNodeTap, this.enabled = true, + this.diameter = kNodeIconDiameter, super.key, }); @@ -91,7 +92,7 @@ class _NodeMapMarkerState extends State { return GestureDetector( onTap: _onTap, onDoubleTap: _onDoubleTap, - child: CameraIcon(type: iconType), + child: CameraIcon(type: iconType, diameter: widget.diameter), ); } } @@ -101,12 +102,13 @@ class NodeMarkersBuilder { static List buildNodeMarkers({ required List nodes, required MapController mapController, - LatLng? userLocation, + required double zoom, int? selectedNodeId, void Function(OsmNode)? onNodeTap, bool shouldDim = false, bool enabled = true, }) { + final diameter = getScaledNodeDiameter(zoom); final markers = [ // Node markers ...nodes @@ -115,31 +117,23 @@ class NodeMarkersBuilder { // Check if this node should be highlighted (selected) or dimmed final isSelected = selectedNodeId == n.id; final shouldDimNode = shouldDim || (selectedNodeId != null && !isSelected); - + return Marker( point: n.coord, - width: kNodeIconDiameter, - height: kNodeIconDiameter, + width: diameter, + height: diameter, child: Opacity( opacity: shouldDimNode ? 0.5 : 1.0, child: NodeMapMarker( - node: n, + node: n, mapController: mapController, onNodeTap: onNodeTap, enabled: enabled, + diameter: diameter, ), ), ); }), - - // User location marker - if (userLocation != null) - Marker( - point: userLocation, - width: 16, - height: 16, - child: const Icon(Icons.my_location, color: Colors.blue), - ), ]; return markers; diff --git a/lib/widgets/map_view.dart b/lib/widgets/map_view.dart index 96ef2b3d..2e785fdf 100644 --- a/lib/widgets/map_view.dart +++ b/lib/widgets/map_view.dart @@ -346,8 +346,8 @@ class MapViewState extends State { Widget cameraLayers = LayoutBuilder( builder: (context, constraints) { - // Build all marker layers - final markerLayer = MarkerLayerBuilder.buildMarkerLayers( + // Build all marker layers (cluster layer + other markers layer) + final markerLayers = MarkerLayerBuilder.buildMarkerLayers( nodesToRender: nodeData.nodesToRender, mapController: _controller, appState: appState, @@ -374,7 +374,7 @@ class MapViewState extends State { return Stack( children: [ ...overlayLayers, - markerLayer, + ...markerLayers, ], ); }, diff --git a/pubspec.lock b/pubspec.lock index 27e167b0..4b172902 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1,6 +1,14 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + animated_stack_widget: + dependency: transitive + description: + name: animated_stack_widget + sha256: ce4788dd158768c9d4388354b6fb72600b78e041a37afc4c279c63ecafcb9408 + url: "https://pub.dev" + source: hosted + version: "0.0.4" ansicolor: dependency: transitive description: @@ -258,10 +266,10 @@ packages: dependency: "direct main" description: name: flutter_map - sha256: df33e784b09fae857c6261a5521dd42bd4d3342cb6200884bb70730638af5fd5 + sha256: "391e7dc95cc3f5190748210a69d4cfeb5d8f84dcdfa9c3235d0a9d7742ccb3f8" url: "https://pub.dev" source: hosted - version: "8.2.1" + version: "8.2.2" flutter_map_animations: dependency: "direct main" description: @@ -270,6 +278,22 @@ packages: url: "https://pub.dev" source: hosted version: "0.9.0" + flutter_map_marker_cluster: + dependency: "direct main" + description: + name: flutter_map_marker_cluster + sha256: "04a20d9b1c3a18b67cc97c1240f75361ab98449b735ab06f2534ece0d0794733" + url: "https://pub.dev" + source: hosted + version: "8.2.2" + flutter_map_marker_popup: + dependency: transitive + description: + name: flutter_map_marker_popup + sha256: "982b38455e739fe04abf05066340e0ce5883c40fb08b121cc8c60f5ee2c664a3" + url: "https://pub.dev" + source: hosted + version: "8.1.0" flutter_native_splash: dependency: "direct dev" description: @@ -428,10 +452,10 @@ packages: dependency: "direct main" description: name: http - sha256: "85ab0074f9bf2b24625906d8382bbec84d3d6919d285ba9c106b07b65791fb99" + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" url: "https://pub.dev" source: hosted - version: "1.5.0-beta.2" + version: "1.6.0" http_parser: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 6523ba46..75e7b079 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -14,6 +14,7 @@ dependencies: provider: ^6.1.2 flutter_map: ^8.2.1 flutter_map_animations: ^0.9.0 + flutter_map_marker_cluster: ^8.2.2 latlong2: ^0.9.0 geolocator: ^10.1.0 http: ^1.2.1 From 670dc142c1892e2c14e6a181489d09b101eb3e81 Mon Sep 17 00:00:00 2001 From: Doug Borg Date: Tue, 3 Mar 2026 16:17:27 -0700 Subject: [PATCH 2/3] Raise default max rendered nodes from 500 to 2000 Clustering now handles visual density at low zoom, so the previous 500 limit was overly conservative. Raise to 2000 while keeping it user- configurable in settings for lower-end devices. Bump the performance warning threshold from 1000 to 5000 to match. Co-Authored-By: Claude Opus 4.6 --- lib/dev_config.dart | 2 +- lib/screens/settings/sections/max_nodes_section.dart | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/dev_config.dart b/lib/dev_config.dart index 7d5b5dbd..19d9f046 100644 --- a/lib/dev_config.dart +++ b/lib/dev_config.dart @@ -142,7 +142,7 @@ const double kNavigationMinRouteDistance = 100.0; // meters - minimum distance b const double kNavigationDistanceWarningThreshold = 20000.0; // meters - distance threshold for timeout warning (30km) // Node display configuration -const int kDefaultMaxNodes = 500; // Default maximum number of nodes to render on the map at once +const int kDefaultMaxNodes = 2000; // Default maximum number of nodes to render on the map at once (clustering handles visual density) // NSI (Name Suggestion Index) configuration const int kNSIMinimumHitCount = 500; // Minimum hit count for NSI suggestions to be considered useful diff --git a/lib/screens/settings/sections/max_nodes_section.dart b/lib/screens/settings/sections/max_nodes_section.dart index 51f8a833..94569d79 100644 --- a/lib/screens/settings/sections/max_nodes_section.dart +++ b/lib/screens/settings/sections/max_nodes_section.dart @@ -34,7 +34,7 @@ class _MaxNodesSectionState extends State { final locService = LocalizationService.instance; final appState = context.watch(); final current = appState.maxNodes; - final showWarning = current > 1000; + final showWarning = current > 5000; return Column( crossAxisAlignment: CrossAxisAlignment.start, From 9f3ca5541dd05c3c07fc1859f26e703c7cde5443 Mon Sep 17 00:00:00 2001 From: Doug Borg Date: Sat, 7 Mar 2026 12:40:10 -0700 Subject: [PATCH 3/3] Add Makefile, fvm version pinning, and Docker build image MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Makefile for build orchestration: - Replaces gen_icons_splashes.sh with dependency-tracked Make targets - Stamp files (.stamps/) skip work that's already done - CI workflows simplified to `make ci`, `make build-apk-debug`, etc. - Removes redundant flutter pub get, icon generation, and setup steps fvm for reproducible Flutter versions: - Pin Flutter 3.41.4 in .fvmrc (single source of truth) - Makefile and do_builds.sh auto-detect fvm, fall back to system Flutter - CI uses kuhnroyal/flutter-fvm-config-action to read .fvmrc - fvm is optional for contributors — system Flutter works if version matches Docker image for reproducible Android builds: - Dockerfile with Debian, JDK 17, Android SDK, fvm + Flutter - GHCR workflow builds and pushes on Dockerfile/.fvmrc changes - `make docker-ci` / `make docker-build-apk-debug` shortcuts - iOS still requires macOS runners (can't be containerized) Co-Authored-By: Claude Opus 4.6 --- .dockerignore | 12 ++ .fvmrc | 3 + .github/workflows/docker.yml | 25 +++++ .github/workflows/pr.yml | 60 ++-------- .github/workflows/workflow.yml | 78 +++++-------- .gitignore | 6 + DEVELOPER.md | 59 +++++++--- Dockerfile | 31 ++++++ Makefile | 193 +++++++++++++++++++++++++++++++++ README.md | 11 +- do_builds.sh | 13 ++- gen_icons_splashes.sh | 8 -- 12 files changed, 364 insertions(+), 135 deletions(-) create mode 100644 .dockerignore create mode 100644 .fvmrc create mode 100644 .github/workflows/docker.yml create mode 100644 Dockerfile create mode 100644 Makefile delete mode 100755 gen_icons_splashes.sh diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..ab3d0c69 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +build/ +.fvm/ +.stamps/ +.git/ +ios/ +.dart_tool/ +.idea/ +.vscode/ +.DS_Store +*.keystore +.env +build_keys.conf diff --git a/.fvmrc b/.fvmrc new file mode 100644 index 00000000..c8437d38 --- /dev/null +++ b/.fvmrc @@ -0,0 +1,3 @@ +{ + "flutter": "3.41.4" +} diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 00000000..6d365be0 --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,25 @@ +name: Build Docker Image +on: + push: + branches: [main] + paths: [Dockerfile, .fvmrc, .dockerignore] +permissions: + packages: write +jobs: + build-image: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - uses: docker/build-push-action@v6 + with: + context: . + push: true + tags: | + ghcr.io/foggedlens/deflock-builder:latest + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 3b894d35..90a1efda 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -13,18 +13,12 @@ jobs: steps: - uses: actions/checkout@v5 - - uses: subosito/flutter-action@v2 + - uses: kuhnroyal/flutter-fvm-config-action@v3 with: - channel: 'stable' + setup: true cache: true - - run: flutter pub get - - - name: Analyze - run: flutter analyze - - - name: Test - run: flutter test + - run: make ci build-debug-apk: name: Build Debug APK @@ -38,31 +32,14 @@ jobs: distribution: 'temurin' java-version: '17' - - uses: subosito/flutter-action@v2 + - uses: kuhnroyal/flutter-fvm-config-action@v3 with: - channel: 'stable' + setup: true cache: true - - uses: actions/cache@v4 - with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper - key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} - restore-keys: gradle-${{ runner.os }}- - - - run: flutter pub get - - - name: Generate icons and splash screens - run: | - dart run flutter_launcher_icons - dart run flutter_native_splash:create + - run: make build-apk-debug - - name: Build debug APK - run: flutter build apk --debug - - - name: Upload debug APK - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v4 with: name: debug-apk path: build/app/outputs/flutter-apk/app-debug.apk @@ -76,32 +53,17 @@ jobs: steps: - uses: actions/checkout@v5 - - uses: subosito/flutter-action@v2 + - uses: kuhnroyal/flutter-fvm-config-action@v3 with: - channel: 'stable' + setup: true cache: true - - uses: actions/cache@v4 - with: - path: ios/Pods - key: pods-${{ runner.os }}-${{ hashFiles('ios/Podfile.lock') }} - restore-keys: pods-${{ runner.os }}- - - - run: flutter pub get - - - name: Generate icons and splash screens - run: | - dart run flutter_launcher_icons - dart run flutter_native_splash:create - - - name: Build iOS simulator app - run: flutter build ios --debug --simulator + - run: make build-ios-simulator - name: Zip Runner.app run: cd build/ios/iphonesimulator && zip -r "$GITHUB_WORKSPACE/Runner.app.zip" Runner.app - - name: Upload simulator build - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v4 with: name: ios-simulator path: Runner.app.zip diff --git a/.github/workflows/workflow.yml b/.github/workflows/workflow.yml index 182466ff..09c22b04 100644 --- a/.github/workflows/workflow.yml +++ b/.github/workflows/workflow.yml @@ -27,7 +27,7 @@ jobs: id: set-info run: | echo "is_prerelease=${{ github.event.release.prerelease }}" >> $GITHUB_OUTPUT - + if [ "${{ github.event.release.prerelease }}" = "true" ]; then echo "should_upload_to_stores=false" >> $GITHUB_OUTPUT echo "✅ Pre-release - will build and attach assets, no store uploads" @@ -44,27 +44,18 @@ jobs: - name: Checkout repository uses: actions/checkout@v5 - - name: Set up JDK 17 - uses: actions/setup-java@v5 + - uses: actions/setup-java@v5 with: distribution: 'temurin' java-version: '17' - - name: Set up Flutter - uses: subosito/flutter-action@v2 + - uses: kuhnroyal/flutter-fvm-config-action@v3 with: - channel: 'stable' - - - name: Install dependencies - run: flutter pub get + setup: true + cache: true - name: Run tests - run: flutter test - - - name: Generate icons and splash screens - run: | - dart run flutter_launcher_icons - dart run flutter_native_splash:create + run: make test - name: Decode Keystore run: | @@ -78,7 +69,7 @@ jobs: echo "storeFile=keystore.jks" >> android/key.properties - name: Build Android .apk - run: flutter build apk --release --dart-define=OSM_PROD_CLIENTID='${{ secrets.OSM_PROD_CLIENTID }}' --dart-define=OSM_SANDBOX_CLIENTID='${{ secrets.OSM_SANDBOX_CLIENTID }}' + run: make release-apk FLUTTER_BUILD_ARGS="--dart-define=OSM_PROD_CLIENTID='${{ secrets.OSM_PROD_CLIENTID }}' --dart-define=OSM_SANDBOX_CLIENTID='${{ secrets.OSM_SANDBOX_CLIENTID }}'" - name: Upload .apk artifact uses: actions/upload-artifact@v4 @@ -96,27 +87,18 @@ jobs: - name: Checkout repository uses: actions/checkout@v5 - - name: Set up JDK 17 - uses: actions/setup-java@v5 + - uses: actions/setup-java@v5 with: distribution: 'temurin' java-version: '17' - - name: Set up Flutter - uses: subosito/flutter-action@v2 + - uses: kuhnroyal/flutter-fvm-config-action@v3 with: - channel: 'stable' - - - name: Install dependencies - run: flutter pub get + setup: true + cache: true - name: Run tests - run: flutter test - - - name: Generate icons and splash screens - run: | - dart run flutter_launcher_icons - dart run flutter_native_splash:create + run: make test - name: Decode Keystore run: | @@ -130,7 +112,7 @@ jobs: echo "storeFile=keystore.jks" >> android/key.properties - name: Build Android appBundle - run: flutter build appbundle --dart-define=OSM_PROD_CLIENTID='${{ secrets.OSM_PROD_CLIENTID }}' --dart-define=OSM_SANDBOX_CLIENTID='${{ secrets.OSM_SANDBOX_CLIENTID }}' + run: make release-aab FLUTTER_BUILD_ARGS="--dart-define=OSM_PROD_CLIENTID='${{ secrets.OSM_PROD_CLIENTID }}' --dart-define=OSM_SANDBOX_CLIENTID='${{ secrets.OSM_SANDBOX_CLIENTID }}'" - name: Upload .aab artifact uses: actions/upload-artifact@v4 @@ -147,21 +129,13 @@ jobs: - name: Checkout repository uses: actions/checkout@v5 - - name: Set up Flutter - uses: subosito/flutter-action@v2 + - uses: kuhnroyal/flutter-fvm-config-action@v3 with: - channel: 'stable' - - - name: Install dependencies - run: flutter pub get + setup: true + cache: true - name: Run tests - run: flutter test - - - name: Generate icons and splash screens - run: | - dart run flutter_launcher_icons - dart run flutter_native_splash:create + run: make test - name: Install Apple certificate and provisioning profile env: @@ -187,7 +161,7 @@ jobs: # import certificate to keychain security import $CERTIFICATE_PATH -P "$P12_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH - + # Set this keychain as the default security list-keychain -d user -s $KEYCHAIN_PATH security default-keychain -s $KEYCHAIN_PATH @@ -195,7 +169,7 @@ jobs: # install provisioning profile mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles cp $PP_PATH ~/Library/MobileDevice/Provisioning\ Profiles/61f9fdb9-bf2d-4d94-b249-63155ee71e74.mobileprovision - + # Also install using the profile's internal UUID for better compatibility UUID=$(security cms -D -i $PP_PATH | plutil -extract UUID xml1 -o - - | xmllint --xpath "//string/text()" -) cp $PP_PATH ~/Library/MobileDevice/Provisioning\ Profiles/$UUID.mobileprovision @@ -234,12 +208,10 @@ jobs: EOF - name: Build iOS .ipa - run: | - flutter build ipa --release \ - --export-options-plist=ios/exportOptions.plist \ - --dart-define=OSM_PROD_CLIENTID='${{ secrets.OSM_PROD_CLIENTID }}' \ - --dart-define=OSM_SANDBOX_CLIENTID='${{ secrets.OSM_SANDBOX_CLIENTID }}' - cp build/ios/ipa/*.ipa Runner.ipa + run: make release-ios FLUTTER_BUILD_ARGS="--dart-define=OSM_PROD_CLIENTID='${{ secrets.OSM_PROD_CLIENTID }}' --dart-define=OSM_SANDBOX_CLIENTID='${{ secrets.OSM_SANDBOX_CLIENTID }}'" + + - name: Copy IPA + run: cp build/ios/ipa/*.ipa Runner.ipa - name: Clean up keychain and provisioning profile run: | @@ -323,14 +295,14 @@ jobs: # Create the private keys directory and decode API key mkdir -p ~/private_keys echo -n "$APP_STORE_CONNECT_API_KEY_BASE64" | base64 --decode > ~/private_keys/AuthKey_${APP_STORE_CONNECT_API_KEY_ID}.p8 - + # Upload to App Store Connect / TestFlight xcrun altool --upload-app \ --type ios \ --file Runner.ipa \ --apiKey $APP_STORE_CONNECT_API_KEY_ID \ --apiIssuer $APP_STORE_CONNECT_ISSUER_ID - + # Clean up sensitive files rm -rf ~/private_keys diff --git a/.gitignore b/.gitignore index d03c8937..79435739 100644 --- a/.gitignore +++ b/.gitignore @@ -96,6 +96,12 @@ Thumbs.db # Local OSM client ID configuration (contains secrets) build_keys.conf +# Make stamp files (track build step completion) +.stamps/ + +# fvm local SDK symlink +.fvm/ + # ─────────────────────────────── # For now - not targeting these # ─────────────────────────────── diff --git a/DEVELOPER.md b/DEVELOPER.md index 193411c2..3c5477aa 100644 --- a/DEVELOPER.md +++ b/DEVELOPER.md @@ -806,7 +806,8 @@ The app uses a **clean, release-triggered workflow** that rebuilds from scratch | Tool | Install | Notes | |------|---------|-------| | **Homebrew** | [brew.sh](https://brew.sh) | Package manager for macOS | -| **Flutter SDK 3.35+** | `brew install --cask flutter` | Installs Flutter + Dart (3.35+ required for RadioGroup widget) | +| **fvm** (recommended) | `brew tap leoafarias/fvm && brew install fvm` | Flutter version manager — pins the exact Flutter version from `.fvmrc` | +| **Flutter SDK** | `fvm install` (or `brew install --cask flutter`) | fvm reads `.fvmrc` automatically; system Flutter works if version matches | | **Xcode** | Mac App Store | Required for iOS builds | | **CocoaPods** | `brew install cocoapods` | Required for iOS plugin resolution | | **Android SDK** | See below | Required for Android builds | @@ -814,9 +815,11 @@ The app uses a **clean, release-triggered workflow** that rebuilds from scratch After installing, verify with: ```bash -flutter doctor # All checks should be green +fvm flutter doctor # All checks should be green (or `flutter doctor` without fvm) ``` +> **Note:** fvm is optional — system Flutter works fine if your version matches what's in `.fvmrc`. The Makefile and `do_builds.sh` automatically detect fvm and fall back to system Flutter. + ### Android SDK Setup (without Android Studio) You don't need the full Android Studio IDE. Install the command-line tools and let Flutter's build system pull what it needs: @@ -866,27 +869,51 @@ You can also pass keys directly via `--dart-define`: flutter run --dart-define=OSM_PROD_CLIENTID=your_id --dart-define=OSM_SANDBOX_CLIENTID=your_id ``` +### Docker (alternative to local setup) + +Contributors who prefer containers can build and test without installing Flutter locally: + +```bash +# Build the Docker image (or pull from GHCR if available) +docker build -t deflock-builder . + +# Run analyze + test +docker run --rm -v $(pwd):/app -w /app deflock-builder make ci + +# Build debug APK +docker run --rm -v $(pwd):/app -w /app deflock-builder make build-apk-debug + +# Or use the Makefile shortcuts +make docker-ci +make docker-build-apk-debug +``` + +> **Note:** Docker builds only support Android and validation (analyze/test). iOS builds require macOS. + ### First Build +The project uses a [Makefile](Makefile) to orchestrate builds. Make automatically +installs dependencies and generates icons/splash screens before building — you +don't need to run those steps manually. + ```bash -# 1. Install dependencies -flutter pub get +# Validate code (analyze + test) — this is the default target +make -# 2. Generate icons and splash screens (gitignored, must be regenerated) -./gen_icons_splashes.sh +# Build debug APK (generates assets automatically if needed) +make build-apk-debug -# 3. Build Android -flutter build apk --debug \ - --dart-define=OSM_PROD_CLIENTID=your_id \ - --dart-define=OSM_SANDBOX_CLIENTID=your_id +# Build iOS simulator app (macOS only) +make build-ios-simulator -# 4. Build iOS (macOS only, no signing needed for testing) -flutter build ios --no-codesign \ - --dart-define=OSM_PROD_CLIENTID=your_id \ - --dart-define=OSM_SANDBOX_CLIENTID=your_id +# See all available targets +make help ``` -> **Important:** You must run `./gen_icons_splashes.sh` before the first build. The generated icons and splash screen assets are gitignored, so the build will fail without this step. +> **How Make works here:** `make` compares file timestamps to skip work that's +> already done. The `.stamps/` directory tracks multi-file outputs like icon +> generation. If you need a completely fresh build, run `make clean` first. +> Tabs in the Makefile are significant (not spaces). ### Running @@ -903,7 +930,7 @@ flutter run --dart-define=OSM_PROD_CLIENTID=your_id --dart-define=OSM_SANDBOX_CL ### Testing ```bash # Run all tests -flutter test +make test # Run with coverage flutter test --coverage diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..60026979 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,31 @@ +FROM debian:bookworm-slim + +RUN apt-get update && apt-get install -y --no-install-recommends \ + curl git unzip xz-utils zip ca-certificates \ + openjdk-17-jdk-headless make \ + && rm -rf /var/lib/apt/lists/* + +ENV ANDROID_HOME=/opt/android-sdk +ENV JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64 + +# Android SDK +RUN mkdir -p $ANDROID_HOME/cmdline-tools && \ + curl -fsSL https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip -o /tmp/sdk.zip && \ + unzip -q /tmp/sdk.zip -d $ANDROID_HOME/cmdline-tools && \ + mv $ANDROID_HOME/cmdline-tools/cmdline-tools $ANDROID_HOME/cmdline-tools/latest && \ + rm /tmp/sdk.zip +RUN yes | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --licenses && \ + $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager \ + "platform-tools" "platforms;android-36" "build-tools;35.0.0" + +# fvm + Flutter (version from .fvmrc baked in) +COPY .fvmrc /tmp/.fvmrc +RUN curl -fsSL https://fvm.app/install.sh | bash +ENV PATH="/root/.pub-cache/bin:/root/fvm/default/bin:/root/.fvm/bin:$PATH" +RUN FLUTTER_VERSION=$(grep -o '"[0-9][^"]*"' /tmp/.fvmrc | tr -d '"') && \ + fvm install $FLUTTER_VERSION && \ + fvm global $FLUTTER_VERSION + +RUN flutter precache --android && yes | flutter doctor --android-licenses + +WORKDIR /app diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..2491a5e1 --- /dev/null +++ b/Makefile @@ -0,0 +1,193 @@ +# ============================================================================ +# DeFlock App — Build Orchestration +# ============================================================================ +# +# This Makefile ensures generated assets (icons, splash screens) are always +# built before any Flutter build target. It uses file-timestamp tracking so +# repeated runs skip work that's already done. +# +# Quick reference: +# make — run analyze + test (the default) +# make all — build debug APK + iOS simulator app +# make help — list every target with descriptions +# +# NOTE: Indentation in Makefiles MUST be tabs, not spaces. +# ============================================================================ + +.DEFAULT_GOAL := check + +# Stop partial outputs from surviving a failed recipe. +.DELETE_ON_ERROR: + +# ──── Variables ──────────────────────────────────────────────────────────── +# Prefer fvm-managed Flutter/Dart if fvm is installed; fall back to system. +FLUTTER := $(shell command -v fvm >/dev/null 2>&1 && echo "fvm flutter" || echo "flutter") +DART := $(shell command -v fvm >/dev/null 2>&1 && echo "fvm dart" || echo "dart") + +# Build output paths — used as file targets so Make can skip fresh builds. + +APK_DEBUG := build/app/outputs/flutter-apk/app-debug.apk +APK_RELEASE := build/app/outputs/flutter-apk/app-release.apk +AAB_RELEASE := build/app/outputs/bundle/release/app-release.aab +IOS_SIM_APP := build/ios/iphonesimulator/Runner.app/Info.plist + +# Inputs that should trigger asset regeneration when changed. +ASSET_SOURCES := pubspec.yaml assets/app_icon.png assets/android_app_icon.png assets/transparent_1x1.png + +# CI can pass extra flags (e.g. --dart-define) via this variable. +# Example: make release-apk FLUTTER_BUILD_ARGS="--dart-define=OSM_PROD_CLIENTID=..." +FLUTTER_BUILD_ARGS ?= + +# ──── Dependency checks ──────────────────────────────────────────────────── +# Verify required tools are installed before building. +# Runs once per clean checkout (tracked by stamp file). + +.stamps/check-deps: + @echo "Checking build dependencies..." + @(command -v fvm >/dev/null && fvm flutter --version >/dev/null 2>&1) || \ + command -v flutter >/dev/null || \ + (echo "ERROR: neither fvm nor flutter found. Install fvm: https://fvm.app/documentation/getting-started/installation" && exit 1) + @mkdir -p .stamps && touch $@ + +.stamps/check-android-deps: .stamps/check-deps + @command -v java >/dev/null || (echo "ERROR: java not found. Install: brew install --cask temurin" && exit 1) + @touch $@ + +.stamps/check-ios-deps: .stamps/check-deps + @command -v pod >/dev/null || (echo "ERROR: cocoapods not found. Install: brew install cocoapods" && exit 1) + @touch $@ + +# ──── File targets (dependency-tracked) ──────────────────────────────────── +# Stamp files in .stamps/ record "this step completed at this time". Make +# compares their timestamps against prerequisites to decide whether to re-run. +# We use stamps because these steps produce many scattered output files +# (e.g. icon generation writes ~20 PNGs across android/ and ios/). + +.stamps/pub-get: .stamps/check-deps pubspec.yaml pubspec.lock + $(FLUTTER) pub get + @mkdir -p .stamps && touch $@ + +.stamps/generate-assets: .stamps/pub-get $(ASSET_SOURCES) + $(DART) run flutter_launcher_icons + $(DART) run flutter_native_splash:create + @touch $@ + +$(APK_DEBUG): .stamps/generate-assets .stamps/check-android-deps + $(FLUTTER) build apk --debug + +$(IOS_SIM_APP): .stamps/generate-assets .stamps/check-ios-deps + $(FLUTTER) build ios --debug --simulator + +$(APK_RELEASE): .stamps/generate-assets .stamps/check-android-deps + $(FLUTTER) build apk --release $(FLUTTER_BUILD_ARGS) + +$(AAB_RELEASE): .stamps/generate-assets .stamps/check-android-deps + $(FLUTTER) build appbundle $(FLUTTER_BUILD_ARGS) + +# IPA output filename varies by version — use stamp instead of file target. +.stamps/release-ios: .stamps/generate-assets .stamps/check-ios-deps + $(FLUTTER) build ipa --release --export-options-plist=ios/exportOptions.plist $(FLUTTER_BUILD_ARGS) + @touch $@ + +# ──── Phony targets ──────────────────────────────────────────────────────── +# These targets don't produce a file with their name, so Make should always +# run their recipes. Everything else uses real file targets above. + +# Docker image for reproducible Android builds. +DOCKER_IMAGE := ghcr.io/foggedlens/deflock-builder:latest + +.PHONY: all check analyze test ci clean help version \ + generate-assets pub-get \ + build-apk-debug build-ios-simulator \ + release release-apk release-aab release-ios \ + docker-ci docker-build-apk-debug docker-build-apk-release \ + docker-build-aab-release docker-build-image + +# Default: validate code (bare `make` runs this). +check: analyze test + +# Build all debug binaries (iOS targets require macOS). +all: build-apk-debug build-ios-simulator + +analyze: .stamps/pub-get + $(FLUTTER) analyze + +test: .stamps/pub-get + $(FLUTTER) test + +# CI validation — what the PR workflow runs. +ci: check + +# ──── Convenience aliases ────────────────────────────────────────────────── +# Human-friendly names that delegate to the file targets above. + +generate-assets: .stamps/generate-assets +pub-get: .stamps/pub-get +build-apk-debug: $(APK_DEBUG) +build-ios-simulator: $(IOS_SIM_APP) +release-apk: $(APK_RELEASE) +release-aab: $(AAB_RELEASE) +release-ios: .stamps/release-ios +release: release-apk release-aab release-ios + +version: + @grep "version:" pubspec.yaml | head -1 | cut -d ':' -f 2 | tr -d ' ' | cut -d '+' -f 1 + +clean: + $(FLUTTER) clean + rm -rf .stamps + rm -rf android/app/src/main/res/drawable*/ + rm -rf android/app/src/main/res/mipmap*/ + rm -f ios/Runner/Assets.xcassets/AppIcon.appiconset/*.png + rm -f ios/Runner/Assets.xcassets/LaunchImage.imageset/*.png + rm -f ios/Runner/Assets.xcassets/LaunchBackground.imageset/*.png + +# ──── Docker targets ────────────────────────────────────────────────────── + +docker-ci: + docker run --rm -v $(CURDIR):/app -w /app $(DOCKER_IMAGE) make ci + +docker-build-apk-debug: + docker run --rm -v $(CURDIR):/app -w /app $(DOCKER_IMAGE) make build-apk-debug + +docker-build-apk-release: + docker run --rm -v $(CURDIR):/app -w /app $(DOCKER_IMAGE) make release-apk FLUTTER_BUILD_ARGS="$(FLUTTER_BUILD_ARGS)" + +docker-build-aab-release: + docker run --rm -v $(CURDIR):/app -w /app $(DOCKER_IMAGE) make release-aab FLUTTER_BUILD_ARGS="$(FLUTTER_BUILD_ARGS)" + +docker-build-image: + docker build -t $(DOCKER_IMAGE) . + +help: + @echo "Usage: make [target]" + @echo "" + @echo "Development (default: check)" + @echo " check Run analyze + test" + @echo " analyze Run flutter analyze" + @echo " test Run flutter test" + @echo " generate-assets Generate app icons and splash screens" + @echo " pub-get Install Flutter dependencies" + @echo "" + @echo "Debug builds" + @echo " all Build all debug binaries (iOS requires macOS)" + @echo " build-apk-debug Build debug APK" + @echo " build-ios-simulator Build iOS simulator app" + @echo "" + @echo "Release builds (require signing config)" + @echo " release Build all release binaries" + @echo " release-apk Build release APK" + @echo " release-aab Build release AAB (Play Store)" + @echo " release-ios Build release IPA (App Store)" + @echo "" + @echo "Docker (reproducible builds in container)" + @echo " docker-ci Run analyze + test in Docker" + @echo " docker-build-apk-debug Build debug APK in Docker" + @echo " docker-build-apk-release Build release APK in Docker" + @echo " docker-build-aab-release Build release AAB in Docker" + @echo " docker-build-image Build the Docker image locally" + @echo "" + @echo "Housekeeping" + @echo " clean Remove all build outputs and generated assets" + @echo " version Print app version from pubspec.yaml" + @echo " help Show this help" diff --git a/README.md b/README.md index 061a8db3..aba20ebd 100644 --- a/README.md +++ b/README.md @@ -92,12 +92,11 @@ A comprehensive Flutter app for mapping public surveillance infrastructure with **Quick setup (macOS with Homebrew):** ```shell -brew install --cask flutter # Install Flutter SDK -brew install cocoapods # Required for iOS -flutter pub get # Install dependencies -./gen_icons_splashes.sh # Generate icons & splash screens (required before first build) -cp build_keys.conf.example build_keys.conf # Add your OSM OAuth2 client IDs -./do_builds.sh # Build both platforms +brew tap leoafarias/fvm && brew install fvm # Install Flutter version manager +fvm install # Install pinned Flutter version from .fvmrc +brew install cocoapods # Required for iOS +cp build_keys.conf.example build_keys.conf # Add your OSM OAuth2 client IDs +make all # Installs deps, generates assets, builds APK + iOS ``` See [DEVELOPER.md](DEVELOPER.md) for cross-platform instructions and Android SDK setup. diff --git a/do_builds.sh b/do_builds.sh index b3c5850d..458aad5b 100755 --- a/do_builds.sh +++ b/do_builds.sh @@ -1,5 +1,12 @@ #!/bin/bash +# Prefer fvm-managed Flutter if available; fall back to system flutter. +if command -v fvm &>/dev/null; then + FLUTTER="fvm flutter" +else + FLUTTER="flutter" +fi + # Default options BUILD_IOS=true BUILD_ANDROID=true @@ -82,7 +89,7 @@ DART_DEFINE_ARGS="--dart-define=OSM_PROD_CLIENTID=$OSM_PROD_CLIENTID --dart-defi # Run tests before building echo "Running tests..." -flutter test || exit 1 +$FLUTTER test || exit 1 echo appver=$(grep "version:" pubspec.yaml | head -1 | cut -d ':' -f 2 | tr -d ' ' | cut -d '+' -f 1) @@ -92,7 +99,7 @@ echo if [ "$BUILD_IOS" = true ]; then echo "Building iOS..." - flutter build ios --no-codesign $DART_DEFINE_ARGS || exit 1 + $FLUTTER build ios --no-codesign $DART_DEFINE_ARGS || exit 1 echo "Converting .app to .ipa..." ./app2ipa.sh build/ios/iphoneos/Runner.app || exit 1 @@ -104,7 +111,7 @@ fi if [ "$BUILD_ANDROID" = true ]; then echo "Building Android..." - flutter build apk $DART_DEFINE_ARGS || exit 1 + $FLUTTER build apk $DART_DEFINE_ARGS || exit 1 echo "Moving Android files..." cp build/app/outputs/flutter-apk/app-release.apk "../deflock_v${appver}.apk" || exit 1 diff --git a/gen_icons_splashes.sh b/gen_icons_splashes.sh deleted file mode 100755 index 1817c69b..00000000 --- a/gen_icons_splashes.sh +++ /dev/null @@ -1,8 +0,0 @@ -#!/bin/bash - -echo "Generate icons..." -dart run flutter_launcher_icons -echo -echo -echo "Generate splash screens..." -dart run flutter_native_splash:create