draggable sheet added.
parent
849ef0f50b
commit
6dd7e12ead
@ -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<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),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue