Compare commits
3 Commits
0.8.1-dev.
...
0.8.2-dev.
| Author | SHA1 | Date | |
|---|---|---|---|
| ecfd63c223 | |||
| de603c46e2 | |||
| 419e2a8766 |
@@ -14,6 +14,10 @@ class LegsPage extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _LegsPageState extends State<LegsPage> {
|
||||
static const int _pageSize = 100;
|
||||
static const double _loadMoreTriggerExtent = 400;
|
||||
static double _persistedScrollOffset = 0;
|
||||
|
||||
int _sortDirection = 0;
|
||||
DateTime? _startDate;
|
||||
DateTime? _endDate;
|
||||
@@ -23,6 +27,18 @@ class _LegsPageState extends State<LegsPage> {
|
||||
bool _loadingNetworks = false;
|
||||
List<String> _availableNetworks = [];
|
||||
List<String> _selectedNetworks = [];
|
||||
String? _lastQuerySignature;
|
||||
late final ScrollController _scrollController;
|
||||
bool _showBackToTop = false;
|
||||
bool _isAutoLoadingMore = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_scrollController = ScrollController(
|
||||
initialScrollOffset: _persistedScrollOffset,
|
||||
)..addListener(_onScroll);
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
@@ -34,6 +50,49 @@ class _LegsPageState extends State<LegsPage> {
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_persistedScrollOffset =
|
||||
_scrollController.hasClients ? _scrollController.offset : 0;
|
||||
_scrollController.removeListener(_onScroll);
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onScroll() {
|
||||
if (!_scrollController.hasClients) return;
|
||||
_persistedScrollOffset = _scrollController.offset;
|
||||
final shouldShow = _scrollController.offset > 500;
|
||||
if (shouldShow != _showBackToTop) {
|
||||
setState(() => _showBackToTop = shouldShow);
|
||||
}
|
||||
_maybeAutoLoadMore();
|
||||
}
|
||||
|
||||
Future<void> _maybeAutoLoadMore() async {
|
||||
if (!mounted || !_scrollController.hasClients || _isAutoLoadingMore) return;
|
||||
final position = _scrollController.position;
|
||||
if (position.extentAfter > _loadMoreTriggerExtent) return;
|
||||
final data = context.read<DataService>();
|
||||
if (data.isLegsLoading || !data.legsHasMore) return;
|
||||
|
||||
_isAutoLoadingMore = true;
|
||||
try {
|
||||
await _loadMore();
|
||||
} finally {
|
||||
_isAutoLoadingMore = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _scrollToTop() async {
|
||||
if (!_scrollController.hasClients) return;
|
||||
await _scrollController.animateTo(
|
||||
0,
|
||||
duration: const Duration(milliseconds: 250),
|
||||
curve: Curves.easeOutCubic,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _loadNetworks() async {
|
||||
setState(() => _loadingNetworks = true);
|
||||
final data = context.read<DataService>();
|
||||
@@ -47,7 +106,16 @@ class _LegsPageState extends State<LegsPage> {
|
||||
|
||||
Future<void> _refreshLegs() async {
|
||||
final data = context.read<DataService>();
|
||||
final signature = _legsQuerySignature();
|
||||
final queryChanged =
|
||||
_lastQuerySignature != null && signature != _lastQuerySignature;
|
||||
_lastQuerySignature = signature;
|
||||
|
||||
final currentCount = data.legs.length;
|
||||
final shouldPreserveLoadedCount = !queryChanged && currentCount > _pageSize;
|
||||
final limit = shouldPreserveLoadedCount ? currentCount : _pageSize;
|
||||
await data.fetchLegs(
|
||||
limit: limit,
|
||||
sortDirection: _sortDirection,
|
||||
dateRangeStart: _formatDate(_startDate),
|
||||
dateRangeEnd: _formatDate(_endDate),
|
||||
@@ -58,17 +126,30 @@ class _LegsPageState extends State<LegsPage> {
|
||||
|
||||
Future<void> _loadMore() async {
|
||||
final data = context.read<DataService>();
|
||||
final offset = data.legs.length;
|
||||
await data.fetchLegs(
|
||||
sortDirection: _sortDirection,
|
||||
dateRangeStart: _formatDate(_startDate),
|
||||
dateRangeEnd: _formatDate(_endDate),
|
||||
offset: data.legs.length,
|
||||
offset: offset,
|
||||
limit: _pageSize,
|
||||
append: true,
|
||||
unallocatedOnly: _unallocatedOnly,
|
||||
networkFilter: _selectedNetworks,
|
||||
);
|
||||
}
|
||||
|
||||
String _legsQuerySignature() {
|
||||
final networks = [..._selectedNetworks]..sort();
|
||||
return [
|
||||
'sort=$_sortDirection',
|
||||
'start=${_formatDate(_startDate) ?? ''}',
|
||||
'end=${_formatDate(_endDate) ?? ''}',
|
||||
'unallocated=$_unallocatedOnly',
|
||||
'networks=${networks.join(',')}',
|
||||
].join(';');
|
||||
}
|
||||
|
||||
double _pageMileage(List legs) {
|
||||
return legs.fold<double>(
|
||||
0,
|
||||
@@ -120,12 +201,15 @@ class _LegsPageState extends State<LegsPage> {
|
||||
final legs = data.legs;
|
||||
final pageMileage = _pageMileage(legs);
|
||||
|
||||
return RefreshIndicator(
|
||||
onRefresh: _refreshLegs,
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
children: [
|
||||
return Stack(
|
||||
children: [
|
||||
RefreshIndicator(
|
||||
onRefresh: _refreshLegs,
|
||||
child: ListView(
|
||||
controller: _scrollController,
|
||||
padding: const EdgeInsets.all(16),
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
@@ -298,28 +382,62 @@ class _LegsPageState extends State<LegsPage> {
|
||||
children: [
|
||||
..._buildLegsWithDividers(context, legs, distanceUnits),
|
||||
const SizedBox(height: 8),
|
||||
if (data.legsHasMore || data.isLegsLoading)
|
||||
Align(
|
||||
if (data.isLegsLoading)
|
||||
const Align(
|
||||
alignment: Alignment.center,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed:
|
||||
data.isLegsLoading ? null : () => _loadMore(),
|
||||
icon: data.isLegsLoading
|
||||
? const SizedBox(
|
||||
height: 14,
|
||||
width: 14,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.expand_more),
|
||||
label: Text(
|
||||
data.isLegsLoading ? 'Loading...' : 'Load more',
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 12),
|
||||
child: SizedBox(
|
||||
height: 18,
|
||||
width: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (data.legsHasMore && !data.isLegsLoading)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const SizedBox(
|
||||
height: 18,
|
||||
width: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Text(
|
||||
'Loading',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
right: 16,
|
||||
bottom: 16,
|
||||
child: AnimatedSlide(
|
||||
offset: _showBackToTop ? Offset.zero : const Offset(0, 1.2),
|
||||
duration: const Duration(milliseconds: 180),
|
||||
curve: Curves.easeOut,
|
||||
child: AnimatedOpacity(
|
||||
opacity: _showBackToTop ? 1 : 0,
|
||||
duration: const Duration(milliseconds: 180),
|
||||
child: FloatingActionButton.small(
|
||||
heroTag: 'legsBackToTop',
|
||||
tooltip: 'Back to top',
|
||||
onPressed: _scrollToTop,
|
||||
child: const Icon(Icons.arrow_upward),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,26 @@ class _LegFetchOptions {
|
||||
this.unallocatedOnly = false,
|
||||
this.networkFilter = const [],
|
||||
});
|
||||
|
||||
_LegFetchOptions copyWith({
|
||||
int? limit,
|
||||
String? sortBy,
|
||||
int? sortDirection,
|
||||
String? dateRangeStart,
|
||||
String? dateRangeEnd,
|
||||
bool? unallocatedOnly,
|
||||
List<String>? networkFilter,
|
||||
}) {
|
||||
return _LegFetchOptions(
|
||||
limit: limit ?? this.limit,
|
||||
sortBy: sortBy ?? this.sortBy,
|
||||
sortDirection: sortDirection ?? this.sortDirection,
|
||||
dateRangeStart: dateRangeStart ?? this.dateRangeStart,
|
||||
dateRangeEnd: dateRangeEnd ?? this.dateRangeEnd,
|
||||
unallocatedOnly: unallocatedOnly ?? this.unallocatedOnly,
|
||||
networkFilter: networkFilter ?? this.networkFilter,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class DataService extends ChangeNotifier {
|
||||
@@ -397,11 +417,11 @@ class DataService extends ChangeNotifier {
|
||||
List<String> networkFilter = const [],
|
||||
}) async {
|
||||
_isLegsLoading = true;
|
||||
final normalizedNetworks = networkFilter
|
||||
.map((network) => network.trim())
|
||||
.where((network) => network.isNotEmpty)
|
||||
.toList();
|
||||
if (!append) {
|
||||
final normalizedNetworks = networkFilter
|
||||
.map((network) => network.trim())
|
||||
.where((network) => network.isNotEmpty)
|
||||
.toList();
|
||||
_lastLegsFetch = _LegFetchOptions(
|
||||
limit: limit,
|
||||
sortBy: sortBy,
|
||||
@@ -437,6 +457,17 @@ class DataService extends ChangeNotifier {
|
||||
if (json is List) {
|
||||
final newLegs = json.map((e) => Leg.fromJson(e)).toList();
|
||||
_legs = append ? [..._legs, ...newLegs] : newLegs;
|
||||
if (append) {
|
||||
_lastLegsFetch = _lastLegsFetch.copyWith(
|
||||
limit: _legs.length,
|
||||
sortBy: sortBy,
|
||||
sortDirection: sortDirection,
|
||||
dateRangeStart: dateRangeStart,
|
||||
dateRangeEnd: dateRangeEnd,
|
||||
unallocatedOnly: unallocatedOnly,
|
||||
networkFilter: normalizedNetworks,
|
||||
);
|
||||
}
|
||||
// Keep "load more" available as long as the server returns items; hide only on empty.
|
||||
_legsHasMore = newLegs.isNotEmpty;
|
||||
} else {
|
||||
|
||||
@@ -32,7 +32,7 @@ extension DataServiceTraction on DataService {
|
||||
payload['class'] = locoClass;
|
||||
}
|
||||
if (locoNumber != null && locoNumber.isNotEmpty) {
|
||||
payload['number'] = locoNumber;
|
||||
payload['number'] = _withLikeWildcards(locoNumber);
|
||||
}
|
||||
if (filters != null) {
|
||||
filters.forEach((key, value) {
|
||||
@@ -67,6 +67,14 @@ extension DataServiceTraction on DataService {
|
||||
|
||||
}
|
||||
|
||||
String _withLikeWildcards(String rawValue) {
|
||||
final value = rawValue.trim();
|
||||
if (value.isEmpty) return value;
|
||||
final hasLeadingWildcard = value.startsWith('%');
|
||||
final hasTrailingWildcard = value.endsWith('%');
|
||||
return '${hasLeadingWildcard ? '' : '%'}$value${hasTrailingWildcard ? '' : '%'}';
|
||||
}
|
||||
|
||||
Future<List<LocoAttrVersion>> fetchLocoTimeline(
|
||||
int locoId, {
|
||||
bool includeAllPending = false,
|
||||
|
||||
@@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
|
||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||
# In Windows, build-name is used as the major, minor, and patch parts
|
||||
# of the product and file versions while build-number is used as the build suffix.
|
||||
version: 0.8.1+18
|
||||
version: 0.8.2+19
|
||||
|
||||
environment:
|
||||
sdk: ^3.8.1
|
||||
|
||||
Reference in New Issue
Block a user