From 994ba0554276d1ad5e70367ad6c0028187abf355 Mon Sep 17 00:00:00 2001 From: M-Talha Date: Mon, 16 Feb 2026 02:46:40 +0500 Subject: [PATCH 1/3] Add WiFiHeatmap widget to the ensemble --- .../visualization/wifi_heatmap_widget.dart | 1319 +++++++++++++++++ .../ensemble/lib/widget/widget_registry.dart | 4 +- modules/ensemble/lib/widget/wifi_heatmap.dart | 249 ++++ 3 files changed, 1571 insertions(+), 1 deletion(-) create mode 100644 modules/ensemble/lib/widget/visualization/wifi_heatmap_widget.dart create mode 100644 modules/ensemble/lib/widget/wifi_heatmap.dart diff --git a/modules/ensemble/lib/widget/visualization/wifi_heatmap_widget.dart b/modules/ensemble/lib/widget/visualization/wifi_heatmap_widget.dart new file mode 100644 index 000000000..dd94e3200 --- /dev/null +++ b/modules/ensemble/lib/widget/visualization/wifi_heatmap_widget.dart @@ -0,0 +1,1319 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:math'; + +import 'package:flutter/material.dart'; + +/// Models +class Device { + final String type; + Offset position; + + Device({required this.type, required this.position}); + + bool get isModem => type == 'modem'; +} + +class GridCell { + final int row; + final int col; + int rssi = -100; + Color? color; + bool scanned = false; + + GridCell({required this.row, required this.col}); +} + +class SignalResult { + final int dBm; + final Color color; + + SignalResult(this.dBm, this.color); +} + +/// WiFi Heatmap Theme Configuration +class WiFiHeatmapTheme { + final double? deviceMarkerSize; + final double? deviceIconSize; + final Color? modemColor; + final Color? modemIconColor; + final Icon? modemIcon; + final Color? routerColor; + final Color? routerIconColor; + final Icon? routerIcon; + final double? deviceBorderWidth; + final Color? deviceBorderColor; + + final double? scanPointDotSizeFactor; + final Color? scanPointColor; + final Color? scanPointBorderColor; + final double? scanPointBorderWidth; + + final double? locationPinSize; + final Color? locationPinColor; + final Icon? locationPinIcon; + + final double? gridLineWidth; + final int? gridAlpha; + final Color? gridLineColor; + + final int? heatmapFillAlpha; + + final Color? pathColor; + final double? pathWidth; + + final int? defaultGridSize; + final double? targetCellSize; + + final Color? excellentSignalColor; + final Color? veryGoodSignalColor; + final Color? goodSignalColor; + final Color? fairSignalColor; + final Color? poorSignalColor; + final Color? badSignalColor; + + final Color? startScanButtonColor; + final Color? addCheckpointButtonColor; + + const WiFiHeatmapTheme({ + this.deviceMarkerSize, + this.deviceIconSize, + this.modemColor, + this.modemIconColor, + this.modemIcon, + this.routerColor, + this.routerIconColor, + this.routerIcon, + this.deviceBorderWidth, + this.deviceBorderColor, + this.scanPointDotSizeFactor, + this.scanPointColor, + this.scanPointBorderColor, + this.scanPointBorderWidth, + this.locationPinSize, + this.locationPinColor, + this.locationPinIcon, + this.gridLineWidth, + this.gridAlpha, + this.gridLineColor, + this.heatmapFillAlpha, + this.pathColor, + this.pathWidth, + this.defaultGridSize, + this.targetCellSize, + this.excellentSignalColor, + this.veryGoodSignalColor, + this.goodSignalColor, + this.fairSignalColor, + this.poorSignalColor, + this.badSignalColor, + this.startScanButtonColor, + this.addCheckpointButtonColor, + }); + + // Default values + double get _deviceMarkerSize => deviceMarkerSize ?? 36.0; + double get _deviceIconSize => deviceIconSize ?? 22.0; + Color get _modemColor => modemColor ?? Colors.red; + Color get _modemIconColor => modemIconColor ?? Colors.white; + Color get _routerColor => routerColor ?? Colors.blue; + Color get _routerIconColor => routerIconColor ?? Colors.white; + double get _deviceBorderWidth => deviceBorderWidth ?? 2.8; + Color get _deviceBorderColor => deviceBorderColor ?? Colors.white; + + double get _scanPointDotSizeFactor => scanPointDotSizeFactor ?? 0.4; + Color get _scanPointColor => scanPointColor ?? Colors.blueAccent; + Color get _scanPointBorderColor => + scanPointBorderColor ?? const Color(0xB3FFFFFF); + double get _scanPointBorderWidth => scanPointBorderWidth ?? 1.8; + + double get _locationPinSize => locationPinSize ?? 44.0; + Color get _locationPinColor => locationPinColor ?? Colors.red; + + double get _gridLineWidth => gridLineWidth ?? 0.6; + int get _gridAlpha => gridAlpha ?? 60; + Color get _gridLineColor => gridLineColor ?? Colors.black; + + int get _heatmapFillAlpha => heatmapFillAlpha ?? 123; + + Color get _pathColor => pathColor ?? const Color(0xFF1976D2); + double get _pathWidth => pathWidth ?? 2.8; + + int get _defaultGridSize => defaultGridSize ?? 12; + + Color get _excellentSignalColor => + excellentSignalColor ?? const Color(0xFF388E3C); + Color get _veryGoodSignalColor => + veryGoodSignalColor ?? const Color(0xFF66BB6A); + Color get _goodSignalColor => goodSignalColor ?? const Color(0xFFAFB42B); + Color get _fairSignalColor => fairSignalColor ?? const Color(0xFFF57C00); + Color get _poorSignalColor => poorSignalColor ?? const Color(0xFFE64A19); + Color get _badSignalColor => badSignalColor ?? const Color(0xFFC62828); + + Color get _startScanButtonColor => + startScanButtonColor ?? const Color(0xFF388E3C); + Color get _addCheckpointButtonColor => + addCheckpointButtonColor ?? const Color(0xFF1976D2); + + Color getSignalColor(int dBm) { + if (dBm >= -50) return _excellentSignalColor; + if (dBm >= -60) return _veryGoodSignalColor; + if (dBm >= -70) return _goodSignalColor; + if (dBm >= -80) return _fairSignalColor; + if (dBm >= -90) return _poorSignalColor; + return _badSignalColor; + } + + Color getDeviceColor(Device device) => + device.isModem ? _modemColor : _routerColor; + Color getDeviceIconColor(Device device) => + device.isModem ? _modemIconColor : _routerIconColor; + + Icon getDeviceIcon(Device device) { + if (device.isModem) { + return modemIcon ?? + Icon(Icons.wifi, color: _modemIconColor, size: _deviceIconSize); + } else { + return routerIcon ?? + Icon(Icons.router, color: _routerIconColor, size: _deviceIconSize); + } + } + + Icon getLocationPinIcon() { + return locationPinIcon ?? + Icon( + Icons.location_on, + color: _locationPinColor, + size: _locationPinSize, + ); + } +} + +/// Reusable WiFi Heatmap Widget +class WiFiHeatmapWidget extends StatefulWidget { + final Future Function()? getSignalStrength; + final String floorPlan; + final int? gridSize; + final Function(String message)? onShowMessage; + final WiFiHeatmapTheme theme; + + // Error state customization + final String errorTitle; + final String errorMessage; + final IconData errorIcon; + final Color errorIconColor; + final double errorIconSize; + final TextStyle? errorTitleStyle; + final TextStyle? errorMessageStyle; + + const WiFiHeatmapWidget({ + super.key, + this.getSignalStrength, + required this.floorPlan, + this.gridSize, + this.onShowMessage, + this.theme = const WiFiHeatmapTheme(), + this.errorTitle = 'Invalid or missing floor plan', + this.errorMessage = 'Please provide a valid image path', + this.errorIcon = Icons.broken_image, + this.errorIconColor = Colors.redAccent, + this.errorIconSize = 80.0, + this.errorTitleStyle, + this.errorMessageStyle, + }); + + @override + State createState() => _WiFiHeatmapWidgetState(); +} + +class _WiFiHeatmapWidgetState extends State { + File? _floorPlan; + Device? _modem; + final List _routers = []; + + Size? _originalImageSize; + Rect? _displayedImageRect; + + String _mode = 'setup'; + + List> _grid = []; + List _rowHeights = []; + List _colWidths = []; + + Offset? _markerGridPos; + final List _scannedGridPositions = []; + + Timer? _signalTimer; + List _currentSegmentDbms = []; + + final _stackKey = GlobalKey(); + + bool _imageLoadFailed = false; + + int get _effectiveGridSize => + widget.gridSize ?? widget.theme._defaultGridSize; + + @override + void initState() { + super.initState(); + _floorPlan = File(widget.floorPlan); + WidgetsBinding.instance.addPostFrameCallback((_) { + _loadImageSize(); + }); + } + + @override + void dispose() { + _signalTimer?.cancel(); + super.dispose(); + } + + Future _loadImageSize() async { + if (_floorPlan == null) return; + + try { + final completer = Completer(); + final provider = FileImage(_floorPlan!); + + provider.resolve(const ImageConfiguration()).addListener( + ImageStreamListener( + (info, _) { + completer.complete(Size( + info.image.width.toDouble(), info.image.height.toDouble())); + }, + onError: (exception, stackTrace) { + print('Image load error: $exception'); + if (mounted) { + setState(() => _imageLoadFailed = true); + widget.onShowMessage?.call('Failed to load floor plan image'); + } + completer.completeError(exception, stackTrace); + }, + ), + ); + + _originalImageSize = await completer.future; + if (mounted) { + setState(() {}); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) setState(() {}); + }); + } + } catch (e) { + print('Image loading failed: $e'); + if (mounted) { + setState(() => _imageLoadFailed = true); + widget.onShowMessage?.call('Invalid or inaccessible floor plan file'); + } + } + } + + void _updateDisplayedRect(BoxConstraints constraints) { + if (_originalImageSize == null || !mounted) return; + + final availW = constraints.maxWidth; + final availH = constraints.maxHeight; + + if (availW <= 0 || availH <= 0) return; + + // Use full available dimensions - no padding + final newRect = Rect.fromLTWH(0, 0, availW, availH); + + // Only update if rect has actually changed significantly + if (_displayedImageRect == null || + (_displayedImageRect!.left - newRect.left).abs() > 1 || + (_displayedImageRect!.top - newRect.top).abs() > 1 || + (_displayedImageRect!.width - newRect.width).abs() > 1 || + (_displayedImageRect!.height - newRect.height).abs() > 1) { + _displayedImageRect = newRect; + + // Recreate grid when image rect changes in scanning mode + if (_mode == 'scanning' && _grid.isEmpty) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + setState(() => _createGrid()); + } + }); + } + } + } + + Widget _buildErrorState() { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + widget.errorIcon, + size: widget.errorIconSize, + color: widget.errorIconColor, + ), + const SizedBox(height: 16), + Text( + widget.errorTitle, + style: widget.errorTitleStyle ?? + const TextStyle( + fontSize: 20, + color: Colors.redAccent, + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + widget.errorMessage, + style: widget.errorMessageStyle ?? + const TextStyle(fontSize: 16, color: Colors.grey), + textAlign: TextAlign.center, + ), + ], + ), + ); + } + + Widget _buildSetupModeContent() { + if (_imageLoadFailed || _displayedImageRect == null) { + return _buildErrorState(); + } + + return Stack( + fit: StackFit.expand, + children: [ + Positioned.fromRect( + rect: _displayedImageRect!, + child: Image.file( + _floorPlan!, + fit: BoxFit.contain, + errorBuilder: (context, error, stackTrace) { + return _buildErrorState(); + }, + ), + ), + if (_modem != null) + DraggableDevice( + device: _modem!, + imageRect: _displayedImageRect!, + theme: widget.theme, + onPositionChanged: (newPos) => + setState(() => _modem!.position = newPos), + ), + ..._routers.map( + (r) => DraggableDevice( + device: r, + imageRect: _displayedImageRect!, + theme: widget.theme, + onPositionChanged: (newPos) => setState(() => r.position = newPos), + ), + ), + ], + ); + } + + Widget _buildScanningModeContent() { + if (_imageLoadFailed || _displayedImageRect == null) { + return _buildErrorState(); + } + + return ClipRect( + child: Stack( + key: _stackKey, + children: [ + Positioned.fromRect( + rect: _displayedImageRect!, + child: Image.file( + _floorPlan!, + fit: BoxFit.contain, + errorBuilder: (context, error, stackTrace) { + return _buildErrorState(); + }, + ), + ), + if (_grid.isNotEmpty) ...[ + Positioned.fromRect( + rect: _displayedImageRect!, + child: VariableGridPainterWidget( + grid: _grid, + colWidths: _colWidths, + rowHeights: _rowHeights, + theme: widget.theme, + ), + ), + if (_scannedGridPositions.length >= 2) + Positioned.fromRect( + rect: _displayedImageRect!, + child: CustomPaint( + painter: PathConnectionPainter( + gridPoints: _scannedGridPositions, + colWidths: _colWidths, + rowHeights: _rowHeights, + theme: widget.theme, + ), + ), + ), + if (_scannedGridPositions.isNotEmpty && _markerGridPos != null) + Positioned.fromRect( + rect: _displayedImageRect!, + child: CustomPaint( + painter: PathConnectionPainter( + gridPoints: [_scannedGridPositions.last, _markerGridPos!], + colWidths: _colWidths, + rowHeights: _rowHeights, + theme: widget.theme, + isDotted: true, + ), + ), + ), + ..._scannedGridPositions.map( + (pos) => ScanPointDot( + gridPos: pos, + colWidths: _colWidths, + rowHeights: _rowHeights, + imageRect: _displayedImageRect!, + theme: widget.theme, + ), + ), + if (_modem != null) + FixedDeviceMarker( + device: _modem!, + imageRect: _displayedImageRect!, + theme: widget.theme), + ..._routers.map((router) => FixedDeviceMarker( + device: router, + imageRect: _displayedImageRect!, + theme: widget.theme)), + if (_markerGridPos != null) + MarkerPin( + gridPos: _markerGridPos!, + colWidths: _colWidths, + rowHeights: _rowHeights, + imageRect: _displayedImageRect!, + theme: widget.theme, + ), + ], + if (_displayedImageRect != null && _grid.isNotEmpty) + Positioned.fill( + child: GestureDetector( + onTapDown: (d) => _updateMarkerFromGlobalPos(d.globalPosition), + onPanStart: (d) => _updateMarkerFromGlobalPos(d.globalPosition), + onPanUpdate: (d) => + _updateMarkerFromGlobalPos(d.globalPosition), + behavior: HitTestBehavior.opaque, + child: Container(color: Colors.transparent), + ), + ), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + if (_floorPlan == null || widget.floorPlan.isEmpty || _imageLoadFailed) { + return _buildErrorState(); + } + + return LayoutBuilder( + builder: (context, constraints) { + if (_originalImageSize == null) { + return const Center(child: CircularProgressIndicator()); + } + + final double availableWidth = constraints.maxWidth; + final double imageAspectRatio = + _originalImageSize!.width / _originalImageSize!.height; + + // Calculate image display dimensions (no padding, full width) + final double imageDisplayWidth = availableWidth; + final double imageDisplayHeight = imageDisplayWidth / imageAspectRatio; + + // Button area height (only for scanning mode) + const double buttonAreaHeight = 76.0; + + // Total height: image + button area (only in scanning mode) + final double totalHeight = + imageDisplayHeight + (_mode == 'scanning' ? buttonAreaHeight : 0); + + // Update displayed rect with exact image dimensions + _updateDisplayedRect(BoxConstraints( + maxWidth: imageDisplayWidth, + maxHeight: imageDisplayHeight, + )); + + return SizedBox( + height: totalHeight, + child: _displayedImageRect == null + ? const Center(child: CircularProgressIndicator()) + : Column( + children: [ + SizedBox( + width: imageDisplayWidth, + height: imageDisplayHeight, + child: Stack( + fit: StackFit.expand, + children: [ + _mode == 'setup' + ? _buildSetupModeContent() + : _buildScanningModeContent(), + if (!_imageLoadFailed && _mode == 'setup') + Positioned( + right: 16, + bottom: 16, + child: _buildFloatingActions(), + ), + ], + ), + ), + if (_mode == 'scanning') + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), + child: _grid.isNotEmpty + ? ElevatedButton.icon( + icon: const Icon(Icons.pin_drop), + label: const Text('Add Checkpoint'), + style: ElevatedButton.styleFrom( + minimumSize: const Size.fromHeight(52), + backgroundColor: + widget.theme._addCheckpointButtonColor, + foregroundColor: Colors.white, + ), + onPressed: _addCheckpoint, + ) + : const SizedBox(height: 52), + ), + ], + ), + ); + }, + ); + } + + Widget _buildFloatingActions() { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + FloatingActionButton( + heroTag: 'modem', + backgroundColor: widget.theme._modemColor, + onPressed: _addModem, + child: widget.theme.modemIcon ?? + Icon( + Icons.wifi, + color: widget.theme._modemIconColor, + size: widget.theme._deviceIconSize, + ), + ), + const SizedBox(height: 12), + FloatingActionButton( + heroTag: 'router', + backgroundColor: widget.theme._routerColor, + onPressed: _addRouter, + child: widget.theme.routerIcon ?? + Icon( + Icons.router, + color: widget.theme._routerIconColor, + size: widget.theme._deviceIconSize, + ), + ), + const SizedBox(height: 16), + FloatingActionButton( + heroTag: 'start', + backgroundColor: widget.theme._startScanButtonColor, + onPressed: _startScanning, + child: const Icon(Icons.navigate_next_outlined, color: Colors.white), + ), + ], + ); + } + + void _createGrid() { + if (_displayedImageRect == null) return; + + final w = _displayedImageRect!.width; + final h = _displayedImageRect!.height; + + final n = _effectiveGridSize.clamp(4, 32); + + _colWidths = List.generate(n, (_) => w / n); + _rowHeights = List.generate(n, (_) => h / n); + + _colWidths[n - 1] += w - _colWidths.fold(0.0, (a, b) => a + b); + _rowHeights[n - 1] += h - _rowHeights.fold(0.0, (a, b) => a + b); + + _grid = List.generate( + n, + (r) => List.generate(n, (c) => GridCell(row: r, col: c)), + ); + + _markerGridPos = Offset((n ~/ 2).toDouble(), (n ~/ 2).toDouble()); + } + + void _updateMarkerFromGlobalPos(Offset global) { + if (_displayedImageRect == null || _grid.isEmpty) return; + + final box = _stackKey.currentContext?.findRenderObject() as RenderBox?; + if (box == null) return; + + final local = box.globalToLocal(global); + final relX = (local.dx - _displayedImageRect!.left).clamp( + 0.0, + _displayedImageRect!.width, + ); + final relY = (local.dy - _displayedImageRect!.top).clamp( + 0.0, + _displayedImageRect!.height, + ); + + int col = 0; + double sumX = 0; + for (int i = 0; i < _colWidths.length; i++) { + sumX += _colWidths[i]; + if (relX <= sumX) { + col = i; + break; + } + } + + int row = 0; + double sumY = 0; + for (int i = 0; i < _rowHeights.length; i++) { + sumY += _rowHeights[i]; + if (relY <= sumY) { + row = i; + break; + } + } + + setState(() => _markerGridPos = Offset(row.toDouble(), col.toDouble())); + } + + Future _addCheckpoint() async { + if (_markerGridPos == null) return; + + final r = _markerGridPos!.dx.toInt(); + final c = _markerGridPos!.dy.toInt(); + + if (r < 0 || r >= _grid.length || c < 0 || c >= _grid[r].length) return; + + if (_scannedGridPositions.isNotEmpty && + _scannedGridPositions.last == Offset(r.toDouble(), c.toDouble())) { + return; + } + + SignalResult? result; + + if (widget.getSignalStrength != null) { + try { + result = await widget.getSignalStrength!(); + } catch (e) { + if (mounted) { + widget.onShowMessage?.call('Error getting signal: $e'); + } + return; + } + } else { + result = _getRandomSignal(); + } + + if (!mounted) return; + + final newPos = Offset(r.toDouble(), c.toDouble()); + + setState(() { + final cell = _grid[r][c]; + cell.rssi = result!.dBm; + cell.color = result.color; + cell.scanned = true; + + _scannedGridPositions.add(newPos); + + if (_scannedGridPositions.length == 1) { + _currentSegmentDbms = [result.dBm]; + _startSignalTimer(); + } else { + _currentSegmentDbms.add(result.dBm); + + final prevPos = _scannedGridPositions[_scannedGridPositions.length - 2]; + _processSegment(prevPos, newPos, _currentSegmentDbms); + + _currentSegmentDbms = [result.dBm]; + } + }); + } + + void _startSignalTimer() { + _signalTimer?.cancel(); + _signalTimer = Timer.periodic(const Duration(seconds: 1), (timer) async { + if (!mounted) { + timer.cancel(); + return; + } + + SignalResult result; + if (widget.getSignalStrength != null) { + try { + result = await widget.getSignalStrength!(); + } catch (e) { + return; + } + } else { + result = _getRandomSignal(); + } + + _currentSegmentDbms.add(result.dBm); + }); + } + + void _processSegment(Offset start, Offset end, List dbms) { + final startR = start.dx.toInt(); + final startC = start.dy.toInt(); + final endR = end.dx.toInt(); + final endC = end.dy.toInt(); + + final lineCells = _getLineCells(startR, startC, endR, endC); + if (lineCells.length < 2) return; + + final numCells = lineCells.length; + List cellDbms = List.filled(numCells, dbms.first); + + cellDbms[0] = dbms[0]; + cellDbms[numCells - 1] = dbms.last; + + final middleSamplesCount = dbms.length - 2; + final middleCellsCount = numCells - 2; + + if (middleCellsCount > 0) { + if (middleSamplesCount > 0) { + for (int j = 1; j < numCells - 1; j++) { + final startS = ((j - 1) * middleSamplesCount) ~/ middleCellsCount; + var endS = (j * middleSamplesCount) ~/ middleCellsCount; + if (endS <= startS) endS = startS + 1; + endS = min(endS, middleSamplesCount); + + int sum = 0; + int count = 0; + for (int s = startS; s < endS; s++) { + sum += dbms[1 + s]; + count++; + } + if (count > 0) { + cellDbms[j] = sum ~/ count; + } + } + } else { + final avg = (dbms[0] + dbms.last) ~/ 2; + for (int j = 1; j < numCells - 1; j++) { + cellDbms[j] = avg; + } + } + } + + for (int i = 0; i < numCells; i++) { + final gpos = lineCells[i]; + final rr = gpos.dx.toInt(); + final cc = gpos.dy.toInt(); + if (rr >= 0 && rr < _grid.length && cc >= 0 && cc < _grid[rr].length) { + final cell = _grid[rr][cc]; + final d = cellDbms[i]; + cell.rssi = d; + cell.color = widget.theme.getSignalColor(d); + cell.scanned = true; + } + } + } + + List _getLineCells(int r0, int c0, int r1, int c1) { + final cells = []; + final dr = (r1 - r0).abs(); + final dc = (c1 - c0).abs(); + final sr = r0 < r1 ? 1 : -1; + final sc = c0 < c1 ? 1 : -1; + int err = dr - dc; + int r = r0; + int c = c0; + + while (true) { + cells.add(Offset(r.toDouble(), c.toDouble())); + if (r == r1 && c == c1) break; + + final e2 = 2 * err; + if (e2 > -dc) { + err -= dc; + r += sr; + } + if (e2 < dr) { + err += dr; + c += sc; + } + } + return cells; + } + + SignalResult _getRandomSignal() { + final possibleValues = [ + -48, + -52, + -55, + -58, + -62, + -65, + -68, + -72, + -75, + -78, + -82, + -86, + -92, + -97, + ]; + + final random = Random(); + final dBm = possibleValues[random.nextInt(possibleValues.length)]; + return SignalResult(dBm, widget.theme.getSignalColor(dBm)); + } + + void _addModem() { + if (_displayedImageRect == null || _modem != null) return; + + final center = Offset( + (_displayedImageRect!.width - widget.theme._deviceMarkerSize) / 2, + (_displayedImageRect!.height - widget.theme._deviceMarkerSize) / 2, + ); + + setState(() => _modem = Device(type: 'modem', position: center)); + } + + void _addRouter() { + if (_displayedImageRect == null) return; + + final center = Offset( + (_displayedImageRect!.width - widget.theme._deviceMarkerSize) / 2, + (_displayedImageRect!.height - widget.theme._deviceMarkerSize) / 2, + ); + setState(() => _routers.add(Device(type: 'router', position: center))); + } + + void _startScanning() { + if (_modem == null) { + widget.onShowMessage?.call('Please place the modem first'); + return; + } + + setState(() { + _mode = 'scanning'; + // Clear grid to force recreation with current image rect + _grid.clear(); + }); + + // Recreate grid after mode change + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && _displayedImageRect != null) { + setState(() => _createGrid()); + } + }); + } +} + +/// Wrapper Screen with Scaffold (Example Usage) +class WiFiHeatmapScreen extends StatefulWidget { + final Future Function()? getSignalStrength; + final String floorPlan; + final int? gridSize; + final WiFiHeatmapTheme? theme; + + const WiFiHeatmapScreen({ + super.key, + this.getSignalStrength, + required this.floorPlan, + this.gridSize, + this.theme, + }); + + @override + State createState() => _WiFiHeatmapScreenState(); +} + +class _WiFiHeatmapScreenState extends State { + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('WiFi Heatmap')), + body: WiFiHeatmapWidget( + floorPlan: widget.floorPlan, + gridSize: widget.gridSize, + getSignalStrength: widget.getSignalStrength, + theme: widget.theme ?? const WiFiHeatmapTheme(), + onShowMessage: (message) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(message)), + ); + }, + ), + ); + } +} + +/// Reusable Widgets +class DeviceMarker extends StatelessWidget { + final Device device; + final WiFiHeatmapTheme theme; + + const DeviceMarker({super.key, required this.device, required this.theme}); + + @override + Widget build(BuildContext context) { + return Container( + width: theme._deviceMarkerSize, + height: theme._deviceMarkerSize, + decoration: BoxDecoration( + color: theme.getDeviceColor(device), + shape: BoxShape.circle, + border: Border.all( + color: theme._deviceBorderColor, + width: theme._deviceBorderWidth, + ), + boxShadow: const [ + BoxShadow(color: Colors.black45, blurRadius: 6, offset: Offset(2, 3)), + ], + ), + alignment: Alignment.center, + child: theme.getDeviceIcon(device), + ); + } +} + +class DraggableDevice extends StatelessWidget { + final Device device; + final Rect imageRect; + final ValueChanged onPositionChanged; + final WiFiHeatmapTheme theme; + + const DraggableDevice({ + super.key, + required this.device, + required this.imageRect, + required this.onPositionChanged, + required this.theme, + }); + + @override + Widget build(BuildContext context) { + return Positioned( + left: imageRect.left + device.position.dx, + top: imageRect.top + device.position.dy, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onPanUpdate: (details) { + final newDx = (device.position.dx + details.delta.dx).clamp( + 0.0, + imageRect.width - theme._deviceMarkerSize, + ); + final newDy = (device.position.dy + details.delta.dy).clamp( + 0.0, + imageRect.height - theme._deviceMarkerSize, + ); + onPositionChanged(Offset(newDx, newDy)); + }, + child: DeviceMarker(device: device, theme: theme), + ), + ); + } +} + +class FixedDeviceMarker extends StatelessWidget { + final Device device; + final Rect imageRect; + final WiFiHeatmapTheme theme; + + const FixedDeviceMarker({ + super.key, + required this.device, + required this.imageRect, + required this.theme, + }); + + @override + Widget build(BuildContext context) { + return Positioned( + left: imageRect.left + device.position.dx, + top: imageRect.top + device.position.dy, + child: DeviceMarker(device: device, theme: theme), + ); + } +} + +class ScanPointDot extends StatelessWidget { + final Offset gridPos; + final List colWidths; + final List rowHeights; + final Rect imageRect; + final WiFiHeatmapTheme theme; + + const ScanPointDot({ + super.key, + required this.gridPos, + required this.colWidths, + required this.rowHeights, + required this.imageRect, + required this.theme, + }); + + @override + Widget build(BuildContext context) { + final r = gridPos.dx.toInt(); + final c = gridPos.dy.toInt(); + + double x = colWidths.take(c).fold(0.0, (a, b) => a + b) + colWidths[c] / 2; + double y = + rowHeights.take(r).fold(0.0, (a, b) => a + b) + rowHeights[r] / 2; + + final cellW = colWidths[c]; + final cellH = rowHeights[r]; + final dotSize = min(cellW, cellH) * theme._scanPointDotSizeFactor; + + return Positioned( + left: imageRect.left + x - dotSize / 2, + top: imageRect.top + y - dotSize / 2, + child: Container( + width: dotSize, + height: dotSize, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: theme._scanPointColor, + border: Border.all( + color: theme._scanPointBorderColor, + width: theme._scanPointBorderWidth, + ), + boxShadow: const [ + BoxShadow( + color: Colors.black26, + blurRadius: 4, + offset: Offset(1, 2), + ), + ], + ), + ), + ); + } +} + +class MarkerPin extends StatelessWidget { + final Offset gridPos; + final List colWidths; + final List rowHeights; + final Rect imageRect; + final WiFiHeatmapTheme theme; + + const MarkerPin({ + super.key, + required this.gridPos, + required this.colWidths, + required this.rowHeights, + required this.imageRect, + required this.theme, + }); + + @override + Widget build(BuildContext context) { + final r = gridPos.dx.toInt(); + final c = gridPos.dy.toInt(); + + final center = Offset( + colWidths.take(c).fold(0.0, (a, b) => a + b) + colWidths[c] / 2, + rowHeights.take(r).fold(0.0, (a, b) => a + b) + rowHeights[r] / 2, + ); + + final icon = theme.getLocationPinIcon(); + + return Positioned( + left: imageRect.left + center.dx - theme._locationPinSize / 2, + top: imageRect.top + center.dy - theme._locationPinSize * 0.85, + child: IconTheme( + data: IconThemeData( + size: theme._locationPinSize, + color: theme._locationPinColor, + shadows: const [ + Shadow(color: Colors.black45, blurRadius: 6, offset: Offset(2, 3)), + ], + ), + child: icon, + ), + ); + } +} + +/// Painters +class VariableGridPainterWidget extends StatelessWidget { + final List> grid; + final List colWidths; + final List rowHeights; + final WiFiHeatmapTheme theme; + + const VariableGridPainterWidget({ + super.key, + required this.grid, + required this.colWidths, + required this.rowHeights, + required this.theme, + }); + + @override + Widget build(BuildContext context) { + return CustomPaint( + painter: _VariableGridPainter( + grid: grid, + colWidths: colWidths, + rowHeights: rowHeights, + theme: theme, + ), + size: Size.infinite, + ); + } +} + +class _VariableGridPainter extends CustomPainter { + final List> grid; + final List colWidths; + final List rowHeights; + final WiFiHeatmapTheme theme; + + _VariableGridPainter({ + required this.grid, + required this.colWidths, + required this.rowHeights, + required this.theme, + }); + + @override + void paint(Canvas canvas, Size size) { + // Heatmap fills + double y = 0; + for (int r = 0; r < grid.length; r++) { + double x = 0; + for (int c = 0; c < grid[r].length; c++) { + final cell = grid[r][c]; + if (cell.scanned && cell.color != null) { + canvas.drawRect( + Rect.fromLTWH(x, y, colWidths[c], rowHeights[r]), + Paint()..color = cell.color!.withAlpha(theme._heatmapFillAlpha), + ); + } + x += colWidths[c]; + } + y += rowHeights[r]; + } + + // Grid lines + final linePaint = Paint() + ..style = PaintingStyle.stroke + ..color = theme._gridLineColor.withAlpha(theme._gridAlpha) + ..strokeWidth = theme._gridLineWidth; + + y = 0; + for (final h in rowHeights) { + canvas.drawLine(Offset(0, y), Offset(size.width, y), linePaint); + y += h; + } + canvas.drawLine(Offset(0, y), Offset(size.width, y), linePaint); + + double x = 0; + for (final w in colWidths) { + canvas.drawLine(Offset(x, 0), Offset(x, size.height), linePaint); + x += w; + } + canvas.drawLine(Offset(x, 0), Offset(x, size.height), linePaint); + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => true; +} + +class PathConnectionPainter extends CustomPainter { + final List gridPoints; + final List colWidths; + final List rowHeights; + final WiFiHeatmapTheme theme; + final bool isDotted; + + PathConnectionPainter({ + required this.gridPoints, + required this.colWidths, + required this.rowHeights, + required this.theme, + this.isDotted = false, + }); + + Offset _gridToPixel(Offset g) { + final r = g.dx.toInt(); + final c = g.dy.toInt(); + final x = + colWidths.take(c).fold(0, (a, b) => a + b) + colWidths[c] / 2; + final y = + rowHeights.take(r).fold(0, (a, b) => a + b) + rowHeights[r] / 2; + return Offset(x, y); + } + + @override + void paint(Canvas canvas, Size size) { + if (gridPoints.length < 2) return; + + final paint = Paint() + ..color = theme._pathColor + ..strokeWidth = theme._pathWidth + ..style = PaintingStyle.stroke; + + if (isDotted) { + paint.strokeCap = StrokeCap.round; + const dashPattern = [8.0, 5.0]; + for (int i = 0; i < gridPoints.length - 1; i++) { + final a = _gridToPixel(gridPoints[i]); + final b = _gridToPixel(gridPoints[i + 1]); + _drawDashedLine(canvas, a, b, paint, dashPattern); + } + } else { + for (int i = 0; i < gridPoints.length - 1; i++) { + final a = _gridToPixel(gridPoints[i]); + final b = _gridToPixel(gridPoints[i + 1]); + canvas.drawLine(a, b, paint); + } + } + } + + void _drawDashedLine( + Canvas canvas, + Offset a, + Offset b, + Paint paint, + List pattern, + ) { + final dx = b.dx - a.dx; + final dy = b.dy - a.dy; + final dist = sqrt(dx * dx + dy * dy); + if (dist < 1e-6) return; + + double traveled = 0.0; + bool shouldDraw = true; + int patternIndex = 0; + + while (traveled < dist) { + final segmentLength = pattern[patternIndex % pattern.length]; + final progress = traveled / dist; + final nextProgress = (traveled + segmentLength) / dist; + + final x1 = a.dx + dx * progress; + final y1 = a.dy + dy * progress; + final x2 = a.dx + dx * nextProgress.clamp(0.0, 1.0); + final y2 = a.dy + dy * nextProgress.clamp(0.0, 1.0); + + if (shouldDraw) { + canvas.drawLine(Offset(x1, y1), Offset(x2, y2), paint); + } + + traveled += segmentLength; + shouldDraw = !shouldDraw; + patternIndex++; + } + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => true; +} diff --git a/modules/ensemble/lib/widget/widget_registry.dart b/modules/ensemble/lib/widget/widget_registry.dart index eda843a66..67ec7ed4d 100644 --- a/modules/ensemble/lib/widget/widget_registry.dart +++ b/modules/ensemble/lib/widget/widget_registry.dart @@ -74,6 +74,7 @@ import 'package:ensemble/widget/weeklyscheduler.dart'; import 'package:get_it/get_it.dart'; import 'fintech/tabapayconnect.dart'; +import 'wifi_heatmap.dart'; class WidgetRegistry { static final WidgetRegistry _instance = WidgetRegistry._(); @@ -89,7 +90,7 @@ class WidgetRegistry { Shape.type: Shape.build, StaticMap.type: StaticMap.build, EnsembleSignature.type: EnsembleSignature.build, - ExternalWidget.type: ExternalWidget.build, + ExternalWidget.type: ExternalWidget.build, }; /// register or override a widget @@ -192,6 +193,7 @@ class WidgetRegistry { EnsembleBarChart.type: () => EnsembleBarChart(), ChartJs.type: () => ChartJs(), TopologyChart.type: () => TopologyChart(), + WiFiHeatmap.type: () => WiFiHeatmap(), //domain specific or custom widgets FinicityConnect.type: () => FinicityConnect(), diff --git a/modules/ensemble/lib/widget/wifi_heatmap.dart b/modules/ensemble/lib/widget/wifi_heatmap.dart new file mode 100644 index 000000000..1959a432d --- /dev/null +++ b/modules/ensemble/lib/widget/wifi_heatmap.dart @@ -0,0 +1,249 @@ +// File: lib/widget/wifi_heatmap/wifi_heatmap.dart + +import 'dart:async'; + +import 'package:ensemble/framework/action.dart' as ensemble; +import 'package:ensemble/framework/event.dart'; +import 'package:ensemble/framework/widget/widget.dart'; +import 'package:ensemble/screen_controller.dart'; +import 'package:ensemble/util/utils.dart'; +import 'package:ensemble/widget/helpers/box_wrapper.dart'; +import 'package:ensemble/widget/helpers/controllers.dart'; +import 'package:ensemble_ts_interpreter/invokables/invokable.dart'; +import 'package:flutter/material.dart'; +import 'package:ensemble/framework/widget/icon.dart' as ensembleIcon; +import 'visualization/wifi_heatmap_widget.dart'; + +// ignore: must_be_immutable +class WiFiHeatmap extends StatefulWidget + with Invokable, HasController { + static const type = 'WiFiHeatmap'; + + static Widget build({Key? key}) { + return WiFiHeatmap(key: key); + } + + WiFiHeatmap({super.key}); + + final WiFiHeatmapController _controller = WiFiHeatmapController(); + + @override + WiFiHeatmapController get controller => _controller; + + @override + Map getters() { + return { + 'floorPlan': () => _controller.floorPlan, + 'gridSize': () => _controller.gridSize, + 'mode': () => _controller.mode, + }; + } + + @override + Map setters() { + return { + 'floorPlan': (v) => + _controller.floorPlan = Utils.getString(v, fallback: ''), + 'gridSize': (v) => + _controller.gridSize = Utils.optionalInt(v, min: 4, max: 40), + 'mode': (v) => _controller.mode = Utils.getString(v, fallback: 'setup'), + 'theme': (v) => _controller.theme = _parseCustomTheme(v), + 'onMessage': (def) => _controller.onMessage = + ensemble.EnsembleAction.from(def, initiator: this), + 'onScanComplete': (def) => _controller.onScanComplete = + ensemble.EnsembleAction.from(def, initiator: this), + 'getSignalStrength': (v) => _controller.customGetSignalStrength = v, + }; + } + + @override + Map methods() { + return { + 'startScanning': () => _controller._startScanning?.call(), + 'reset': () => _controller._reset?.call(), + }; + } + + WiFiHeatmapTheme? _parseCustomTheme(dynamic value) { + if (value is! Map) return null; + + final map = value; + + Icon? parseIcon(dynamic v) { + if (v == null) return null; + + final iconModel = Utils.getIcon(v); + if (iconModel == null) return null; + return ensembleIcon.Icon.fromModel(iconModel); + } + + return WiFiHeatmapTheme( + // Device marker properties + deviceMarkerSize: Utils.optionalDouble(map['deviceMarkerSize']), + deviceIconSize: Utils.optionalDouble(map['deviceIconSize']), + deviceBorderWidth: Utils.optionalDouble(map['deviceBorderWidth']), + deviceBorderColor: Utils.getColor(map['deviceBorderColor']), + + // Modem properties + modemColor: Utils.getColor(map['modemColor']), + modemIconColor: Utils.getColor(map['modemIconColor']), + modemIcon: parseIcon(map['modemIcon']), + + // Router properties + routerColor: Utils.getColor(map['routerColor']), + routerIconColor: Utils.getColor(map['routerIconColor']), + routerIcon: parseIcon(map['routerIcon']), + + // Scan point properties + scanPointDotSizeFactor: + Utils.optionalDouble(map['scanPointDotSizeFactor']), + scanPointColor: Utils.getColor(map['scanPointColor']), + scanPointBorderColor: Utils.getColor(map['scanPointBorderColor']), + scanPointBorderWidth: Utils.optionalDouble(map['scanPointBorderWidth']), + + // Location pin properties + locationPinSize: Utils.optionalDouble(map['locationPinSize']), + locationPinColor: Utils.getColor(map['locationPinColor']), + locationPinIcon: parseIcon(map['locationPinIcon']), + + // Grid properties + gridLineWidth: Utils.optionalDouble(map['gridLineWidth']), + gridAlpha: Utils.optionalInt(map['gridAlpha'], min: 0, max: 255), + gridLineColor: Utils.getColor(map['gridLineColor']), + + // Heatmap properties + heatmapFillAlpha: + Utils.optionalInt(map['heatmapFillAlpha'], min: 0, max: 255), + + // Path properties + pathColor: Utils.getColor(map['pathColor']), + pathWidth: Utils.optionalDouble(map['pathWidth']), + + // Grid size properties + defaultGridSize: + Utils.optionalInt(map['defaultGridSize'], min: 4, max: 32), + targetCellSize: Utils.optionalDouble(map['targetCellSize']), + + // Signal color properties + excellentSignalColor: Utils.getColor(map['excellentSignalColor']), + veryGoodSignalColor: Utils.getColor(map['veryGoodSignalColor']), + goodSignalColor: Utils.getColor(map['goodSignalColor']), + fairSignalColor: Utils.getColor(map['fairSignalColor']), + poorSignalColor: Utils.getColor(map['poorSignalColor']), + badSignalColor: Utils.getColor(map['badSignalColor']), + + // Button color properties + startScanButtonColor: Utils.getColor(map['startScanButtonColor']), + addCheckpointButtonColor: Utils.getColor(map['addCheckpointButtonColor']), + ); + } + + @override + State createState() => WiFiHeatmapState(); +} + +class WiFiHeatmapController extends BoxController { + String floorPlan = ''; + int? gridSize; + String mode = 'setup'; + + WiFiHeatmapTheme? theme; + + ensemble.EnsembleAction? onMessage; + ensemble.EnsembleAction? onScanComplete; + + dynamic customGetSignalStrength; + + VoidCallback? _startScanning; + VoidCallback? _reset; + + void showMessage(String msg, BuildContext context) { + print('WiFiHeatmap showMessage: $msg'); + + if (onMessage != null) { + ScreenController().executeAction( + context, + onMessage!, + event: EnsembleEvent(null, data: {'message': msg}), + ); + } else { + ScaffoldMessenger.maybeOf(context)?.showSnackBar( + SnackBar(content: Text(msg), duration: const Duration(seconds: 3)), + ); + } + } +} + +class WiFiHeatmapState extends EWidgetState { + final _heatmapKey = GlobalKey(); + + @override + void initState() { + super.initState(); + + widget.controller._startScanning = () { + if (widget.controller.mode != 'scanning' && mounted) { + setState(() { + widget.controller.mode = 'scanning'; + }); + } + }; + + widget.controller._reset = () { + if (mounted) { + setState(() { + widget.controller.mode = 'setup'; + }); + } + }; + } + + @override + Widget buildWidget(BuildContext context) { + return BoxWrapper( + boxController: widget.controller, + widget: WiFiHeatmapWidget( + key: _heatmapKey, + floorPlan: widget.controller.floorPlan, + gridSize: widget.controller.gridSize, + theme: widget.controller.theme ?? const WiFiHeatmapTheme(), + getSignalStrength: _getSignalStrength, + onShowMessage: (msg) => widget.controller.showMessage(msg, context), + ), + ); + } + + Future _getSignalStrength() async { + if (widget.controller.customGetSignalStrength != null) { + try { + final result = await widget.controller.customGetSignalStrength(); + if (result is Map && result['dBm'] != null) { + final dBm = Utils.getInt(result['dBm'], fallback: -70); + final colorStr = result['color']; + + Color color; + if (colorStr != null) { + color = Utils.getColor(colorStr) ?? + widget.controller.theme?.getSignalColor(dBm) ?? + const WiFiHeatmapTheme().getSignalColor(dBm); + } else { + color = widget.controller.theme?.getSignalColor(dBm) ?? + const WiFiHeatmapTheme().getSignalColor(dBm); + } + + return SignalResult(dBm, color); + } + } catch (e) { + print('Custom getSignalStrength failed: $e'); + } + } + + // Fallback random signal generation + await Future.delayed(const Duration(milliseconds: 600)); + const values = [-45, -52, -58, -64, -72, -79, -88, -94]; + final rssi = values[DateTime.now().millisecond % values.length]; + final color = widget.controller.theme?.getSignalColor(rssi) ?? + const WiFiHeatmapTheme().getSignalColor(rssi); + return SignalResult(rssi, color); + } +} From d1bf6dd10d92b23e7bdf053290db147fb6e9b12d Mon Sep 17 00:00:00 2001 From: M-Talha Date: Mon, 16 Feb 2026 19:00:26 +0500 Subject: [PATCH 2/3] Refactor WiFi Heatmap theming and styling - Introduced separate style classes for devices, scan points, location pins, grids, heatmaps, paths, signals, and buttons. - Removed the previous WiFiHeatmapTheme class and replaced it with individual style classes for better modularity and customization. --- .../visualization/wifi_heatmap_widget.dart | 500 ++++++++++-------- modules/ensemble/lib/widget/wifi_heatmap.dart | 242 +++++---- 2 files changed, 413 insertions(+), 329 deletions(-) diff --git a/modules/ensemble/lib/widget/visualization/wifi_heatmap_widget.dart b/modules/ensemble/lib/widget/visualization/wifi_heatmap_widget.dart index dd94e3200..46e6f1305 100644 --- a/modules/ensemble/lib/widget/visualization/wifi_heatmap_widget.dart +++ b/modules/ensemble/lib/widget/visualization/wifi_heatmap_widget.dart @@ -31,171 +31,147 @@ class SignalResult { SignalResult(this.dBm, this.color); } -/// WiFi Heatmap Theme Configuration -class WiFiHeatmapTheme { - final double? deviceMarkerSize; - final double? deviceIconSize; - final Color? modemColor; - final Color? modemIconColor; - final Icon? modemIcon; - final Color? routerColor; - final Color? routerIconColor; - final Icon? routerIcon; - final double? deviceBorderWidth; - final Color? deviceBorderColor; +/// Separate style classes +class DeviceStyles { + final double markerSize; + final double iconSize; + final double borderWidth; + final Color borderColor; + final Color modemColor; + final Color modemIconColor; + final Color routerColor; + final Color routerIconColor; + + const DeviceStyles({ + this.markerSize = 36.0, + this.iconSize = 22.0, + this.borderWidth = 2.8, + this.borderColor = Colors.white, + this.modemColor = Colors.red, + this.modemIconColor = Colors.white, + this.routerColor = Colors.blue, + this.routerIconColor = Colors.white, + }); - final double? scanPointDotSizeFactor; - final Color? scanPointColor; - final Color? scanPointBorderColor; - final double? scanPointBorderWidth; + Color getDeviceColor(Device device) => + device.isModem ? modemColor : routerColor; - final double? locationPinSize; - final Color? locationPinColor; - final Icon? locationPinIcon; + Color getDeviceIconColor(Device device) => + device.isModem ? modemIconColor : routerIconColor; +} - final double? gridLineWidth; - final int? gridAlpha; - final Color? gridLineColor; +class ScanPointStyles { + final double dotSizeFactor; + final Color color; + final Color borderColor; + final double borderWidth; + + const ScanPointStyles({ + this.dotSizeFactor = 0.4, + this.color = Colors.blueAccent, + this.borderColor = const Color(0xB3FFFFFF), + this.borderWidth = 1.8, + }); +} - final int? heatmapFillAlpha; +class LocationPinStyles { + final double size; + final Color color; - final Color? pathColor; - final double? pathWidth; + const LocationPinStyles({ + this.size = 44.0, + this.color = Colors.red, + }); +} - final int? defaultGridSize; - final double? targetCellSize; +class GridStyles { + final double lineWidth; + final int alpha; + final Color lineColor; - final Color? excellentSignalColor; - final Color? veryGoodSignalColor; - final Color? goodSignalColor; - final Color? fairSignalColor; - final Color? poorSignalColor; - final Color? badSignalColor; + const GridStyles({ + this.lineWidth = 0.6, + this.alpha = 60, + this.lineColor = Colors.black, + }); +} - final Color? startScanButtonColor; - final Color? addCheckpointButtonColor; +class HeatmapStyles { + final int fillAlpha; - const WiFiHeatmapTheme({ - this.deviceMarkerSize, - this.deviceIconSize, - this.modemColor, - this.modemIconColor, - this.modemIcon, - this.routerColor, - this.routerIconColor, - this.routerIcon, - this.deviceBorderWidth, - this.deviceBorderColor, - this.scanPointDotSizeFactor, - this.scanPointColor, - this.scanPointBorderColor, - this.scanPointBorderWidth, - this.locationPinSize, - this.locationPinColor, - this.locationPinIcon, - this.gridLineWidth, - this.gridAlpha, - this.gridLineColor, - this.heatmapFillAlpha, - this.pathColor, - this.pathWidth, - this.defaultGridSize, - this.targetCellSize, - this.excellentSignalColor, - this.veryGoodSignalColor, - this.goodSignalColor, - this.fairSignalColor, - this.poorSignalColor, - this.badSignalColor, - this.startScanButtonColor, - this.addCheckpointButtonColor, + const HeatmapStyles({ + this.fillAlpha = 123, }); +} - // Default values - double get _deviceMarkerSize => deviceMarkerSize ?? 36.0; - double get _deviceIconSize => deviceIconSize ?? 22.0; - Color get _modemColor => modemColor ?? Colors.red; - Color get _modemIconColor => modemIconColor ?? Colors.white; - Color get _routerColor => routerColor ?? Colors.blue; - Color get _routerIconColor => routerIconColor ?? Colors.white; - double get _deviceBorderWidth => deviceBorderWidth ?? 2.8; - Color get _deviceBorderColor => deviceBorderColor ?? Colors.white; - - double get _scanPointDotSizeFactor => scanPointDotSizeFactor ?? 0.4; - Color get _scanPointColor => scanPointColor ?? Colors.blueAccent; - Color get _scanPointBorderColor => - scanPointBorderColor ?? const Color(0xB3FFFFFF); - double get _scanPointBorderWidth => scanPointBorderWidth ?? 1.8; - - double get _locationPinSize => locationPinSize ?? 44.0; - Color get _locationPinColor => locationPinColor ?? Colors.red; - - double get _gridLineWidth => gridLineWidth ?? 0.6; - int get _gridAlpha => gridAlpha ?? 60; - Color get _gridLineColor => gridLineColor ?? Colors.black; - - int get _heatmapFillAlpha => heatmapFillAlpha ?? 123; - - Color get _pathColor => pathColor ?? const Color(0xFF1976D2); - double get _pathWidth => pathWidth ?? 2.8; - - int get _defaultGridSize => defaultGridSize ?? 12; - - Color get _excellentSignalColor => - excellentSignalColor ?? const Color(0xFF388E3C); - Color get _veryGoodSignalColor => - veryGoodSignalColor ?? const Color(0xFF66BB6A); - Color get _goodSignalColor => goodSignalColor ?? const Color(0xFFAFB42B); - Color get _fairSignalColor => fairSignalColor ?? const Color(0xFFF57C00); - Color get _poorSignalColor => poorSignalColor ?? const Color(0xFFE64A19); - Color get _badSignalColor => badSignalColor ?? const Color(0xFFC62828); - - Color get _startScanButtonColor => - startScanButtonColor ?? const Color(0xFF388E3C); - Color get _addCheckpointButtonColor => - addCheckpointButtonColor ?? const Color(0xFF1976D2); +class PathStyles { + final Color color; + final double width; - Color getSignalColor(int dBm) { - if (dBm >= -50) return _excellentSignalColor; - if (dBm >= -60) return _veryGoodSignalColor; - if (dBm >= -70) return _goodSignalColor; - if (dBm >= -80) return _fairSignalColor; - if (dBm >= -90) return _poorSignalColor; - return _badSignalColor; - } + const PathStyles({ + this.color = const Color(0xFF1976D2), + this.width = 2.8, + }); +} - Color getDeviceColor(Device device) => - device.isModem ? _modemColor : _routerColor; - Color getDeviceIconColor(Device device) => - device.isModem ? _modemIconColor : _routerIconColor; +class SignalStyles { + final Color excellentColor; + final Color veryGoodColor; + final Color goodColor; + final Color fairColor; + final Color poorColor; + final Color badColor; + + const SignalStyles({ + this.excellentColor = const Color(0xFF388E3C), + this.veryGoodColor = const Color(0xFF66BB6A), + this.goodColor = const Color(0xFFAFB42B), + this.fairColor = const Color(0xFFF57C00), + this.poorColor = const Color(0xFFE64A19), + this.badColor = const Color(0xFFC62828), + }); - Icon getDeviceIcon(Device device) { - if (device.isModem) { - return modemIcon ?? - Icon(Icons.wifi, color: _modemIconColor, size: _deviceIconSize); - } else { - return routerIcon ?? - Icon(Icons.router, color: _routerIconColor, size: _deviceIconSize); - } + Color getSignalColor(int dBm) { + if (dBm >= -50) return excellentColor; + if (dBm >= -60) return veryGoodColor; + if (dBm >= -70) return goodColor; + if (dBm >= -80) return fairColor; + if (dBm >= -90) return poorColor; + return badColor; } +} - Icon getLocationPinIcon() { - return locationPinIcon ?? - Icon( - Icons.location_on, - color: _locationPinColor, - size: _locationPinSize, - ); - } +class ButtonStyles { + final Color startScanColor; + final Color addCheckpointColor; + + const ButtonStyles({ + this.startScanColor = const Color(0xFF388E3C), + this.addCheckpointColor = const Color(0xFF1976D2), + }); } /// Reusable WiFi Heatmap Widget class WiFiHeatmapWidget extends StatefulWidget { final Future Function()? getSignalStrength; final String floorPlan; - final int? gridSize; + final int gridSize; final Function(String message)? onShowMessage; - final WiFiHeatmapTheme theme; + + // Separate style classes + final DeviceStyles deviceStyles; + final ScanPointStyles scanPointStyles; + final LocationPinStyles locationPinStyles; + final GridStyles gridStyles; + final HeatmapStyles heatmapStyles; + final PathStyles pathStyles; + final SignalStyles signalStyles; + final ButtonStyles buttonStyles; + + // Icons moved out of styles + final Icon? modemIcon; + final Icon? routerIcon; + final Icon? locationPinIcon; // Error state customization final String errorTitle; @@ -210,9 +186,19 @@ class WiFiHeatmapWidget extends StatefulWidget { super.key, this.getSignalStrength, required this.floorPlan, - this.gridSize, + this.gridSize = 12, this.onShowMessage, - this.theme = const WiFiHeatmapTheme(), + this.deviceStyles = const DeviceStyles(), + this.scanPointStyles = const ScanPointStyles(), + this.locationPinStyles = const LocationPinStyles(), + this.gridStyles = const GridStyles(), + this.heatmapStyles = const HeatmapStyles(), + this.pathStyles = const PathStyles(), + this.signalStyles = const SignalStyles(), + this.buttonStyles = const ButtonStyles(), + this.modemIcon, + this.routerIcon, + this.locationPinIcon, this.errorTitle = 'Invalid or missing floor plan', this.errorMessage = 'Please provide a valid image path', this.errorIcon = Icons.broken_image, @@ -250,9 +236,6 @@ class _WiFiHeatmapWidgetState extends State { bool _imageLoadFailed = false; - int get _effectiveGridSize => - widget.gridSize ?? widget.theme._defaultGridSize; - @override void initState() { super.initState(); @@ -316,10 +299,8 @@ class _WiFiHeatmapWidgetState extends State { if (availW <= 0 || availH <= 0) return; - // Use full available dimensions - no padding final newRect = Rect.fromLTWH(0, 0, availW, availH); - // Only update if rect has actually changed significantly if (_displayedImageRect == null || (_displayedImageRect!.left - newRect.left).abs() > 1 || (_displayedImageRect!.top - newRect.top).abs() > 1 || @@ -327,7 +308,6 @@ class _WiFiHeatmapWidgetState extends State { (_displayedImageRect!.height - newRect.height).abs() > 1) { _displayedImageRect = newRect; - // Recreate grid when image rect changes in scanning mode if (_mode == 'scanning' && _grid.isEmpty) { WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) { @@ -338,6 +318,29 @@ class _WiFiHeatmapWidgetState extends State { } } + Icon _getDeviceIcon(Device device) { + if (device.isModem) { + return widget.modemIcon ?? + Icon(Icons.wifi, + color: widget.deviceStyles.modemIconColor, + size: widget.deviceStyles.iconSize); + } else { + return widget.routerIcon ?? + Icon(Icons.router, + color: widget.deviceStyles.routerIconColor, + size: widget.deviceStyles.iconSize); + } + } + + Icon _getLocationPinIcon() { + return widget.locationPinIcon ?? + Icon( + Icons.location_on, + color: widget.locationPinStyles.color, + size: widget.locationPinStyles.size, + ); + } + Widget _buildErrorState() { return Center( child: Column( @@ -393,7 +396,8 @@ class _WiFiHeatmapWidgetState extends State { DraggableDevice( device: _modem!, imageRect: _displayedImageRect!, - theme: widget.theme, + deviceStyles: widget.deviceStyles, + getIcon: _getDeviceIcon, onPositionChanged: (newPos) => setState(() => _modem!.position = newPos), ), @@ -401,7 +405,8 @@ class _WiFiHeatmapWidgetState extends State { (r) => DraggableDevice( device: r, imageRect: _displayedImageRect!, - theme: widget.theme, + deviceStyles: widget.deviceStyles, + getIcon: _getDeviceIcon, onPositionChanged: (newPos) => setState(() => r.position = newPos), ), ), @@ -435,7 +440,8 @@ class _WiFiHeatmapWidgetState extends State { grid: _grid, colWidths: _colWidths, rowHeights: _rowHeights, - theme: widget.theme, + gridStyles: widget.gridStyles, + heatmapStyles: widget.heatmapStyles, ), ), if (_scannedGridPositions.length >= 2) @@ -446,7 +452,7 @@ class _WiFiHeatmapWidgetState extends State { gridPoints: _scannedGridPositions, colWidths: _colWidths, rowHeights: _rowHeights, - theme: widget.theme, + pathStyles: widget.pathStyles, ), ), ), @@ -458,7 +464,7 @@ class _WiFiHeatmapWidgetState extends State { gridPoints: [_scannedGridPositions.last, _markerGridPos!], colWidths: _colWidths, rowHeights: _rowHeights, - theme: widget.theme, + pathStyles: widget.pathStyles, isDotted: true, ), ), @@ -469,25 +475,28 @@ class _WiFiHeatmapWidgetState extends State { colWidths: _colWidths, rowHeights: _rowHeights, imageRect: _displayedImageRect!, - theme: widget.theme, + scanPointStyles: widget.scanPointStyles, ), ), if (_modem != null) FixedDeviceMarker( device: _modem!, imageRect: _displayedImageRect!, - theme: widget.theme), + deviceStyles: widget.deviceStyles, + getIcon: _getDeviceIcon), ..._routers.map((router) => FixedDeviceMarker( device: router, imageRect: _displayedImageRect!, - theme: widget.theme)), + deviceStyles: widget.deviceStyles, + getIcon: _getDeviceIcon)), if (_markerGridPos != null) MarkerPin( gridPos: _markerGridPos!, colWidths: _colWidths, rowHeights: _rowHeights, imageRect: _displayedImageRect!, - theme: widget.theme, + locationPinStyles: widget.locationPinStyles, + getIcon: _getLocationPinIcon, ), ], if (_displayedImageRect != null && _grid.isNotEmpty) @@ -522,18 +531,14 @@ class _WiFiHeatmapWidgetState extends State { final double imageAspectRatio = _originalImageSize!.width / _originalImageSize!.height; - // Calculate image display dimensions (no padding, full width) final double imageDisplayWidth = availableWidth; final double imageDisplayHeight = imageDisplayWidth / imageAspectRatio; - // Button area height (only for scanning mode) const double buttonAreaHeight = 76.0; - // Total height: image + button area (only in scanning mode) final double totalHeight = imageDisplayHeight + (_mode == 'scanning' ? buttonAreaHeight : 0); - // Update displayed rect with exact image dimensions _updateDisplayedRect(BoxConstraints( maxWidth: imageDisplayWidth, maxHeight: imageDisplayHeight, @@ -573,7 +578,7 @@ class _WiFiHeatmapWidgetState extends State { style: ElevatedButton.styleFrom( minimumSize: const Size.fromHeight(52), backgroundColor: - widget.theme._addCheckpointButtonColor, + widget.buttonStyles.addCheckpointColor, foregroundColor: Colors.white, ), onPressed: _addCheckpoint, @@ -593,31 +598,31 @@ class _WiFiHeatmapWidgetState extends State { children: [ FloatingActionButton( heroTag: 'modem', - backgroundColor: widget.theme._modemColor, + backgroundColor: widget.deviceStyles.modemColor, onPressed: _addModem, - child: widget.theme.modemIcon ?? + child: widget.modemIcon ?? Icon( Icons.wifi, - color: widget.theme._modemIconColor, - size: widget.theme._deviceIconSize, + color: widget.deviceStyles.modemIconColor, + size: widget.deviceStyles.iconSize, ), ), const SizedBox(height: 12), FloatingActionButton( heroTag: 'router', - backgroundColor: widget.theme._routerColor, + backgroundColor: widget.deviceStyles.routerColor, onPressed: _addRouter, - child: widget.theme.routerIcon ?? + child: widget.routerIcon ?? Icon( Icons.router, - color: widget.theme._routerIconColor, - size: widget.theme._deviceIconSize, + color: widget.deviceStyles.routerIconColor, + size: widget.deviceStyles.iconSize, ), ), const SizedBox(height: 16), FloatingActionButton( heroTag: 'start', - backgroundColor: widget.theme._startScanButtonColor, + backgroundColor: widget.buttonStyles.startScanColor, onPressed: _startScanning, child: const Icon(Icons.navigate_next_outlined, color: Colors.white), ), @@ -631,7 +636,7 @@ class _WiFiHeatmapWidgetState extends State { final w = _displayedImageRect!.width; final h = _displayedImageRect!.height; - final n = _effectiveGridSize.clamp(4, 32); + final n = widget.gridSize.clamp(4, 32); _colWidths = List.generate(n, (_) => w / n); _rowHeights = List.generate(n, (_) => h / n); @@ -815,7 +820,7 @@ class _WiFiHeatmapWidgetState extends State { final cell = _grid[rr][cc]; final d = cellDbms[i]; cell.rssi = d; - cell.color = widget.theme.getSignalColor(d); + cell.color = widget.signalStyles.getSignalColor(d); cell.scanned = true; } } @@ -868,15 +873,15 @@ class _WiFiHeatmapWidgetState extends State { final random = Random(); final dBm = possibleValues[random.nextInt(possibleValues.length)]; - return SignalResult(dBm, widget.theme.getSignalColor(dBm)); + return SignalResult(dBm, widget.signalStyles.getSignalColor(dBm)); } void _addModem() { if (_displayedImageRect == null || _modem != null) return; final center = Offset( - (_displayedImageRect!.width - widget.theme._deviceMarkerSize) / 2, - (_displayedImageRect!.height - widget.theme._deviceMarkerSize) / 2, + (_displayedImageRect!.width - widget.deviceStyles.markerSize) / 2, + (_displayedImageRect!.height - widget.deviceStyles.markerSize) / 2, ); setState(() => _modem = Device(type: 'modem', position: center)); @@ -886,8 +891,8 @@ class _WiFiHeatmapWidgetState extends State { if (_displayedImageRect == null) return; final center = Offset( - (_displayedImageRect!.width - widget.theme._deviceMarkerSize) / 2, - (_displayedImageRect!.height - widget.theme._deviceMarkerSize) / 2, + (_displayedImageRect!.width - widget.deviceStyles.markerSize) / 2, + (_displayedImageRect!.height - widget.deviceStyles.markerSize) / 2, ); setState(() => _routers.add(Device(type: 'router', position: center))); } @@ -900,11 +905,9 @@ class _WiFiHeatmapWidgetState extends State { setState(() { _mode = 'scanning'; - // Clear grid to force recreation with current image rect _grid.clear(); }); - // Recreate grid after mode change WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted && _displayedImageRect != null) { setState(() => _createGrid()); @@ -917,15 +920,29 @@ class _WiFiHeatmapWidgetState extends State { class WiFiHeatmapScreen extends StatefulWidget { final Future Function()? getSignalStrength; final String floorPlan; - final int? gridSize; - final WiFiHeatmapTheme? theme; + final int gridSize; + final DeviceStyles? deviceStyles; + final ScanPointStyles? scanPointStyles; + final LocationPinStyles? locationPinStyles; + final GridStyles? gridStyles; + final HeatmapStyles? heatmapStyles; + final PathStyles? pathStyles; + final SignalStyles? signalStyles; + final ButtonStyles? buttonStyles; const WiFiHeatmapScreen({ super.key, this.getSignalStrength, required this.floorPlan, - this.gridSize, - this.theme, + this.gridSize = 12, + this.deviceStyles, + this.scanPointStyles, + this.locationPinStyles, + this.gridStyles, + this.heatmapStyles, + this.pathStyles, + this.signalStyles, + this.buttonStyles, }); @override @@ -941,7 +958,15 @@ class _WiFiHeatmapScreenState extends State { floorPlan: widget.floorPlan, gridSize: widget.gridSize, getSignalStrength: widget.getSignalStrength, - theme: widget.theme ?? const WiFiHeatmapTheme(), + deviceStyles: widget.deviceStyles ?? const DeviceStyles(), + scanPointStyles: widget.scanPointStyles ?? const ScanPointStyles(), + locationPinStyles: + widget.locationPinStyles ?? const LocationPinStyles(), + gridStyles: widget.gridStyles ?? const GridStyles(), + heatmapStyles: widget.heatmapStyles ?? const HeatmapStyles(), + pathStyles: widget.pathStyles ?? const PathStyles(), + signalStyles: widget.signalStyles ?? const SignalStyles(), + buttonStyles: widget.buttonStyles ?? const ButtonStyles(), onShowMessage: (message) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(message)), @@ -955,28 +980,34 @@ class _WiFiHeatmapScreenState extends State { /// Reusable Widgets class DeviceMarker extends StatelessWidget { final Device device; - final WiFiHeatmapTheme theme; + final DeviceStyles deviceStyles; + final Icon Function(Device) getIcon; - const DeviceMarker({super.key, required this.device, required this.theme}); + const DeviceMarker({ + super.key, + required this.device, + required this.deviceStyles, + required this.getIcon, + }); @override Widget build(BuildContext context) { return Container( - width: theme._deviceMarkerSize, - height: theme._deviceMarkerSize, + width: deviceStyles.markerSize, + height: deviceStyles.markerSize, decoration: BoxDecoration( - color: theme.getDeviceColor(device), + color: deviceStyles.getDeviceColor(device), shape: BoxShape.circle, border: Border.all( - color: theme._deviceBorderColor, - width: theme._deviceBorderWidth, + color: deviceStyles.borderColor, + width: deviceStyles.borderWidth, ), boxShadow: const [ BoxShadow(color: Colors.black45, blurRadius: 6, offset: Offset(2, 3)), ], ), alignment: Alignment.center, - child: theme.getDeviceIcon(device), + child: getIcon(device), ); } } @@ -985,14 +1016,16 @@ class DraggableDevice extends StatelessWidget { final Device device; final Rect imageRect; final ValueChanged onPositionChanged; - final WiFiHeatmapTheme theme; + final DeviceStyles deviceStyles; + final Icon Function(Device) getIcon; const DraggableDevice({ super.key, required this.device, required this.imageRect, required this.onPositionChanged, - required this.theme, + required this.deviceStyles, + required this.getIcon, }); @override @@ -1005,15 +1038,16 @@ class DraggableDevice extends StatelessWidget { onPanUpdate: (details) { final newDx = (device.position.dx + details.delta.dx).clamp( 0.0, - imageRect.width - theme._deviceMarkerSize, + imageRect.width - deviceStyles.markerSize, ); final newDy = (device.position.dy + details.delta.dy).clamp( 0.0, - imageRect.height - theme._deviceMarkerSize, + imageRect.height - deviceStyles.markerSize, ); onPositionChanged(Offset(newDx, newDy)); }, - child: DeviceMarker(device: device, theme: theme), + child: DeviceMarker( + device: device, deviceStyles: deviceStyles, getIcon: getIcon), ), ); } @@ -1022,13 +1056,15 @@ class DraggableDevice extends StatelessWidget { class FixedDeviceMarker extends StatelessWidget { final Device device; final Rect imageRect; - final WiFiHeatmapTheme theme; + final DeviceStyles deviceStyles; + final Icon Function(Device) getIcon; const FixedDeviceMarker({ super.key, required this.device, required this.imageRect, - required this.theme, + required this.deviceStyles, + required this.getIcon, }); @override @@ -1036,7 +1072,8 @@ class FixedDeviceMarker extends StatelessWidget { return Positioned( left: imageRect.left + device.position.dx, top: imageRect.top + device.position.dy, - child: DeviceMarker(device: device, theme: theme), + child: DeviceMarker( + device: device, deviceStyles: deviceStyles, getIcon: getIcon), ); } } @@ -1046,7 +1083,7 @@ class ScanPointDot extends StatelessWidget { final List colWidths; final List rowHeights; final Rect imageRect; - final WiFiHeatmapTheme theme; + final ScanPointStyles scanPointStyles; const ScanPointDot({ super.key, @@ -1054,7 +1091,7 @@ class ScanPointDot extends StatelessWidget { required this.colWidths, required this.rowHeights, required this.imageRect, - required this.theme, + required this.scanPointStyles, }); @override @@ -1068,7 +1105,7 @@ class ScanPointDot extends StatelessWidget { final cellW = colWidths[c]; final cellH = rowHeights[r]; - final dotSize = min(cellW, cellH) * theme._scanPointDotSizeFactor; + final dotSize = min(cellW, cellH) * scanPointStyles.dotSizeFactor; return Positioned( left: imageRect.left + x - dotSize / 2, @@ -1078,10 +1115,10 @@ class ScanPointDot extends StatelessWidget { height: dotSize, decoration: BoxDecoration( shape: BoxShape.circle, - color: theme._scanPointColor, + color: scanPointStyles.color, border: Border.all( - color: theme._scanPointBorderColor, - width: theme._scanPointBorderWidth, + color: scanPointStyles.borderColor, + width: scanPointStyles.borderWidth, ), boxShadow: const [ BoxShadow( @@ -1101,7 +1138,8 @@ class MarkerPin extends StatelessWidget { final List colWidths; final List rowHeights; final Rect imageRect; - final WiFiHeatmapTheme theme; + final LocationPinStyles locationPinStyles; + final Icon Function() getIcon; const MarkerPin({ super.key, @@ -1109,7 +1147,8 @@ class MarkerPin extends StatelessWidget { required this.colWidths, required this.rowHeights, required this.imageRect, - required this.theme, + required this.locationPinStyles, + required this.getIcon, }); @override @@ -1122,15 +1161,15 @@ class MarkerPin extends StatelessWidget { rowHeights.take(r).fold(0.0, (a, b) => a + b) + rowHeights[r] / 2, ); - final icon = theme.getLocationPinIcon(); + final icon = getIcon(); return Positioned( - left: imageRect.left + center.dx - theme._locationPinSize / 2, - top: imageRect.top + center.dy - theme._locationPinSize * 0.85, + left: imageRect.left + center.dx - locationPinStyles.size / 2, + top: imageRect.top + center.dy - locationPinStyles.size * 0.85, child: IconTheme( data: IconThemeData( - size: theme._locationPinSize, - color: theme._locationPinColor, + size: locationPinStyles.size, + color: locationPinStyles.color, shadows: const [ Shadow(color: Colors.black45, blurRadius: 6, offset: Offset(2, 3)), ], @@ -1146,14 +1185,16 @@ class VariableGridPainterWidget extends StatelessWidget { final List> grid; final List colWidths; final List rowHeights; - final WiFiHeatmapTheme theme; + final GridStyles gridStyles; + final HeatmapStyles heatmapStyles; const VariableGridPainterWidget({ super.key, required this.grid, required this.colWidths, required this.rowHeights, - required this.theme, + required this.gridStyles, + required this.heatmapStyles, }); @override @@ -1163,7 +1204,8 @@ class VariableGridPainterWidget extends StatelessWidget { grid: grid, colWidths: colWidths, rowHeights: rowHeights, - theme: theme, + gridStyles: gridStyles, + heatmapStyles: heatmapStyles, ), size: Size.infinite, ); @@ -1174,13 +1216,15 @@ class _VariableGridPainter extends CustomPainter { final List> grid; final List colWidths; final List rowHeights; - final WiFiHeatmapTheme theme; + final GridStyles gridStyles; + final HeatmapStyles heatmapStyles; _VariableGridPainter({ required this.grid, required this.colWidths, required this.rowHeights, - required this.theme, + required this.gridStyles, + required this.heatmapStyles, }); @override @@ -1194,7 +1238,7 @@ class _VariableGridPainter extends CustomPainter { if (cell.scanned && cell.color != null) { canvas.drawRect( Rect.fromLTWH(x, y, colWidths[c], rowHeights[r]), - Paint()..color = cell.color!.withAlpha(theme._heatmapFillAlpha), + Paint()..color = cell.color!.withAlpha(heatmapStyles.fillAlpha), ); } x += colWidths[c]; @@ -1205,8 +1249,8 @@ class _VariableGridPainter extends CustomPainter { // Grid lines final linePaint = Paint() ..style = PaintingStyle.stroke - ..color = theme._gridLineColor.withAlpha(theme._gridAlpha) - ..strokeWidth = theme._gridLineWidth; + ..color = gridStyles.lineColor.withAlpha(gridStyles.alpha) + ..strokeWidth = gridStyles.lineWidth; y = 0; for (final h in rowHeights) { @@ -1231,14 +1275,14 @@ class PathConnectionPainter extends CustomPainter { final List gridPoints; final List colWidths; final List rowHeights; - final WiFiHeatmapTheme theme; + final PathStyles pathStyles; final bool isDotted; PathConnectionPainter({ required this.gridPoints, required this.colWidths, required this.rowHeights, - required this.theme, + required this.pathStyles, this.isDotted = false, }); @@ -1257,8 +1301,8 @@ class PathConnectionPainter extends CustomPainter { if (gridPoints.length < 2) return; final paint = Paint() - ..color = theme._pathColor - ..strokeWidth = theme._pathWidth + ..color = pathStyles.color + ..strokeWidth = pathStyles.width ..style = PaintingStyle.stroke; if (isDotted) { diff --git a/modules/ensemble/lib/widget/wifi_heatmap.dart b/modules/ensemble/lib/widget/wifi_heatmap.dart index 1959a432d..a2760d36b 100644 --- a/modules/ensemble/lib/widget/wifi_heatmap.dart +++ b/modules/ensemble/lib/widget/wifi_heatmap.dart @@ -45,14 +45,34 @@ class WiFiHeatmap extends StatefulWidget 'floorPlan': (v) => _controller.floorPlan = Utils.getString(v, fallback: ''), 'gridSize': (v) => - _controller.gridSize = Utils.optionalInt(v, min: 4, max: 40), + _controller.gridSize = Utils.optionalInt(v, min: 4, max: 40) ?? 12, 'mode': (v) => _controller.mode = Utils.getString(v, fallback: 'setup'), - 'theme': (v) => _controller.theme = _parseCustomTheme(v), + + // Icons as direct properties (not in styles) + 'modemIcon': (v) => _controller.modemIcon = _parseIcon(v), + 'routerIcon': (v) => _controller.routerIcon = _parseIcon(v), + 'locationPinIcon': (v) => _controller.locationPinIcon = _parseIcon(v), + + // Separate style maps + 'deviceStyles': (v) => _controller.deviceStyles = _parseDeviceStyles(v), + 'scanPointStyles': (v) => + _controller.scanPointStyles = _parseScanPointStyles(v), + 'locationPinStyles': (v) => + _controller.locationPinStyles = _parseLocationPinStyles(v), + 'gridStyles': (v) => _controller.gridStyles = _parseGridStyles(v), + 'heatmapStyles': (v) => + _controller.heatmapStyles = _parseHeatmapStyles(v), + 'pathStyles': (v) => _controller.pathStyles = _parsePathStyles(v), + 'signalStyles': (v) => _controller.signalStyles = _parseSignalStyles(v), + 'buttonStyles': (v) => _controller.buttonStyles = _parseButtonStyles(v), + + // Actions 'onMessage': (def) => _controller.onMessage = ensemble.EnsembleAction.from(def, initiator: this), 'onScanComplete': (def) => _controller.onScanComplete = ensemble.EnsembleAction.from(def, initiator: this), - 'getSignalStrength': (v) => _controller.customGetSignalStrength = v, + 'getSignalStrength': (def) => _controller.getSignalStrength = + ensemble.EnsembleAction.from(def, initiator: this), }; } @@ -64,77 +84,99 @@ class WiFiHeatmap extends StatefulWidget }; } - WiFiHeatmapTheme? _parseCustomTheme(dynamic value) { - if (value is! Map) return null; + Icon? _parseIcon(dynamic v) { + if (v == null) return null; + final iconModel = Utils.getIcon(v); + if (iconModel == null) return null; + return ensembleIcon.Icon.fromModel(iconModel); + } + DeviceStyles _parseDeviceStyles(dynamic value) { + if (value is! Map) return const DeviceStyles(); final map = value; + return DeviceStyles( + markerSize: Utils.optionalDouble(map['markerSize']) ?? 36.0, + iconSize: Utils.optionalDouble(map['iconSize']) ?? 22.0, + borderWidth: Utils.optionalDouble(map['borderWidth']) ?? 2.8, + borderColor: Utils.getColor(map['borderColor']) ?? Colors.white, + modemColor: Utils.getColor(map['modemColor']) ?? Colors.red, + modemIconColor: Utils.getColor(map['modemIconColor']) ?? Colors.white, + routerColor: Utils.getColor(map['routerColor']) ?? Colors.blue, + routerIconColor: Utils.getColor(map['routerIconColor']) ?? Colors.white, + ); + } - Icon? parseIcon(dynamic v) { - if (v == null) return null; + ScanPointStyles _parseScanPointStyles(dynamic value) { + if (value is! Map) return const ScanPointStyles(); + final map = value; + return ScanPointStyles( + dotSizeFactor: Utils.optionalDouble(map['dotSizeFactor']) ?? 0.4, + color: Utils.getColor(map['color']) ?? Colors.blueAccent, + borderColor: + Utils.getColor(map['borderColor']) ?? const Color(0xB3FFFFFF), + borderWidth: Utils.optionalDouble(map['borderWidth']) ?? 1.8, + ); + } - final iconModel = Utils.getIcon(v); - if (iconModel == null) return null; - return ensembleIcon.Icon.fromModel(iconModel); - } + LocationPinStyles _parseLocationPinStyles(dynamic value) { + if (value is! Map) return const LocationPinStyles(); + final map = value; + return LocationPinStyles( + size: Utils.optionalDouble(map['size']) ?? 44.0, + color: Utils.getColor(map['color']) ?? Colors.red, + ); + } + + GridStyles _parseGridStyles(dynamic value) { + if (value is! Map) return const GridStyles(); + final map = value; + return GridStyles( + lineWidth: Utils.optionalDouble(map['lineWidth']) ?? 0.6, + alpha: Utils.optionalInt(map['alpha'], min: 0, max: 255) ?? 60, + lineColor: Utils.getColor(map['lineColor']) ?? Colors.black, + ); + } + + HeatmapStyles _parseHeatmapStyles(dynamic value) { + if (value is! Map) return const HeatmapStyles(); + final map = value; + return HeatmapStyles( + fillAlpha: Utils.optionalInt(map['fillAlpha'], min: 0, max: 255) ?? 123, + ); + } - return WiFiHeatmapTheme( - // Device marker properties - deviceMarkerSize: Utils.optionalDouble(map['deviceMarkerSize']), - deviceIconSize: Utils.optionalDouble(map['deviceIconSize']), - deviceBorderWidth: Utils.optionalDouble(map['deviceBorderWidth']), - deviceBorderColor: Utils.getColor(map['deviceBorderColor']), - - // Modem properties - modemColor: Utils.getColor(map['modemColor']), - modemIconColor: Utils.getColor(map['modemIconColor']), - modemIcon: parseIcon(map['modemIcon']), - - // Router properties - routerColor: Utils.getColor(map['routerColor']), - routerIconColor: Utils.getColor(map['routerIconColor']), - routerIcon: parseIcon(map['routerIcon']), - - // Scan point properties - scanPointDotSizeFactor: - Utils.optionalDouble(map['scanPointDotSizeFactor']), - scanPointColor: Utils.getColor(map['scanPointColor']), - scanPointBorderColor: Utils.getColor(map['scanPointBorderColor']), - scanPointBorderWidth: Utils.optionalDouble(map['scanPointBorderWidth']), - - // Location pin properties - locationPinSize: Utils.optionalDouble(map['locationPinSize']), - locationPinColor: Utils.getColor(map['locationPinColor']), - locationPinIcon: parseIcon(map['locationPinIcon']), - - // Grid properties - gridLineWidth: Utils.optionalDouble(map['gridLineWidth']), - gridAlpha: Utils.optionalInt(map['gridAlpha'], min: 0, max: 255), - gridLineColor: Utils.getColor(map['gridLineColor']), - - // Heatmap properties - heatmapFillAlpha: - Utils.optionalInt(map['heatmapFillAlpha'], min: 0, max: 255), - - // Path properties - pathColor: Utils.getColor(map['pathColor']), - pathWidth: Utils.optionalDouble(map['pathWidth']), - - // Grid size properties - defaultGridSize: - Utils.optionalInt(map['defaultGridSize'], min: 4, max: 32), - targetCellSize: Utils.optionalDouble(map['targetCellSize']), - - // Signal color properties - excellentSignalColor: Utils.getColor(map['excellentSignalColor']), - veryGoodSignalColor: Utils.getColor(map['veryGoodSignalColor']), - goodSignalColor: Utils.getColor(map['goodSignalColor']), - fairSignalColor: Utils.getColor(map['fairSignalColor']), - poorSignalColor: Utils.getColor(map['poorSignalColor']), - badSignalColor: Utils.getColor(map['badSignalColor']), - - // Button color properties - startScanButtonColor: Utils.getColor(map['startScanButtonColor']), - addCheckpointButtonColor: Utils.getColor(map['addCheckpointButtonColor']), + PathStyles _parsePathStyles(dynamic value) { + if (value is! Map) return const PathStyles(); + final map = value; + return PathStyles( + color: Utils.getColor(map['color']) ?? const Color(0xFF1976D2), + width: Utils.optionalDouble(map['width']) ?? 2.8, + ); + } + + SignalStyles _parseSignalStyles(dynamic value) { + if (value is! Map) return const SignalStyles(); + final map = value; + return SignalStyles( + excellentColor: + Utils.getColor(map['excellentColor']) ?? const Color(0xFF388E3C), + veryGoodColor: + Utils.getColor(map['veryGoodColor']) ?? const Color(0xFF66BB6A), + goodColor: Utils.getColor(map['goodColor']) ?? const Color(0xFFAFB42B), + fairColor: Utils.getColor(map['fairColor']) ?? const Color(0xFFF57C00), + poorColor: Utils.getColor(map['poorColor']) ?? const Color(0xFFE64A19), + badColor: Utils.getColor(map['badColor']) ?? const Color(0xFFC62828), + ); + } + + ButtonStyles _parseButtonStyles(dynamic value) { + if (value is! Map) return const ButtonStyles(); + final map = value; + return ButtonStyles( + startScanColor: + Utils.getColor(map['startScanColor']) ?? const Color(0xFF388E3C), + addCheckpointColor: + Utils.getColor(map['addCheckpointColor']) ?? const Color(0xFF1976D2), ); } @@ -144,15 +186,28 @@ class WiFiHeatmap extends StatefulWidget class WiFiHeatmapController extends BoxController { String floorPlan = ''; - int? gridSize; + int gridSize = 12; String mode = 'setup'; - WiFiHeatmapTheme? theme; - + // Icons as direct properties (not in styles) + Icon? modemIcon; + Icon? routerIcon; + Icon? locationPinIcon; + + // Separate style maps + DeviceStyles deviceStyles = const DeviceStyles(); + ScanPointStyles scanPointStyles = const ScanPointStyles(); + LocationPinStyles locationPinStyles = const LocationPinStyles(); + GridStyles gridStyles = const GridStyles(); + HeatmapStyles heatmapStyles = const HeatmapStyles(); + PathStyles pathStyles = const PathStyles(); + SignalStyles signalStyles = const SignalStyles(); + ButtonStyles buttonStyles = const ButtonStyles(); + + // Actions ensemble.EnsembleAction? onMessage; ensemble.EnsembleAction? onScanComplete; - - dynamic customGetSignalStrength; + ensemble.EnsembleAction? getSignalStrength; VoidCallback? _startScanning; VoidCallback? _reset; @@ -206,7 +261,17 @@ class WiFiHeatmapState extends EWidgetState { key: _heatmapKey, floorPlan: widget.controller.floorPlan, gridSize: widget.controller.gridSize, - theme: widget.controller.theme ?? const WiFiHeatmapTheme(), + deviceStyles: widget.controller.deviceStyles, + scanPointStyles: widget.controller.scanPointStyles, + locationPinStyles: widget.controller.locationPinStyles, + gridStyles: widget.controller.gridStyles, + heatmapStyles: widget.controller.heatmapStyles, + pathStyles: widget.controller.pathStyles, + signalStyles: widget.controller.signalStyles, + buttonStyles: widget.controller.buttonStyles, + modemIcon: widget.controller.modemIcon, + routerIcon: widget.controller.routerIcon, + locationPinIcon: widget.controller.locationPinIcon, getSignalStrength: _getSignalStrength, onShowMessage: (msg) => widget.controller.showMessage(msg, context), ), @@ -214,36 +279,11 @@ class WiFiHeatmapState extends EWidgetState { } Future _getSignalStrength() async { - if (widget.controller.customGetSignalStrength != null) { - try { - final result = await widget.controller.customGetSignalStrength(); - if (result is Map && result['dBm'] != null) { - final dBm = Utils.getInt(result['dBm'], fallback: -70); - final colorStr = result['color']; - - Color color; - if (colorStr != null) { - color = Utils.getColor(colorStr) ?? - widget.controller.theme?.getSignalColor(dBm) ?? - const WiFiHeatmapTheme().getSignalColor(dBm); - } else { - color = widget.controller.theme?.getSignalColor(dBm) ?? - const WiFiHeatmapTheme().getSignalColor(dBm); - } - - return SignalResult(dBm, color); - } - } catch (e) { - print('Custom getSignalStrength failed: $e'); - } - } - // Fallback random signal generation await Future.delayed(const Duration(milliseconds: 600)); const values = [-45, -52, -58, -64, -72, -79, -88, -94]; final rssi = values[DateTime.now().millisecond % values.length]; - final color = widget.controller.theme?.getSignalColor(rssi) ?? - const WiFiHeatmapTheme().getSignalColor(rssi); + final color = widget.controller.signalStyles.getSignalColor(rssi); return SignalResult(rssi, color); } } From 2543d85d17a3aa381d30c6540880276bb38d0d07 Mon Sep 17 00:00:00 2001 From: M-Talha Date: Tue, 17 Feb 2026 02:56:53 +0500 Subject: [PATCH 3/3] feat: Enhance WiFiHeatmap with checkpoint tracking and signal value integration - Introduced Checkpoint model to track grid positions and timestamps. - Added signalValues parameter to WiFiHeatmapWidget for external signal data. --- .../visualization/wifi_heatmap_widget.dart | 449 ++++++++++-------- modules/ensemble/lib/widget/wifi_heatmap.dart | 158 ++++-- 2 files changed, 364 insertions(+), 243 deletions(-) diff --git a/modules/ensemble/lib/widget/visualization/wifi_heatmap_widget.dart b/modules/ensemble/lib/widget/visualization/wifi_heatmap_widget.dart index 46e6f1305..c3f18b2f4 100644 --- a/modules/ensemble/lib/widget/visualization/wifi_heatmap_widget.dart +++ b/modules/ensemble/lib/widget/visualization/wifi_heatmap_widget.dart @@ -2,13 +2,13 @@ import 'dart:async'; import 'dart:io'; import 'dart:math'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; /// Models class Device { final String type; Offset position; - Device({required this.type, required this.position}); bool get isModem => type == 'modem'; @@ -20,18 +20,29 @@ class GridCell { int rssi = -100; Color? color; bool scanned = false; - GridCell({required this.row, required this.col}); } class SignalResult { final int dBm; final Color color; - SignalResult(this.dBm, this.color); } -/// Separate style classes +/// Checkpoint model to track timestamp and grid position +class Checkpoint { + final Offset gridPosition; + final int timestamp; + final int dBm; + + Checkpoint({ + required this.gridPosition, + required this.timestamp, + required this.dBm, + }); +} + +/// Separate style classes (with nullable constructor params + defaults) class DeviceStyles { final double markerSize; final double iconSize; @@ -43,19 +54,25 @@ class DeviceStyles { final Color routerIconColor; const DeviceStyles({ - this.markerSize = 36.0, - this.iconSize = 22.0, - this.borderWidth = 2.8, - this.borderColor = Colors.white, - this.modemColor = Colors.red, - this.modemIconColor = Colors.white, - this.routerColor = Colors.blue, - this.routerIconColor = Colors.white, - }); + double? markerSize, + double? iconSize, + double? borderWidth, + Color? borderColor, + Color? modemColor, + Color? modemIconColor, + Color? routerColor, + Color? routerIconColor, + }) : markerSize = markerSize ?? 32.0, + iconSize = iconSize ?? 20.0, + borderWidth = borderWidth ?? 2.8, + borderColor = borderColor ?? Colors.white, + modemColor = modemColor ?? Colors.red, + modemIconColor = modemIconColor ?? Colors.white, + routerColor = routerColor ?? Colors.blue, + routerIconColor = routerIconColor ?? Colors.white; Color getDeviceColor(Device device) => device.isModem ? modemColor : routerColor; - Color getDeviceIconColor(Device device) => device.isModem ? modemIconColor : routerIconColor; } @@ -67,11 +84,14 @@ class ScanPointStyles { final double borderWidth; const ScanPointStyles({ - this.dotSizeFactor = 0.4, - this.color = Colors.blueAccent, - this.borderColor = const Color(0xB3FFFFFF), - this.borderWidth = 1.8, - }); + double? dotSizeFactor, + Color? color, + Color? borderColor, + double? borderWidth, + }) : dotSizeFactor = dotSizeFactor ?? 0.4, + color = color ?? Colors.blueAccent, + borderColor = borderColor ?? const Color(0xB3FFFFFF), + borderWidth = borderWidth ?? 1.8; } class LocationPinStyles { @@ -79,9 +99,10 @@ class LocationPinStyles { final Color color; const LocationPinStyles({ - this.size = 44.0, - this.color = Colors.red, - }); + double? size, + Color? color, + }) : size = size ?? 44.0, + color = color ?? Colors.red; } class GridStyles { @@ -90,18 +111,20 @@ class GridStyles { final Color lineColor; const GridStyles({ - this.lineWidth = 0.6, - this.alpha = 60, - this.lineColor = Colors.black, - }); + double? lineWidth, + int? alpha, + Color? lineColor, + }) : lineWidth = lineWidth ?? 0.6, + alpha = alpha ?? 60, + lineColor = lineColor ?? Colors.black; } class HeatmapStyles { final int fillAlpha; const HeatmapStyles({ - this.fillAlpha = 123, - }); + int? fillAlpha, + }) : fillAlpha = fillAlpha ?? 123; } class PathStyles { @@ -109,9 +132,10 @@ class PathStyles { final double width; const PathStyles({ - this.color = const Color(0xFF1976D2), - this.width = 2.8, - }); + Color? color, + double? width, + }) : color = color ?? const Color(0xFF1976D2), + width = width ?? 2.8; } class SignalStyles { @@ -123,13 +147,18 @@ class SignalStyles { final Color badColor; const SignalStyles({ - this.excellentColor = const Color(0xFF388E3C), - this.veryGoodColor = const Color(0xFF66BB6A), - this.goodColor = const Color(0xFFAFB42B), - this.fairColor = const Color(0xFFF57C00), - this.poorColor = const Color(0xFFE64A19), - this.badColor = const Color(0xFFC62828), - }); + Color? excellentColor, + Color? veryGoodColor, + Color? goodColor, + Color? fairColor, + Color? poorColor, + Color? badColor, + }) : excellentColor = excellentColor ?? const Color(0xFF388E3C), + veryGoodColor = veryGoodColor ?? const Color(0xFF66BB6A), + goodColor = goodColor ?? const Color(0xFFAFB42B), + fairColor = fairColor ?? const Color(0xFFF57C00), + poorColor = poorColor ?? const Color(0xFFE64A19), + badColor = badColor ?? const Color(0xFFC62828); Color getSignalColor(int dBm) { if (dBm >= -50) return excellentColor; @@ -146,9 +175,10 @@ class ButtonStyles { final Color addCheckpointColor; const ButtonStyles({ - this.startScanColor = const Color(0xFF388E3C), - this.addCheckpointColor = const Color(0xFF1976D2), - }); + Color? startScanColor, + Color? addCheckpointColor, + }) : startScanColor = startScanColor ?? const Color(0xFF388E3C), + addCheckpointColor = addCheckpointColor ?? const Color(0xFF1976D2); } /// Reusable WiFi Heatmap Widget @@ -157,6 +187,8 @@ class WiFiHeatmapWidget extends StatefulWidget { final String floorPlan; final int gridSize; final Function(String message)? onShowMessage; + final VoidCallback? onFirstCheckpoint; + final VoidCallback? onAllGridsFilled; // Separate style classes final DeviceStyles deviceStyles; @@ -173,6 +205,9 @@ class WiFiHeatmapWidget extends StatefulWidget { final Icon? routerIcon; final Icon? locationPinIcon; + // External signal values (list of {timestamp, dbm} objects) + final List> signalValues; + // Error state customization final String errorTitle; final String errorMessage; @@ -188,6 +223,8 @@ class WiFiHeatmapWidget extends StatefulWidget { required this.floorPlan, this.gridSize = 12, this.onShowMessage, + this.onFirstCheckpoint, + this.onAllGridsFilled, this.deviceStyles = const DeviceStyles(), this.scanPointStyles = const ScanPointStyles(), this.locationPinStyles = const LocationPinStyles(), @@ -199,6 +236,7 @@ class WiFiHeatmapWidget extends StatefulWidget { this.modemIcon, this.routerIcon, this.locationPinIcon, + this.signalValues = const [], this.errorTitle = 'Invalid or missing floor plan', this.errorMessage = 'Please provide a valid image path', this.errorIcon = Icons.broken_image, @@ -216,25 +254,21 @@ class _WiFiHeatmapWidgetState extends State { File? _floorPlan; Device? _modem; final List _routers = []; - Size? _originalImageSize; Rect? _displayedImageRect; - String _mode = 'setup'; - List> _grid = []; List _rowHeights = []; List _colWidths = []; - Offset? _markerGridPos; - final List _scannedGridPositions = []; - Timer? _signalTimer; - List _currentSegmentDbms = []; + // Checkpoint tracking + final List _checkpoints = []; + List> _signalValues = []; final _stackKey = GlobalKey(); - bool _imageLoadFailed = false; + bool _allGridsFilled = false; @override void initState() { @@ -245,19 +279,28 @@ class _WiFiHeatmapWidgetState extends State { }); } + @override + void didUpdateWidget(covariant WiFiHeatmapWidget oldWidget) { + super.didUpdateWidget(oldWidget); + // React to external signalValues updates + if (!listEquals(widget.signalValues, _signalValues)) { + _signalValues = List.from(widget.signalValues); + if (mounted) { + setState(() {}); + } + } + } + @override void dispose() { - _signalTimer?.cancel(); super.dispose(); } Future _loadImageSize() async { if (_floorPlan == null) return; - try { final completer = Completer(); final provider = FileImage(_floorPlan!); - provider.resolve(const ImageConfiguration()).addListener( ImageStreamListener( (info, _) { @@ -274,7 +317,6 @@ class _WiFiHeatmapWidgetState extends State { }, ), ); - _originalImageSize = await completer.future; if (mounted) { setState(() {}); @@ -293,21 +335,16 @@ class _WiFiHeatmapWidgetState extends State { void _updateDisplayedRect(BoxConstraints constraints) { if (_originalImageSize == null || !mounted) return; - final availW = constraints.maxWidth; final availH = constraints.maxHeight; - if (availW <= 0 || availH <= 0) return; - final newRect = Rect.fromLTWH(0, 0, availW, availH); - if (_displayedImageRect == null || (_displayedImageRect!.left - newRect.left).abs() > 1 || (_displayedImageRect!.top - newRect.top).abs() > 1 || (_displayedImageRect!.width - newRect.width).abs() > 1 || (_displayedImageRect!.height - newRect.height).abs() > 1) { _displayedImageRect = newRect; - if (_mode == 'scanning' && _grid.isEmpty) { WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) { @@ -378,7 +415,6 @@ class _WiFiHeatmapWidgetState extends State { if (_imageLoadFailed || _displayedImageRect == null) { return _buildErrorState(); } - return Stack( fit: StackFit.expand, children: [ @@ -418,7 +454,6 @@ class _WiFiHeatmapWidgetState extends State { if (_imageLoadFailed || _displayedImageRect == null) { return _buildErrorState(); } - return ClipRect( child: Stack( key: _stackKey, @@ -444,24 +479,28 @@ class _WiFiHeatmapWidgetState extends State { heatmapStyles: widget.heatmapStyles, ), ), - if (_scannedGridPositions.length >= 2) + if (_checkpoints.length >= 2) Positioned.fromRect( rect: _displayedImageRect!, child: CustomPaint( painter: PathConnectionPainter( - gridPoints: _scannedGridPositions, + gridPoints: + _checkpoints.map((c) => c.gridPosition).toList(), colWidths: _colWidths, rowHeights: _rowHeights, pathStyles: widget.pathStyles, ), ), ), - if (_scannedGridPositions.isNotEmpty && _markerGridPos != null) + if (_checkpoints.isNotEmpty && _markerGridPos != null) Positioned.fromRect( rect: _displayedImageRect!, child: CustomPaint( painter: PathConnectionPainter( - gridPoints: [_scannedGridPositions.last, _markerGridPos!], + gridPoints: [ + _checkpoints.last.gridPosition, + _markerGridPos! + ], colWidths: _colWidths, rowHeights: _rowHeights, pathStyles: widget.pathStyles, @@ -469,9 +508,9 @@ class _WiFiHeatmapWidgetState extends State { ), ), ), - ..._scannedGridPositions.map( - (pos) => ScanPointDot( - gridPos: pos, + ..._checkpoints.map( + (checkpoint) => ScanPointDot( + gridPos: checkpoint.gridPosition, colWidths: _colWidths, rowHeights: _rowHeights, imageRect: _displayedImageRect!, @@ -520,30 +559,23 @@ class _WiFiHeatmapWidgetState extends State { if (_floorPlan == null || widget.floorPlan.isEmpty || _imageLoadFailed) { return _buildErrorState(); } - return LayoutBuilder( builder: (context, constraints) { if (_originalImageSize == null) { return const Center(child: CircularProgressIndicator()); } - final double availableWidth = constraints.maxWidth; final double imageAspectRatio = _originalImageSize!.width / _originalImageSize!.height; - final double imageDisplayWidth = availableWidth; final double imageDisplayHeight = imageDisplayWidth / imageAspectRatio; - const double buttonAreaHeight = 76.0; - final double totalHeight = imageDisplayHeight + (_mode == 'scanning' ? buttonAreaHeight : 0); - _updateDisplayedRect(BoxConstraints( maxWidth: imageDisplayWidth, maxHeight: imageDisplayHeight, )); - return SizedBox( height: totalHeight, child: _displayedImageRect == null @@ -581,7 +613,8 @@ class _WiFiHeatmapWidgetState extends State { widget.buttonStyles.addCheckpointColor, foregroundColor: Colors.white, ), - onPressed: _addCheckpoint, + onPressed: + _allGridsFilled ? null : _addCheckpoint, ) : const SizedBox(height: 52), ), @@ -632,32 +665,24 @@ class _WiFiHeatmapWidgetState extends State { void _createGrid() { if (_displayedImageRect == null) return; - final w = _displayedImageRect!.width; final h = _displayedImageRect!.height; - final n = widget.gridSize.clamp(4, 32); - _colWidths = List.generate(n, (_) => w / n); _rowHeights = List.generate(n, (_) => h / n); - _colWidths[n - 1] += w - _colWidths.fold(0.0, (a, b) => a + b); _rowHeights[n - 1] += h - _rowHeights.fold(0.0, (a, b) => a + b); - _grid = List.generate( n, (r) => List.generate(n, (c) => GridCell(row: r, col: c)), ); - _markerGridPos = Offset((n ~/ 2).toDouble(), (n ~/ 2).toDouble()); } void _updateMarkerFromGlobalPos(Offset global) { if (_displayedImageRect == null || _grid.isEmpty) return; - final box = _stackKey.currentContext?.findRenderObject() as RenderBox?; if (box == null) return; - final local = box.globalToLocal(global); final relX = (local.dx - _displayedImageRect!.left).clamp( 0.0, @@ -667,7 +692,6 @@ class _WiFiHeatmapWidgetState extends State { 0.0, _displayedImageRect!.height, ); - int col = 0; double sumX = 0; for (int i = 0; i < _colWidths.length; i++) { @@ -677,7 +701,6 @@ class _WiFiHeatmapWidgetState extends State { break; } } - int row = 0; double sumY = 0; for (int i = 0; i < _rowHeights.length; i++) { @@ -687,28 +710,33 @@ class _WiFiHeatmapWidgetState extends State { break; } } - setState(() => _markerGridPos = Offset(row.toDouble(), col.toDouble())); } Future _addCheckpoint() async { if (_markerGridPos == null) return; - final r = _markerGridPos!.dx.toInt(); final c = _markerGridPos!.dy.toInt(); - if (r < 0 || r >= _grid.length || c < 0 || c >= _grid[r].length) return; - if (_scannedGridPositions.isNotEmpty && - _scannedGridPositions.last == Offset(r.toDouble(), c.toDouble())) { + // Don't add checkpoint if it's the same as the last one + if (_checkpoints.isNotEmpty && + _checkpoints.last.gridPosition == Offset(r.toDouble(), c.toDouble())) { return; } - SignalResult? result; + // Get current timestamp and dBm + final currentTimestamp = DateTime.now().millisecondsSinceEpoch; + int currentDbm; - if (widget.getSignalStrength != null) { + // Try to get dBm from latest signal value first + if (_signalValues.isNotEmpty) { + final lastSignal = _signalValues.last; + currentDbm = lastSignal['dbm'] as int; + } else if (widget.getSignalStrength != null) { try { - result = await widget.getSignalStrength!(); + final result = await widget.getSignalStrength!(); + currentDbm = result.dBm; } catch (e) { if (mounted) { widget.onShowMessage?.call('Error getting signal: $e'); @@ -716,116 +744,166 @@ class _WiFiHeatmapWidgetState extends State { return; } } else { - result = _getRandomSignal(); + final result = _getRandomSignal(); + currentDbm = result.dBm; } if (!mounted) return; final newPos = Offset(r.toDouble(), c.toDouble()); + final newCheckpoint = Checkpoint( + gridPosition: newPos, + timestamp: currentTimestamp, + dBm: currentDbm, + ); setState(() { + // Update the checkpoint cell itself final cell = _grid[r][c]; - cell.rssi = result!.dBm; - cell.color = result.color; + cell.rssi = currentDbm; + cell.color = widget.signalStyles.getSignalColor(currentDbm); cell.scanned = true; - _scannedGridPositions.add(newPos); - - if (_scannedGridPositions.length == 1) { - _currentSegmentDbms = [result.dBm]; - _startSignalTimer(); + if (_checkpoints.isEmpty) { + // First checkpoint - trigger callback to start timer + _checkpoints.add(newCheckpoint); + widget.onFirstCheckpoint?.call(); } else { - _currentSegmentDbms.add(result.dBm); - - final prevPos = _scannedGridPositions[_scannedGridPositions.length - 2]; - _processSegment(prevPos, newPos, _currentSegmentDbms); + // Subsequent checkpoint - process the segment between previous and current + final prevCheckpoint = _checkpoints.last; + _processSegmentWithTimestamps(prevCheckpoint, newCheckpoint); + _checkpoints.add(newCheckpoint); - _currentSegmentDbms = [result.dBm]; + // Check if all grids are filled + _checkAllGridsFilled(); } }); } - void _startSignalTimer() { - _signalTimer?.cancel(); - _signalTimer = Timer.periodic(const Duration(seconds: 1), (timer) async { - if (!mounted) { - timer.cancel(); - return; - } + void _processSegmentWithTimestamps(Checkpoint start, Checkpoint end) { + final startR = start.gridPosition.dx.toInt(); + final startC = start.gridPosition.dy.toInt(); + final endR = end.gridPosition.dx.toInt(); + final endC = end.gridPosition.dy.toInt(); - SignalResult result; - if (widget.getSignalStrength != null) { - try { - result = await widget.getSignalStrength!(); - } catch (e) { - return; - } - } else { - result = _getRandomSignal(); - } + // Get all cells between start and end + final lineCells = _getLineCells(startR, startC, endR, endC); + if (lineCells.length < 2) return; - _currentSegmentDbms.add(result.dBm); - }); - } + // Filter signal values between the two checkpoint timestamps + final signalsInRange = _signalValues.where((signal) { + final timestamp = signal['timestamp'] as int; + return timestamp >= start.timestamp && timestamp <= end.timestamp; + }).toList(); - void _processSegment(Offset start, Offset end, List dbms) { - final startR = start.dx.toInt(); - final startC = start.dy.toInt(); - final endR = end.dx.toInt(); - final endC = end.dy.toInt(); + // If no signals in range, use checkpoint values + if (signalsInRange.isEmpty) { + _fillCellsWithInterpolation(lineCells, start.dBm, end.dBm); + return; + } - final lineCells = _getLineCells(startR, startC, endR, endC); - if (lineCells.length < 2) return; + // Extract dBm values + final dbmValues = signalsInRange.map((s) => s['dbm'] as int).toList(); + + // Ensure start and end values are included + if (dbmValues.first != start.dBm) { + dbmValues.insert(0, start.dBm); + } + if (dbmValues.last != end.dBm) { + dbmValues.add(end.dBm); + } final numCells = lineCells.length; - List cellDbms = List.filled(numCells, dbms.first); - - cellDbms[0] = dbms[0]; - cellDbms[numCells - 1] = dbms.last; - - final middleSamplesCount = dbms.length - 2; - final middleCellsCount = numCells - 2; - - if (middleCellsCount > 0) { - if (middleSamplesCount > 0) { - for (int j = 1; j < numCells - 1; j++) { - final startS = ((j - 1) * middleSamplesCount) ~/ middleCellsCount; - var endS = (j * middleSamplesCount) ~/ middleCellsCount; - if (endS <= startS) endS = startS + 1; - endS = min(endS, middleSamplesCount); - - int sum = 0; - int count = 0; - for (int s = startS; s < endS; s++) { - sum += dbms[1 + s]; - count++; - } - if (count > 0) { - cellDbms[j] = sum ~/ count; - } + final numValues = dbmValues.length; + + // Map values to cells + for (int i = 0; i < numCells; i++) { + final gridPos = lineCells[i]; + final rr = gridPos.dx.toInt(); + final cc = gridPos.dy.toInt(); + + if (rr >= 0 && rr < _grid.length && cc >= 0 && cc < _grid[rr].length) { + final cell = _grid[rr][cc]; + + // CRITICAL: Never overwrite cells that already have values + if (cell.scanned) { + continue; } - } else { - final avg = (dbms[0] + dbms.last) ~/ 2; - for (int j = 1; j < numCells - 1; j++) { - cellDbms[j] = avg; + + int cellDbm; + + if (numValues == numCells) { + // Perfect match - one value per cell + cellDbm = dbmValues[i]; + } else if (numValues > numCells) { + // More values than cells - use average + final startIdx = (i * numValues) ~/ numCells; + final endIdx = ((i + 1) * numValues) ~/ numCells; + final valuesForCell = dbmValues.sublist( + startIdx, + endIdx.clamp(startIdx + 1, numValues), + ); + cellDbm = + valuesForCell.reduce((a, b) => a + b) ~/ valuesForCell.length; + } else { + // More cells than values - distribute values + final valueIdx = (i * numValues) ~/ numCells; + final safeIdx = valueIdx.clamp(0, numValues - 1); + cellDbm = dbmValues[safeIdx]; } + + cell.rssi = cellDbm; + cell.color = widget.signalStyles.getSignalColor(cellDbm); + cell.scanned = true; } } + } + + void _fillCellsWithInterpolation( + List cells, int startDbm, int endDbm) { + for (int i = 0; i < cells.length; i++) { + final gridPos = cells[i]; + final rr = gridPos.dx.toInt(); + final cc = gridPos.dy.toInt(); - for (int i = 0; i < numCells; i++) { - final gpos = lineCells[i]; - final rr = gpos.dx.toInt(); - final cc = gpos.dy.toInt(); if (rr >= 0 && rr < _grid.length && cc >= 0 && cc < _grid[rr].length) { final cell = _grid[rr][cc]; - final d = cellDbms[i]; - cell.rssi = d; - cell.color = widget.signalStyles.getSignalColor(d); + + // CRITICAL: Never overwrite cells that already have values + if (cell.scanned) { + continue; + } + + // Linear interpolation + final ratio = cells.length > 1 ? i / (cells.length - 1) : 0.0; + final cellDbm = (startDbm + (endDbm - startDbm) * ratio).round(); + + cell.rssi = cellDbm; + cell.color = widget.signalStyles.getSignalColor(cellDbm); cell.scanned = true; } } } + void _checkAllGridsFilled() { + // Check if all grid cells are scanned + bool allFilled = true; + for (final row in _grid) { + for (final cell in row) { + if (!cell.scanned) { + allFilled = false; + break; + } + } + if (!allFilled) break; + } + + if (allFilled && !_allGridsFilled) { + _allGridsFilled = true; + widget.onAllGridsFilled?.call(); + } + } + List _getLineCells(int r0, int c0, int r1, int c1) { final cells = []; final dr = (r1 - r0).abs(); @@ -835,11 +913,9 @@ class _WiFiHeatmapWidgetState extends State { int err = dr - dc; int r = r0; int c = c0; - while (true) { cells.add(Offset(r.toDouble(), c.toDouble())); if (r == r1 && c == c1) break; - final e2 = 2 * err; if (e2 > -dc) { err -= dc; @@ -870,7 +946,6 @@ class _WiFiHeatmapWidgetState extends State { -92, -97, ]; - final random = Random(); final dBm = possibleValues[random.nextInt(possibleValues.length)]; return SignalResult(dBm, widget.signalStyles.getSignalColor(dBm)); @@ -878,18 +953,15 @@ class _WiFiHeatmapWidgetState extends State { void _addModem() { if (_displayedImageRect == null || _modem != null) return; - final center = Offset( (_displayedImageRect!.width - widget.deviceStyles.markerSize) / 2, (_displayedImageRect!.height - widget.deviceStyles.markerSize) / 2, ); - setState(() => _modem = Device(type: 'modem', position: center)); } void _addRouter() { if (_displayedImageRect == null) return; - final center = Offset( (_displayedImageRect!.width - widget.deviceStyles.markerSize) / 2, (_displayedImageRect!.height - widget.deviceStyles.markerSize) / 2, @@ -902,12 +974,13 @@ class _WiFiHeatmapWidgetState extends State { widget.onShowMessage?.call('Please place the modem first'); return; } - setState(() { _mode = 'scanning'; _grid.clear(); + _checkpoints.clear(); + _signalValues.clear(); + _allGridsFilled = false; }); - WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted && _displayedImageRect != null) { setState(() => _createGrid()); @@ -916,7 +989,7 @@ class _WiFiHeatmapWidgetState extends State { } } -/// Wrapper Screen with Scaffold (Example Usage) +/// Wrapper Screen with Scaffold (Example Usage) class WiFiHeatmapScreen extends StatefulWidget { final Future Function()? getSignalStrength; final String floorPlan; @@ -977,7 +1050,7 @@ class _WiFiHeatmapScreenState extends State { } } -/// Reusable Widgets +/// Reusable Widgets class DeviceMarker extends StatelessWidget { final Device device; final DeviceStyles deviceStyles; @@ -1098,15 +1171,12 @@ class ScanPointDot extends StatelessWidget { Widget build(BuildContext context) { final r = gridPos.dx.toInt(); final c = gridPos.dy.toInt(); - double x = colWidths.take(c).fold(0.0, (a, b) => a + b) + colWidths[c] / 2; double y = rowHeights.take(r).fold(0.0, (a, b) => a + b) + rowHeights[r] / 2; - final cellW = colWidths[c]; final cellH = rowHeights[r]; final dotSize = min(cellW, cellH) * scanPointStyles.dotSizeFactor; - return Positioned( left: imageRect.left + x - dotSize / 2, top: imageRect.top + y - dotSize / 2, @@ -1155,14 +1225,11 @@ class MarkerPin extends StatelessWidget { Widget build(BuildContext context) { final r = gridPos.dx.toInt(); final c = gridPos.dy.toInt(); - final center = Offset( colWidths.take(c).fold(0.0, (a, b) => a + b) + colWidths[c] / 2, rowHeights.take(r).fold(0.0, (a, b) => a + b) + rowHeights[r] / 2, ); - final icon = getIcon(); - return Positioned( left: imageRect.left + center.dx - locationPinStyles.size / 2, top: imageRect.top + center.dy - locationPinStyles.size * 0.85, @@ -1180,7 +1247,7 @@ class MarkerPin extends StatelessWidget { } } -/// Painters +/// Painters class VariableGridPainterWidget extends StatelessWidget { final List> grid; final List colWidths; @@ -1245,20 +1312,17 @@ class _VariableGridPainter extends CustomPainter { } y += rowHeights[r]; } - // Grid lines final linePaint = Paint() ..style = PaintingStyle.stroke ..color = gridStyles.lineColor.withAlpha(gridStyles.alpha) ..strokeWidth = gridStyles.lineWidth; - y = 0; for (final h in rowHeights) { canvas.drawLine(Offset(0, y), Offset(size.width, y), linePaint); y += h; } canvas.drawLine(Offset(0, y), Offset(size.width, y), linePaint); - double x = 0; for (final w in colWidths) { canvas.drawLine(Offset(x, 0), Offset(x, size.height), linePaint); @@ -1299,12 +1363,10 @@ class PathConnectionPainter extends CustomPainter { @override void paint(Canvas canvas, Size size) { if (gridPoints.length < 2) return; - final paint = Paint() ..color = pathStyles.color ..strokeWidth = pathStyles.width ..style = PaintingStyle.stroke; - if (isDotted) { paint.strokeCap = StrokeCap.round; const dashPattern = [8.0, 5.0]; @@ -1333,25 +1395,20 @@ class PathConnectionPainter extends CustomPainter { final dy = b.dy - a.dy; final dist = sqrt(dx * dx + dy * dy); if (dist < 1e-6) return; - double traveled = 0.0; bool shouldDraw = true; int patternIndex = 0; - while (traveled < dist) { final segmentLength = pattern[patternIndex % pattern.length]; final progress = traveled / dist; final nextProgress = (traveled + segmentLength) / dist; - final x1 = a.dx + dx * progress; final y1 = a.dy + dy * progress; final x2 = a.dx + dx * nextProgress.clamp(0.0, 1.0); final y2 = a.dy + dy * nextProgress.clamp(0.0, 1.0); - if (shouldDraw) { canvas.drawLine(Offset(x1, y1), Offset(x2, y2), paint); } - traveled += segmentLength; shouldDraw = !shouldDraw; patternIndex++; diff --git a/modules/ensemble/lib/widget/wifi_heatmap.dart b/modules/ensemble/lib/widget/wifi_heatmap.dart index a2760d36b..4eb3e99ed 100644 --- a/modules/ensemble/lib/widget/wifi_heatmap.dart +++ b/modules/ensemble/lib/widget/wifi_heatmap.dart @@ -1,7 +1,4 @@ -// File: lib/widget/wifi_heatmap/wifi_heatmap.dart - import 'dart:async'; - import 'package:ensemble/framework/action.dart' as ensemble; import 'package:ensemble/framework/event.dart'; import 'package:ensemble/framework/widget/widget.dart'; @@ -18,7 +15,6 @@ import 'visualization/wifi_heatmap_widget.dart'; class WiFiHeatmap extends StatefulWidget with Invokable, HasController { static const type = 'WiFiHeatmap'; - static Widget build({Key? key}) { return WiFiHeatmap(key: key); } @@ -36,6 +32,7 @@ class WiFiHeatmap extends StatefulWidget 'floorPlan': () => _controller.floorPlan, 'gridSize': () => _controller.gridSize, 'mode': () => _controller.mode, + 'signalValues': () => _controller.signalValues, }; } @@ -48,12 +45,10 @@ class WiFiHeatmap extends StatefulWidget _controller.gridSize = Utils.optionalInt(v, min: 4, max: 40) ?? 12, 'mode': (v) => _controller.mode = Utils.getString(v, fallback: 'setup'), - // Icons as direct properties (not in styles) 'modemIcon': (v) => _controller.modemIcon = _parseIcon(v), 'routerIcon': (v) => _controller.routerIcon = _parseIcon(v), 'locationPinIcon': (v) => _controller.locationPinIcon = _parseIcon(v), - // Separate style maps 'deviceStyles': (v) => _controller.deviceStyles = _parseDeviceStyles(v), 'scanPointStyles': (v) => _controller.scanPointStyles = _parseScanPointStyles(v), @@ -66,6 +61,13 @@ class WiFiHeatmap extends StatefulWidget 'signalStyles': (v) => _controller.signalStyles = _parseSignalStyles(v), 'buttonStyles': (v) => _controller.buttonStyles = _parseButtonStyles(v), + 'signalValues': (v) { + print( + 'WiFiHeatmap setter signalValues called with: $v ${v.runtimeType}'); + _controller.signalValues = _parseSignalValues(v); + return _controller.signalValues; + }, + // Actions 'onMessage': (def) => _controller.onMessage = ensemble.EnsembleAction.from(def, initiator: this), @@ -73,6 +75,10 @@ class WiFiHeatmap extends StatefulWidget ensemble.EnsembleAction.from(def, initiator: this), 'getSignalStrength': (def) => _controller.getSignalStrength = ensemble.EnsembleAction.from(def, initiator: this), + 'onFirstCheckpoint': (def) => _controller.onFirstCheckpoint = + ensemble.EnsembleAction.from(def, initiator: this), + 'onAllGridsFilled': (def) => _controller.onAllGridsFilled = + ensemble.EnsembleAction.from(def, initiator: this), }; } @@ -91,18 +97,56 @@ class WiFiHeatmap extends StatefulWidget return ensembleIcon.Icon.fromModel(iconModel); } + List> _parseSignalValues(dynamic value) { + if (value == null) return []; + + // If it's already a list + if (value is List) { + return value.map((item) { + if (item is Map) { + // Ensure we have both timestamp and dbm + final timestamp = item['timestamp']; + final dbm = item['dbm'] ?? item['value']; + + if (timestamp != null && dbm != null) { + return { + 'timestamp': timestamp is int + ? timestamp + : int.tryParse(timestamp.toString()) ?? + DateTime.now().millisecondsSinceEpoch, + 'dbm': dbm is int ? dbm : int.tryParse(dbm.toString()) ?? -100, + }; + } + } + // If item is just a number, treat it as dbm with current timestamp + if (item is int) { + return { + 'timestamp': DateTime.now().millisecondsSinceEpoch, + 'dbm': item, + }; + } + return { + 'timestamp': DateTime.now().millisecondsSinceEpoch, + 'dbm': -100, + }; + }).toList(); + } + + return []; + } + DeviceStyles _parseDeviceStyles(dynamic value) { if (value is! Map) return const DeviceStyles(); final map = value; return DeviceStyles( - markerSize: Utils.optionalDouble(map['markerSize']) ?? 36.0, - iconSize: Utils.optionalDouble(map['iconSize']) ?? 22.0, - borderWidth: Utils.optionalDouble(map['borderWidth']) ?? 2.8, - borderColor: Utils.getColor(map['borderColor']) ?? Colors.white, - modemColor: Utils.getColor(map['modemColor']) ?? Colors.red, - modemIconColor: Utils.getColor(map['modemIconColor']) ?? Colors.white, - routerColor: Utils.getColor(map['routerColor']) ?? Colors.blue, - routerIconColor: Utils.getColor(map['routerIconColor']) ?? Colors.white, + markerSize: Utils.optionalDouble(map['markerSize']), + iconSize: Utils.optionalDouble(map['iconSize']), + borderWidth: Utils.optionalDouble(map['borderWidth']), + borderColor: Utils.getColor(map['borderColor']), + modemColor: Utils.getColor(map['modemColor']), + modemIconColor: Utils.getColor(map['modemIconColor']), + routerColor: Utils.getColor(map['routerColor']), + routerIconColor: Utils.getColor(map['routerIconColor']), ); } @@ -110,11 +154,10 @@ class WiFiHeatmap extends StatefulWidget if (value is! Map) return const ScanPointStyles(); final map = value; return ScanPointStyles( - dotSizeFactor: Utils.optionalDouble(map['dotSizeFactor']) ?? 0.4, - color: Utils.getColor(map['color']) ?? Colors.blueAccent, - borderColor: - Utils.getColor(map['borderColor']) ?? const Color(0xB3FFFFFF), - borderWidth: Utils.optionalDouble(map['borderWidth']) ?? 1.8, + dotSizeFactor: Utils.optionalDouble(map['dotSizeFactor']), + color: Utils.getColor(map['color']), + borderColor: Utils.getColor(map['borderColor']), + borderWidth: Utils.optionalDouble(map['borderWidth']), ); } @@ -122,8 +165,8 @@ class WiFiHeatmap extends StatefulWidget if (value is! Map) return const LocationPinStyles(); final map = value; return LocationPinStyles( - size: Utils.optionalDouble(map['size']) ?? 44.0, - color: Utils.getColor(map['color']) ?? Colors.red, + size: Utils.optionalDouble(map['size']), + color: Utils.getColor(map['color']), ); } @@ -131,9 +174,9 @@ class WiFiHeatmap extends StatefulWidget if (value is! Map) return const GridStyles(); final map = value; return GridStyles( - lineWidth: Utils.optionalDouble(map['lineWidth']) ?? 0.6, - alpha: Utils.optionalInt(map['alpha'], min: 0, max: 255) ?? 60, - lineColor: Utils.getColor(map['lineColor']) ?? Colors.black, + lineWidth: Utils.optionalDouble(map['lineWidth']), + alpha: Utils.optionalInt(map['alpha'], min: 0, max: 255), + lineColor: Utils.getColor(map['lineColor']), ); } @@ -141,7 +184,7 @@ class WiFiHeatmap extends StatefulWidget if (value is! Map) return const HeatmapStyles(); final map = value; return HeatmapStyles( - fillAlpha: Utils.optionalInt(map['fillAlpha'], min: 0, max: 255) ?? 123, + fillAlpha: Utils.optionalInt(map['fillAlpha'], min: 0, max: 255), ); } @@ -149,8 +192,8 @@ class WiFiHeatmap extends StatefulWidget if (value is! Map) return const PathStyles(); final map = value; return PathStyles( - color: Utils.getColor(map['color']) ?? const Color(0xFF1976D2), - width: Utils.optionalDouble(map['width']) ?? 2.8, + color: Utils.getColor(map['color']), + width: Utils.optionalDouble(map['width']), ); } @@ -158,14 +201,12 @@ class WiFiHeatmap extends StatefulWidget if (value is! Map) return const SignalStyles(); final map = value; return SignalStyles( - excellentColor: - Utils.getColor(map['excellentColor']) ?? const Color(0xFF388E3C), - veryGoodColor: - Utils.getColor(map['veryGoodColor']) ?? const Color(0xFF66BB6A), - goodColor: Utils.getColor(map['goodColor']) ?? const Color(0xFFAFB42B), - fairColor: Utils.getColor(map['fairColor']) ?? const Color(0xFFF57C00), - poorColor: Utils.getColor(map['poorColor']) ?? const Color(0xFFE64A19), - badColor: Utils.getColor(map['badColor']) ?? const Color(0xFFC62828), + excellentColor: Utils.getColor(map['excellentColor']), + veryGoodColor: Utils.getColor(map['veryGoodColor']), + goodColor: Utils.getColor(map['goodColor']), + fairColor: Utils.getColor(map['fairColor']), + poorColor: Utils.getColor(map['poorColor']), + badColor: Utils.getColor(map['badColor']), ); } @@ -173,10 +214,8 @@ class WiFiHeatmap extends StatefulWidget if (value is! Map) return const ButtonStyles(); final map = value; return ButtonStyles( - startScanColor: - Utils.getColor(map['startScanColor']) ?? const Color(0xFF388E3C), - addCheckpointColor: - Utils.getColor(map['addCheckpointColor']) ?? const Color(0xFF1976D2), + startScanColor: Utils.getColor(map['startScanColor']), + addCheckpointColor: Utils.getColor(map['addCheckpointColor']), ); } @@ -188,12 +227,10 @@ class WiFiHeatmapController extends BoxController { String floorPlan = ''; int gridSize = 12; String mode = 'setup'; - - // Icons as direct properties (not in styles) + // Icons as direct Icon? modemIcon; Icon? routerIcon; Icon? locationPinIcon; - // Separate style maps DeviceStyles deviceStyles = const DeviceStyles(); ScanPointStyles scanPointStyles = const ScanPointStyles(); @@ -203,18 +240,20 @@ class WiFiHeatmapController extends BoxController { PathStyles pathStyles = const PathStyles(); SignalStyles signalStyles = const SignalStyles(); ButtonStyles buttonStyles = const ButtonStyles(); - + // External signal values (timestamp+dBm objects passed from outside) + List> signalValues = []; // Actions ensemble.EnsembleAction? onMessage; ensemble.EnsembleAction? onScanComplete; ensemble.EnsembleAction? getSignalStrength; + ensemble.EnsembleAction? onFirstCheckpoint; + ensemble.EnsembleAction? onAllGridsFilled; VoidCallback? _startScanning; VoidCallback? _reset; void showMessage(String msg, BuildContext context) { print('WiFiHeatmap showMessage: $msg'); - if (onMessage != null) { ScreenController().executeAction( context, @@ -227,6 +266,28 @@ class WiFiHeatmapController extends BoxController { ); } } + + void triggerFirstCheckpoint(BuildContext context) { + print('WiFiHeatmap triggerFirstCheckpoint'); + if (onFirstCheckpoint != null) { + ScreenController().executeAction( + context, + onFirstCheckpoint!, + event: EnsembleEvent(null), + ); + } + } + + void triggerAllGridsFilled(BuildContext context) { + print('WiFiHeatmap triggerAllGridsFilled'); + if (onAllGridsFilled != null) { + ScreenController().executeAction( + context, + onAllGridsFilled!, + event: EnsembleEvent(null), + ); + } + } } class WiFiHeatmapState extends EWidgetState { @@ -235,7 +296,6 @@ class WiFiHeatmapState extends EWidgetState { @override void initState() { super.initState(); - widget.controller._startScanning = () { if (widget.controller.mode != 'scanning' && mounted) { setState(() { @@ -243,7 +303,6 @@ class WiFiHeatmapState extends EWidgetState { }); } }; - widget.controller._reset = () { if (mounted) { setState(() { @@ -272,14 +331,19 @@ class WiFiHeatmapState extends EWidgetState { modemIcon: widget.controller.modemIcon, routerIcon: widget.controller.routerIcon, locationPinIcon: widget.controller.locationPinIcon, + signalValues: widget.controller.signalValues, getSignalStrength: _getSignalStrength, onShowMessage: (msg) => widget.controller.showMessage(msg, context), + onFirstCheckpoint: () => + widget.controller.triggerFirstCheckpoint(context), + onAllGridsFilled: () => + widget.controller.triggerAllGridsFilled(context), ), ); } Future _getSignalStrength() async { - // Fallback random signal generation + // Fallback random signal generation (for backward compatibility) await Future.delayed(const Duration(milliseconds: 600)); const values = [-45, -52, -58, -64, -72, -79, -88, -94]; final rssi = values[DateTime.now().millisecond % values.length];