diff --git a/example/lib/example_building_explorer.dart b/example/lib/example_building_explorer.dart new file mode 100644 index 0000000..c058272 --- /dev/null +++ b/example/lib/example_building_explorer.dart @@ -0,0 +1,112 @@ +// +// Copyright 2025 Esri +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import 'package:arcgis_maps/arcgis_maps.dart'; +import 'package:arcgis_maps_toolkit/arcgis_maps_toolkit.dart'; +import 'package:flutter/material.dart'; + +void main() { + runApp(const MaterialApp(home: ExampleBuildingExplorer())); +} + +class ExampleBuildingExplorer extends StatefulWidget { + const ExampleBuildingExplorer({super.key}); + + @override + State createState() => + _ExampleBuildingExplorerState(); +} + +class _ExampleBuildingExplorerState extends State { + // Create a controller for the local scene view. + final _localSceneViewController = ArcGISLocalSceneView.createController(); + + // Building scene layer that will be filtered. Set after the WebScene is loaded. + BuildingSceneLayer? _buildingSceneLayer; + + bool _showBottomSheet = false; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Building Explorer')), + body: SafeArea( + top: false, + left: false, + right: false, + child: Stack( + children: [ + Column( + children: [ + Expanded( + // Add a local scene view to the widget tree and set a controller. + child: ArcGISLocalSceneView( + controllerProvider: () => _localSceneViewController, + onLocalSceneViewReady: onLocalSceneViewReady, + ), + ), + Center( + // Button to show the building filter settings sheet. + child: ElevatedButton( + onPressed: _buildingSceneLayer != null + ? () => setState(() => _showBottomSheet = true) + : null, + child: const Text('Building Filter Settings'), + ), + ), + ], + ), + ], + ), + ), + bottomSheet: _showBottomSheet ? showBuildingExplorerModal(context) : null, + ); + } + + Widget showBuildingExplorerModal(BuildContext context) { + return Container( + height: 400, // Define the height of the bottom sheet + color: Colors.white, + child: Center( + child: BuildingExplorer( + localScene: _localSceneViewController.arcGISScene!, + onClose: () => setState(() => _showBottomSheet = false), + ), + ), + ); + } + + Future onLocalSceneViewReady() async { + // Create the local scene from a ArcGISOnline web scene. + final sceneUri = Uri.parse( + 'https://arcgisruntime.maps.arcgis.com/home/item.html?id=b7c387d599a84a50aafaece5ca139d44', + ); + final scene = ArcGISScene.withUri(sceneUri)!; + + // Load the scene so the underlying layers can be accessed. + await scene.load(); + + // Get the BuildingSceneLayer from the webmap. + final buildingSceneLayers = scene.operationalLayers + .whereType(); + if (buildingSceneLayers.isNotEmpty) { + setState(() => _buildingSceneLayer = buildingSceneLayers.first); + } + + // Apply the scene to the local scene view controller. + _localSceneViewController.arcGISScene = scene; + } +} diff --git a/example/lib/main.dart b/example/lib/main.dart index 079311e..dead7f6 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -16,6 +16,7 @@ import 'package:arcgis_maps/arcgis_maps.dart'; import 'package:arcgis_maps_toolkit_example/example_authenticator.dart'; +import 'package:arcgis_maps_toolkit_example/example_building_explorer.dart'; import 'package:arcgis_maps_toolkit_example/example_compass.dart'; import 'package:arcgis_maps_toolkit_example/example_overview_map.dart'; import 'package:arcgis_maps_toolkit_example/example_popup.dart'; @@ -64,6 +65,11 @@ enum ComponentExample { 'PopupView', 'Displays a popup for a feature, including fields, media, and attachments', PopupExample.new, + ), + buildingExplorer( + 'BuildingExplorer', + 'Filters a BuildingSceneLayer by floor level and category sublayers', + ExampleBuildingExplorer.new, ); const ComponentExample(this.title, this.subtitle, this.constructor); diff --git a/lib/arcgis_maps_toolkit.dart b/lib/arcgis_maps_toolkit.dart index 38260dc..7f26f7d 100644 --- a/lib/arcgis_maps_toolkit.dart +++ b/lib/arcgis_maps_toolkit.dart @@ -46,6 +46,11 @@ part 'src/authenticator/authenticator_certificate_required.dart'; part 'src/authenticator/authenticator_certificate_password.dart'; part 'src/authenticator/authenticator_login.dart'; part 'src/authenticator/authenticator_trust.dart'; +// Building Explorer Widget +part 'src/building_explorer/building_category_list.dart'; +part 'src/building_explorer/building_category_selector.dart'; +part 'src/building_explorer/building_explorer.dart'; +part 'src/building_explorer/building_level_selector.dart'; // Compass Widget part 'src/compass/compass.dart'; part 'src/compass/compass_needle_painter.dart'; diff --git a/lib/src/building_explorer/building_category_list.dart b/lib/src/building_explorer/building_category_list.dart new file mode 100644 index 0000000..acf2dd7 --- /dev/null +++ b/lib/src/building_explorer/building_category_list.dart @@ -0,0 +1,51 @@ +// +// Copyright 2025 Esri +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +part of '../../arcgis_maps_toolkit.dart'; + +class _BuildingCategoryList extends StatelessWidget { + const _BuildingCategoryList({ + required this.buildingSceneLayer, + required this.fullModelSublayerName, + }); + + final BuildingSceneLayer buildingSceneLayer; + final String fullModelSublayerName; + + @override + Widget build(BuildContext context) { + final fullModelGroupSublayer = buildingSceneLayer.sublayers + .whereType() + .where((sublayer) => sublayer.name == 'Full Model') + .firstOrNull; + + final categoryGroupSublayers = + fullModelGroupSublayer?.sublayers + .whereType() + .toList() ?? + []; + + return ListView.builder( + itemCount: categoryGroupSublayers.length, + itemBuilder: (context, index) { + final categoryGroupSublayer = categoryGroupSublayers[index]; + return _BuildingCategorySelector( + buildingCategory: categoryGroupSublayer, + ); + }, + ); + } +} diff --git a/lib/src/building_explorer/building_category_selector.dart b/lib/src/building_explorer/building_category_selector.dart new file mode 100644 index 0000000..dc79dc3 --- /dev/null +++ b/lib/src/building_explorer/building_category_selector.dart @@ -0,0 +1,61 @@ +// +// Copyright 2025 Esri +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +part of '../../arcgis_maps_toolkit.dart'; + +class _BuildingCategorySelector extends StatefulWidget { + const _BuildingCategorySelector({required this.buildingCategory}); + + final BuildingGroupSublayer buildingCategory; + + @override + State createState() => _BuildingCategorySelectorState(); +} + +class _BuildingCategorySelectorState extends State<_BuildingCategorySelector> { + @override + Widget build(BuildContext context) { + final componentSublayers = widget.buildingCategory.sublayers; + return ExpansionTile( + title: Row( + children: [ + Text(widget.buildingCategory.name), + const Spacer(), + Checkbox( + value: widget.buildingCategory.isVisible, + onChanged: (val) { + setState(() { + widget.buildingCategory.isVisible = val ?? false; + }); + }, + ), + ], + ), + children: componentSublayers.map((componentSublayer) { + return CheckboxListTile( + title: Text(componentSublayer.name), + value: componentSublayer.isVisible, + enabled: widget.buildingCategory.isVisible, + onChanged: (val) { + setState(() { + componentSublayer.isVisible = val ?? false; + }); + }, + ); + }).toList(), + ); + } +} diff --git a/lib/src/building_explorer/building_explorer.dart b/lib/src/building_explorer/building_explorer.dart new file mode 100644 index 0000000..c1fb16b --- /dev/null +++ b/lib/src/building_explorer/building_explorer.dart @@ -0,0 +1,186 @@ +// +// Copyright 2025 Esri +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +part of '../../arcgis_maps_toolkit.dart'; + +/// A widget that enables a user to explore a building model in a [BuildingSceneLayer]. +/// +/// # Overview +/// The Building Explorer widget provides a tool for users to browse the levels and sublayers of a building scene layer. The widget can highlight specified levels and show or hide building features of different categories and subcategories. +/// +/// ## Features +/// Features of the Building Explorer widget include: +/// * Showing the name of the layer as the title of the widget. +/// * Selecting a level of the building to highlight in the view. +/// * The selected level and all of the features of the level are rendered normally. +/// * Levels above are hidden. +/// * Levels below are given an Xray style. +/// * Visibility of building feature categories and subcategories can be toggled on and off. +/// * The widget can present a close button when provided with an onClose callback function. +/// +/// ## Usage +/// A [BuildingExplorer] widget is created with the following parameters: +/// * buildingSceneLayer: The [BuildingSceneLayer] that this widget will be exploring +/// * fullModelSublayerName: An optional [String] that is the name of the full model sublayer. Default is “Full Model”. +/// * onClose: An optional callback that is called when the close button of the widget is tapped. If a callback is not provided, the close button will be hidden. +/// +/// The widget can be inserted into a widget tree by calling the constructor and supplying a [BuildlingSceneLayer] and an optional onClose callback function. +/// ```dart +/// ... +/// BuildingExplorer( +/// buildingSceneLayer: _buildingSceneLayer!, +/// onClose: () => Navigator.pop(context), +/// ), +/// ... +/// ``` +class BuildingExplorer extends StatefulWidget { + const BuildingExplorer({ + required this.localScene, + // required this.buildingSceneLayer, + this.fullModelSublayerName = 'Full Model', + this.onClose, + super.key, + }); + + /// Local Scene that contains BuildingSceneLayers. + final ArcGISScene localScene; + + /// BuildingSceneLayer that this widget explores + // final BuildingSceneLayer buildingSceneLayer; + + /// Name of the full model group sublayer + final String fullModelSublayerName; + + /// Optional onClose callback. If set, a close [IconButton] will appear at the top right of the widget. + final VoidCallback? onClose; + + @override + State createState() => _BuildingExplorerState(); +} + +class _BuildingExplorerState extends State { + var _buildingSceneLayers = []; + BuildingSceneLayer? _selectedBuildingSceneLayer; + + StreamSubscription? _sceneOnLoadSubscription; + + @override + void initState() { + super.initState(); + + if (widget.localScene.loadStatus == LoadStatus.loaded) { + final buildingSceneLayers = widget.localScene.operationalLayers + .whereType() + .toList(); + _buildingSceneLayers = buildingSceneLayers; + _selectedBuildingSceneLayer = _buildingSceneLayers.first; + } else { + _sceneOnLoadSubscription = widget.localScene.onLoadStatusChanged.listen(( + loadStatus, + ) { + if (loadStatus == LoadStatus.loaded) { + final buildingSceneLayers = widget.localScene.operationalLayers + .whereType() + .toList(); + + setState(() { + _buildingSceneLayers = buildingSceneLayers; + _selectedBuildingSceneLayer = _buildingSceneLayers.first; + }); + + // We've heard enough. Cancel the subscription and null the variable. + _sceneOnLoadSubscription!.cancel(); + _sceneOnLoadSubscription = null; + } + }); + } + } + + @override + void dispose() { + if (_sceneOnLoadSubscription != null) { + _sceneOnLoadSubscription!.cancel().then( + (_) => _sceneOnLoadSubscription = null, + ); + } + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Column( + children: [ + Stack( + alignment: Alignment.center, + children: [ + // Building scene layer name centered + if (_buildingSceneLayers.length == 1) + Text( + _selectedBuildingSceneLayer!.name, + style: Theme.of(context).textTheme.headlineSmall, + textAlign: TextAlign.center, + ) + else + DropdownButton( + value: + _selectedBuildingSceneLayer ?? _buildingSceneLayers.first, + items: _buildingSceneLayers + .map( + (e) => DropdownMenuItem( + value: e, + child: Text( + e.name, + style: Theme.of(context).textTheme.headlineSmall, + textAlign: TextAlign.center, + ), + ), + ) + .toList(), + onChanged: (layer) => + setState(() => _selectedBuildingSceneLayer = layer), + ), + // Right-justified close icon button + if (widget.onClose != null) + Align( + alignment: Alignment.centerRight, + child: IconButton( + icon: const Icon(Icons.close), + onPressed: widget.onClose, + tooltip: 'Close', + ), + ), + ], + ), + const Divider(), + _BuildingLevelSelector( + buildingSceneLayer: _selectedBuildingSceneLayer!, + ), + const Divider(), + Text( + 'Disciplines & Categories:', + style: Theme.of(context).textTheme.bodyLarge, + ), + Expanded( + child: _BuildingCategoryList( + buildingSceneLayer: _selectedBuildingSceneLayer!, + fullModelSublayerName: widget.fullModelSublayerName, + ), + ), + ], + ); + } +} diff --git a/lib/src/building_explorer/building_level_selector.dart b/lib/src/building_explorer/building_level_selector.dart new file mode 100644 index 0000000..7aa69ec --- /dev/null +++ b/lib/src/building_explorer/building_level_selector.dart @@ -0,0 +1,157 @@ +// +// Copyright 2025 Esri +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +part of '../../arcgis_maps_toolkit.dart'; + +class _BuildingLevelSelector extends StatefulWidget { + const _BuildingLevelSelector({required this.buildingSceneLayer}); + + final BuildingSceneLayer buildingSceneLayer; + + @override + State createState() => _BuildingLevelSelectorState(); +} + +// Widget to list and select building level. +class _BuildingLevelSelectorState extends State<_BuildingLevelSelector> { + // The currently selected level. + var _selectedLevel = 'All'; + + // A listing of all levels in the building scene layer. + var _levelList = []; + + // Name constants + final _filterName = 'Level filter'; + final _levelBlockName = 'solid block'; + final _xrayBlockName = 'xray block'; + final _buildingLevelAttribute = 'BldgLevel'; + + @override + void initState() { + super.initState(); + + // Get the level listing from the layer statistics, then look for a + // currently selected level level. + _initLevelList().then((_) => _initSelectedLevel()); + } + + @override + void didUpdateWidget(_BuildingLevelSelector oldWidget) { + super.didUpdateWidget(oldWidget); + + // Reset the state variables + _selectedLevel = 'All'; + _levelList = []; + + // Get the state for the new BuidlingSceneLayer + _initLevelList().then((_) => _initSelectedLevel()); + } + + @override + Widget build(BuildContext context) { + final options = ['All', ..._levelList]; + return Padding( + padding: const EdgeInsets.fromLTRB(15, 0, 20, 0), + child: Row( + children: [ + Text('Level:', style: Theme.of(context).textTheme.bodyLarge), + const Spacer(), + DropdownButton( + value: _selectedLevel, + items: options + .map( + (value) => DropdownMenuItem(value: value, child: Text(value)), + ) + .toList(), + onChanged: onLevelChanged, + ), + ], + ), + ); + } + + Future _initLevelList() async { + // Get the level listing from the statistics. + final statistics = await widget.buildingSceneLayer.fetchStatistics(); + if (statistics[_buildingLevelAttribute] != null) { + final levelList = []; + levelList.addAll(statistics[_buildingLevelAttribute]!.mostFrequentValues); + levelList.sort((a, b) { + final intA = int.tryParse(a) ?? 0; + final intB = int.tryParse(b) ?? 0; + return intB.compareTo(intA); + }); + setState(() { + _levelList = levelList; + }); + } + } + + void _initSelectedLevel() { + final activeFilter = widget.buildingSceneLayer.activeFilter; + if (activeFilter != null) { + if (activeFilter.name == _filterName) { + // Get the selected level from the where clause of the solid filter block. + final levelBlock = activeFilter.blocks + .where((block) => block.title == _levelBlockName) + .firstOrNull; + if (levelBlock != null) { + setState( + () => _selectedLevel = levelBlock.whereClause.split(' ').last, + ); + } + } + } + } + + void onLevelChanged(String? level) { + if (level == null) return; + + setState(() => _selectedLevel = level); + updateLevelFilters(); + } + + // Utility function to update the building filters based on the selected level. + void updateLevelFilters() { + if (_selectedLevel == 'All') { + // No filtering applied if 'All' levels are selected. + widget.buildingSceneLayer.activeFilter = null; + return; + } + + // Build a building filter to show the selected level and an xray view of the levels below. + // levels above the selected level are not shown at all. + final buildingFilter = BuildingFilter( + name: _filterName, + description: 'Show selected level and xray filter for lower levels.', + blocks: [ + BuildingFilterBlock( + title: _levelBlockName, + whereClause: '$_buildingLevelAttribute = $_selectedLevel', + mode: BuildingSolidFilterMode(), + ), + BuildingFilterBlock( + title: _xrayBlockName, + whereClause: '$_buildingLevelAttribute < $_selectedLevel', + mode: BuildingXrayFilterMode(), + ), + ], + ); + + // Apply the filter to the building scene layer. + widget.buildingSceneLayer.activeFilter = buildingFilter; + } +}