diff --git a/lib/modules/asset_tracking/asset_tracking_draggable_sheet.dart b/lib/modules/asset_tracking/asset_tracking_draggable_sheet.dart new file mode 100644 index 00000000..9e85dccc --- /dev/null +++ b/lib/modules/asset_tracking/asset_tracking_draggable_sheet.dart @@ -0,0 +1,570 @@ +// ===================================================================================== +// NEW WORKFLOW: Draggable bottom sheet with paginated + searchable + multi-select assets. +// +// Controlled from `MapTrackingPage` via `showAsDraggableSheet`. +// This file is fully self-contained (own paging/search/selection state, own API call) +// so it doesn't touch the existing "Assets on the floor" expansion-tile workflow. +// To roll back: set `showAsDraggableSheet = false` (or delete this file + its usages). +// +// Data flow (no duplicate API calls): +// "View" pressed -> AssetTrackingSheetLauncher creates an [AssetSheetPager] and loads page 1 +// (bar shows `totalRows`). +// Bar tapped -> the SAME pager is handed to the sheet, which renders the cached pages. +// Scroll -> pager loads pageNumber + 1, ... +// Re-open sheet -> still the same pager: already loaded pages are reused, no new request. +// ===================================================================================== + +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:test_sa/controllers/api_routes/api_manager.dart'; +import 'package:test_sa/controllers/api_routes/urls.dart'; +import 'package:test_sa/new_views/app_style/app_color.dart'; +import 'package:test_sa/views/widgets/item_views/info_text_widget.dart'; + +import 'asset_tracking_item_view.dart'; +import 'models/asset_tracking_model.dart'; + +/// Snapshot of the Site / Building / Floor selection at the time "View" was pressed. +/// [requestId] changes on every "View" press so a fresh pager (page 1) is created. +class AssetSheetQuery { + final int requestId; + final int siteId; + final int buildingId; + final int? floorId; + + const AssetSheetQuery({required this.requestId, required this.siteId, required this.buildingId, this.floorId}); + + /// "Floor" when a floor is selected, otherwise "Building". + String get scopeLabel => floorId != null ? "Floor" : "Building"; +} + +// ===================================================================================== +// Pager: single source of truth for paging/search. Shared by the bar and the sheet. +// ===================================================================================== + +class AssetSheetPager extends ChangeNotifier { + AssetSheetPager(this.query); + + final AssetSheetQuery query; + static const int pageSize = 20; + + final List assets = []; + int pageNumber = 0; // last loaded page + int totalRows = 0; // total for the current search + int? unfilteredTotal; // total without search (shown on the bar); null until first load + String search = ""; + + bool isLoading = false; // first page + bool isLoadingMore = false; // next pages + bool hasLoaded = false; + String? error; + + int _activeRequest = 0; // discards responses of outdated requests (e.g. fast typing) + bool _disposed = false; + + bool get hasMore => assets.length < totalRows; + + /// Loads page 1 (optionally with a new [newSearch]) and replaces the current list. + Future loadFirstPage({String? newSearch}) async { + if (newSearch != null) search = newSearch.trim(); + final requestId = ++_activeRequest; + isLoading = true; + isLoadingMore = false; + error = null; + assets.clear(); + pageNumber = 0; + totalRows = 0; + _notify(); + await _fetchPage(1, requestId); + } + + /// Loads `pageNumber + 1` and appends it. + Future loadNextPage() async { + if (isLoading || isLoadingMore || !hasMore) return; + isLoadingMore = true; + error = null; + _notify(); + await _fetchPage(pageNumber + 1, _activeRequest); + } + + Future _fetchPage(int page, int requestId) async { + final body = { + "campusId": query.siteId, + "venueIds": [query.buildingId], + if (query.floorId != null) "floorIds": [query.floorId], + "pageSize": pageSize, + "pageNumber": page, + if (search.isNotEmpty) "searchString": search, + }; + + try { + final response = await ApiManager.instance.post(URLs.getAssetsFromPenguin, body: body); + if (_disposed || requestId != _activeRequest) return; // outdated response + + if (response.statusCode >= 200 && response.statusCode < 300) { + final Map jsonData = json.decode(response.body); + final List list = jsonData["data"] ?? []; + final pageAssets = list.map((item) => AssetTrackingModel.fromJson(item)).toList(); + assets.addAll(pageAssets); + pageNumber = page; + totalRows = (jsonData["totalRows"] as int?) ?? assets.length; + if (pageAssets.isEmpty) totalRows = assets.length; // safety: stop paging on empty page + if (search.isEmpty) unfilteredTotal = totalRows; + hasLoaded = true; + } else { + error = "Failed to load assets (${response.statusCode})"; + } + } catch (_) { + if (_disposed || requestId != _activeRequest) return; + error = "Failed to load assets"; + } + + if (_disposed || requestId != _activeRequest) return; + isLoading = false; + isLoadingMore = false; + _notify(); + } + + void _notify() { + if (!_disposed) notifyListeners(); + } + + @override + void dispose() { + _disposed = true; + super.dispose(); + } +} + +// ===================================================================================== +// Draggable sheet +// ===================================================================================== + +class AssetTrackingDraggableSheet extends StatefulWidget { + final AssetSheetQuery query; + + /// Shared pager (from [AssetTrackingSheetLauncher]). If null, the sheet creates its own. + final AssetSheetPager? pager; + + /// Called with the selected assets when the user taps "Show on map" (or clears selection). + final ValueChanged> onShowOnMap; + + /// Assets that should appear already checked (e.g. selection from a previous opening). + final List initialSelection; + + /// true -> hosted inside a modal bottom sheet (see [showAssetTrackingSheet]). + /// false -> embedded directly in a Stack above the map. + final bool asModal; + + const AssetTrackingDraggableSheet({ + Key? key, + required this.query, + required this.onShowOnMap, + this.pager, + this.initialSelection = const [], + this.asModal = false, + }) : super(key: key); + + @override + State createState() => _AssetTrackingDraggableSheetState(); +} + +class _AssetTrackingDraggableSheetState extends State { + double get _minSize => widget.asModal ? 0.3 : 0.12; + + double get _initialSize => widget.asModal ? 0.6 : 0.35; + + double get _maxSize => widget.asModal ? 0.92 : 0.85; + + final DraggableScrollableController _sheetController = DraggableScrollableController(); + late final TextEditingController _searchController; + Timer? _debounce; + + late final AssetSheetPager _pager; + late final bool _ownsPager; + + // Selection (kept across pages & searches, keyed by asset id) + final Map _selected = {}; + + @override + void initState() { + super.initState(); + _ownsPager = widget.pager == null; + _pager = widget.pager ?? AssetSheetPager(widget.query); + _pager.addListener(_onPagerChanged); + _searchController = TextEditingController(text: _pager.search); + + for (final asset in widget.initialSelection) { + if (asset.id != null) _selected[asset.id!] = asset; + } + + // Reuse already-loaded data; only call the API if nothing was loaded yet. + if (!_pager.hasLoaded && !_pager.isLoading) _pager.loadFirstPage(); + } + + @override + void dispose() { + _debounce?.cancel(); + _pager.removeListener(_onPagerChanged); + if (_ownsPager) _pager.dispose(); + _searchController.dispose(); + _sheetController.dispose(); + super.dispose(); + } + + void _onPagerChanged() { + if (mounted) setState(() {}); + } + + // --------------------------------------------------------------------------- + // Search + // --------------------------------------------------------------------------- + + void _applySearch() { + _debounce?.cancel(); + final text = _searchController.text.trim(); + if (text == _pager.search && _pager.hasLoaded) return; // nothing changed -> no API call + _pager.loadFirstPage(newSearch: text); + } + + void _onSearchChanged(String _) { + _debounce?.cancel(); + _debounce = Timer(const Duration(milliseconds: 500), _applySearch); + setState(() {}); // refresh clear icon + } + + // --------------------------------------------------------------------------- + // Selection + // --------------------------------------------------------------------------- + + void _toggle(AssetTrackingModel asset) { + final id = asset.id; + if (id == null) return; + setState(() => _selected.containsKey(id) ? _selected.remove(id) : _selected[id] = asset); + } + + void _clearSelection() { + setState(_selected.clear); + widget.onShowOnMap(const []); + } + + void _showSelectedOnMap() { + widget.onShowOnMap(_selected.values.toList()); + if (widget.asModal) { + Navigator.of(context).pop(); + return; + } + if (_sheetController.isAttached) { + _sheetController.animateTo(_minSize, duration: const Duration(milliseconds: 250), curve: Curves.easeOut); + } + } + + // --------------------------------------------------------------------------- + // UI + // --------------------------------------------------------------------------- + + /// Lets the (non-scrolling) header drag the sheet as well. + void _onHeaderDrag(DragUpdateDetails details) { + if (!_sheetController.isAttached) return; + final height = MediaQuery.of(context).size.height; + final newSize = (_sheetController.size - details.delta.dy / height).clamp(_minSize, _maxSize); + _sheetController.jumpTo(newSize); + } + + @override + Widget build(BuildContext context) { + return DraggableScrollableSheet( + controller: _sheetController, + minChildSize: _minSize, + initialChildSize: _initialSize, + maxChildSize: _maxSize, + expand: !widget.asModal, // modal: size to the sheet so tapping the barrier closes it + snap: true, + snapSizes: [_initialSize], + builder: (context, scrollController) { + return Container( + decoration: BoxDecoration( + color: AppColor.background(context), + borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), + boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.15), blurRadius: 12, offset: const Offset(0, -2))], + ), + child: Column( + children: [ + GestureDetector( + behavior: HitTestBehavior.opaque, + onVerticalDragUpdate: _onHeaderDrag, + child: _buildHeader(context), + ), + Expanded(child: _buildList(context, scrollController)), + ], + ), + ); + }, + ); + } + + Widget _buildHeader(BuildContext context) { + final textColor = AppColor.textColor(context); + final scope = widget.query.scopeLabel.toLowerCase(); + final title = _pager.isLoading && _pager.assets.isEmpty ? "Loading assets…" : "${_pager.totalRows} Assets found on this $scope"; + + return Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Center( + child: Container( + width: 40, + height: 5, + decoration: BoxDecoration(color: AppColor.neutral10, borderRadius: BorderRadius.circular(8)), + ), + ), + const SizedBox(height: 10), + Text(title, style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: textColor)), + const SizedBox(height: 10), + TextField( + controller: _searchController, + onChanged: _onSearchChanged, + textInputAction: TextInputAction.search, + onSubmitted: (_) => _applySearch(), + style: TextStyle(color: textColor), + decoration: InputDecoration( + isDense: true, + hintText: "Search By Label or Serial Number", + prefixIcon: const Icon(Icons.search), + suffixIcon: _searchController.text.isEmpty + ? null + : IconButton( + icon: const Icon(Icons.close), + onPressed: () { + _searchController.clear(); + _applySearch(); + setState(() {}); + }, + ), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), + ), + ), + if (_selected.isNotEmpty) ...[ + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: Text("${_selected.length} selected", style: TextStyle(color: textColor, fontWeight: FontWeight.w500)), + ), + TextButton(onPressed: _clearSelection, child: const Text("Clear")), + const SizedBox(width: 4), + FilledButton.icon( + onPressed: _showSelectedOnMap, + icon: const Icon(Icons.location_pin, size: 18), + label: const Text("Show on map"), + ), + ], + ), + ], + ], + ), + ); + } + + Widget _buildList(BuildContext context, ScrollController scrollController) { + final assets = _pager.assets; + + // Note: the list MUST use [scrollController] so dragging the list also drags the sheet. + if (_pager.isLoading && assets.isEmpty) { + return ListView(controller: scrollController, children: const [ + Padding(padding: EdgeInsets.all(24), child: Center(child: CircularProgressIndicator())), + ]); + } + + if (assets.isEmpty) { + return ListView(controller: scrollController, children: [ + Padding( + padding: const EdgeInsets.all(24), + child: Column( + children: [ + Text(_pager.error ?? "No assets found", textAlign: TextAlign.center, style: TextStyle(color: AppColor.textColor(context))), + if (_pager.error != null) TextButton(onPressed: () => _pager.loadFirstPage(), child: const Text("Retry")), + ], + ), + ), + ]); + } + + return NotificationListener( + onNotification: (info) { + // Auto-load next page near the bottom (not while showing an error -> user taps retry). + if (_pager.error == null && info.metrics.pixels >= info.metrics.maxScrollExtent - 200) { + _pager.loadNextPage(); + } + return false; + }, + child: ListView.separated( + controller: scrollController, + padding: const EdgeInsets.fromLTRB(16, 4, 16, 24), + itemCount: assets.length + 1, // +1 footer (loader / retry / end) + separatorBuilder: (_, __) => const SizedBox(height: 12), + itemBuilder: (context, index) { + if (index == assets.length) return _buildFooter(context); + final asset = assets[index]; + final isSelected = asset.id != null && _selected.containsKey(asset.id); + return InkWell( + borderRadius: BorderRadius.circular(12), + onTap: () => _toggle(asset), + child: Stack( + children: [ + AssetTrackingListItemView(asset, false, showViewOnMap: false), + PositionedDirectional( + top: 4, + end: 4, + child: Checkbox(value: isSelected, onChanged: (_) => _toggle(asset)), + ), + ], + ), + ); + }, + ), + ); + } + + Widget _buildFooter(BuildContext context) { + if (_pager.isLoadingMore) { + return const Padding(padding: EdgeInsets.all(12), child: Center(child: CircularProgressIndicator())); + } + if (_pager.error != null && _pager.hasMore) { + return Center(child: TextButton(onPressed: _pager.loadNextPage, child: const Text("Retry loading more"))); + } + if (!_pager.hasMore) { + return Padding( + padding: const EdgeInsets.all(8), + child: Center(child: Text("Showing all ${_pager.assets.length} assets", style: const TextStyle(color: AppColor.neutral20))), + ); + } + return const SizedBox(height: 24); + } +} + +// ===================================================================================== +// Bottom bar shown in place of the old "Assets found on this floor" expansion tile. +// Tapping it opens [AssetTrackingDraggableSheet] as a modal draggable bottom sheet. +// ===================================================================================== + +/// Opens the paginated assets sheet as a modal draggable bottom sheet. +Future showAssetTrackingSheet( + BuildContext context, { + required AssetSheetQuery query, + required ValueChanged> onShowOnMap, + AssetSheetPager? pager, + List initialSelection = const [], +}) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + backgroundColor: Colors.transparent, + builder: (_) => AssetTrackingDraggableSheet( + query: query, + pager: pager, + onShowOnMap: onShowOnMap, + initialSelection: initialSelection, + asModal: true, + ), + ); +} + +class AssetTrackingSheetLauncher extends StatefulWidget { + final AssetSheetQuery query; + final ValueChanged> onShowOnMap; + + const AssetTrackingSheetLauncher({Key? key, required this.query, required this.onShowOnMap}) : super(key: key); + + @override + State createState() => _AssetTrackingSheetLauncherState(); +} + +class _AssetTrackingSheetLauncherState extends State { + late final AssetSheetPager _pager; + List _selection = const []; + + @override + void initState() { + super.initState(); + // One pager per "View": page 1 loaded here is reused by the sheet. + _pager = AssetSheetPager(widget.query)..addListener(_onPagerChanged); + _pager.loadFirstPage(); + } + + @override + void dispose() { + _pager.removeListener(_onPagerChanged); + _pager.dispose(); + super.dispose(); + } + + void _onPagerChanged() { + if (mounted) setState(() {}); + } + + void _open() { + showAssetTrackingSheet( + context, + query: widget.query, + pager: _pager, + initialSelection: _selection, + onShowOnMap: (assets) { + _selection = assets; // remember selection for the next opening + widget.onShowOnMap(assets); + if (mounted) setState(() {}); + }, + ); + } + + @override + Widget build(BuildContext context) { + final total = _pager.unfilteredTotal; // null until the first (unfiltered) page arrives + final failed = total == null && _pager.error != null && !_pager.isLoading; + if (total == 0) return const SizedBox(); // same as old tile: hidden when no assets + + final scope = widget.query.scopeLabel.toLowerCase(); + final label = failed + ? "Failed to load assets, tap to retry" + : total == null + ? "Loading assets…" + : "$total Assets found on this $scope"; + + return SafeArea( + top: false, + child: Container( + margin: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppColor.tabBarBackground(context), + border: Border.all(color: AppColor.tabBarBorder(context)), + borderRadius: BorderRadius.circular(20), + ), + child: InkWell( + borderRadius: BorderRadius.circular(20), + onTap: failed ? () => _pager.loadFirstPage() : (total == null ? null : _open), + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Expanded(child: InfoTextLabelWidget(label: label)), + if (_selection.isNotEmpty) + Padding( + padding: const EdgeInsetsDirectional.only(end: 8), + child: Text("${_selection.length} pinned", style: const TextStyle(color: AppColor.neutral20, fontSize: 12)), + ), + Icon(failed ? Icons.refresh : Icons.expand_less), + ], + ), + ), + ), + ), + ); + } +} + diff --git a/lib/modules/asset_tracking/map_tracking_page.dart b/lib/modules/asset_tracking/map_tracking_page.dart index 47ad3ec3..e26056d2 100644 --- a/lib/modules/asset_tracking/map_tracking_page.dart +++ b/lib/modules/asset_tracking/map_tracking_page.dart @@ -21,6 +21,7 @@ import 'package:test_sa/providers/loading_list_notifier.dart'; import 'package:test_sa/views/widgets/item_views/info_header_widget.dart'; import 'package:test_sa/views/widgets/item_views/info_text_widget.dart'; +import 'asset_tracking_draggable_sheet.dart'; import 'asset_tracking_item_view.dart'; import 'asset_tracking_provider.dart'; import 'models/asset_tracking_model.dart'; @@ -70,6 +71,45 @@ class _MapTrackingPageState extends State { static const LatLng _fallbackCenter = LatLng(24.81200245073082, 46.624273601310186); + // --------------------------------------------------------------------------- + // Workflow switch: + // true -> NEW: paginated/searchable/multi-select draggable bottom sheet + // (see asset_tracking_draggable_sheet.dart) + // false -> EXISTING: "Assets found on this floor" UpwardExpansionTile + dropdown + // --------------------------------------------------------------------------- + static const bool showAsDraggableSheet = false; + + /// NEW WORKFLOW: current query for the draggable sheet (null until "View" is pressed). + AssetSheetQuery? _sheetQuery; + int _sheetRequestId = 0; + + /// NEW WORKFLOW: (re)opens the draggable sheet for the current Site/Building/Floor selection. + void _openAssetsSheet(AssetTrackingProvider provider) { + final siteId = provider.selectedSite?.id; + final buildingId = provider.selectedBuilding?.id; + if (siteId == null || buildingId == null) return; + setState(() { + _sheetQuery = AssetSheetQuery( + requestId: ++_sheetRequestId, + siteId: siteId, + buildingId: buildingId, + floorId: provider.selectedFloor?.id, + ); + }); + } + + /// NEW WORKFLOW: pins the assets selected in the draggable sheet on the map. + void _onSheetShowOnMap(List assets) { + final located = assets.where((a) => a.location?.latitude != null && a.location?.longitude != null).toList(); + setState(() { + _selectedZoneProperties = null; + markers = [for (final asset in located) _buildAssetMarker(asset)]; + }); + if (located.isNotEmpty) { + _fitCameraToPoints([for (final a in located) LatLng(a.location!.latitude!, a.location!.longitude!)]); + } + } + @override void initState() { super.initState(); @@ -122,6 +162,7 @@ class _MapTrackingPageState extends State { /// "View" button: loads the floor map (if a floor is selected) or the building outline. Future _onViewPressed(AssetTrackingProvider provider) async { expansibleController.collapse(); + if (showAsDraggableSheet) _openAssetsSheet(provider); // NEW WORKFLOW provider.coordinates = []; provider.assetsOnTheFloorList = []; provider.notify(); @@ -556,8 +597,8 @@ class _MapTrackingPageState extends State { }), // Selected asset pins (floor map or building outline). if (markers.isNotEmpty) MarkerLayer(markers: markers), - // Assets panel: shown for both floor view and building-only view. - if (inAppView) + // Assets panel: shown for both floor view and building-only view (EXISTING workflow only). + if (inAppView && !showAsDraggableSheet) Positioned( bottom: 0, left: 0, @@ -658,6 +699,19 @@ class _MapTrackingPageState extends State { alignment: AlignmentDirectional.topCenter, children: [ mapWidget, + // NEW WORKFLOW: bottom bar (replaces the "Assets found on this floor" expansion tile). + // Tapping it opens the paginated/searchable/multi-select draggable sheet. + if (showAsDraggableSheet && _sheetQuery != null) + Positioned( + bottom: 0, + left: 0, + right: 0, + child: AssetTrackingSheetLauncher( + key: ValueKey(_sheetQuery!.requestId), // fresh state on every "View" + query: _sheetQuery!, + onShowOnMap: _onSheetShowOnMap, + ), + ), Container( margin: const EdgeInsets.all(16), padding: const EdgeInsets.only(right: 16, left: 16),