// ===================================================================================== // 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), ], ), ), ), ), ); } }