You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
cloudsolutions-atoms/lib/modules/asset_tracking/asset_tracking_draggable_sh...

571 lines
20 KiB
Dart

// =====================================================================================
// 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<AssetTrackingModel> 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<void> 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<void> loadNextPage() async {
if (isLoading || isLoadingMore || !hasMore) return;
isLoadingMore = true;
error = null;
_notify();
await _fetchPage(pageNumber + 1, _activeRequest);
}
Future<void> _fetchPage(int page, int requestId) async {
final body = <String, dynamic>{
"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<String, dynamic> 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<List<AssetTrackingModel>> onShowOnMap;
/// Assets that should appear already checked (e.g. selection from a previous opening).
final List<AssetTrackingModel> 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<AssetTrackingDraggableSheet> createState() => _AssetTrackingDraggableSheetState();
}
class _AssetTrackingDraggableSheetState extends State<AssetTrackingDraggableSheet> {
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<int, AssetTrackingModel> _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<ScrollNotification>(
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<void> showAssetTrackingSheet(
BuildContext context, {
required AssetSheetQuery query,
required ValueChanged<List<AssetTrackingModel>> onShowOnMap,
AssetSheetPager? pager,
List<AssetTrackingModel> 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<List<AssetTrackingModel>> onShowOnMap;
const AssetTrackingSheetLauncher({Key? key, required this.query, required this.onShowOnMap}) : super(key: key);
@override
State<AssetTrackingSheetLauncher> createState() => _AssetTrackingSheetLauncherState();
}
class _AssetTrackingSheetLauncherState extends State<AssetTrackingSheetLauncher> {
late final AssetSheetPager _pager;
List<AssetTrackingModel> _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),
],
),
),
),
),
);
}
}