2025-08-10 01:20:45 +05:30
|
|
|
import 'package:flutter/material.dart';
|
|
|
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
|
|
|
import 'skeleton_loader.dart';
|
2025-08-23 20:09:43 +05:30
|
|
|
import 'package:conduit/l10n/app_localizations.dart';
|
2025-08-10 01:20:45 +05:30
|
|
|
import 'improved_loading_states.dart';
|
|
|
|
|
|
2025-10-10 18:49:35 +05:30
|
|
|
/// Sliver version of an optimized list for use in CustomScrollView.
|
2025-08-10 01:20:45 +05:30
|
|
|
class OptimizedSliverList<T> extends ConsumerWidget {
|
|
|
|
|
final List<T> items;
|
|
|
|
|
final Widget Function(BuildContext context, T item, int index) itemBuilder;
|
|
|
|
|
final Widget? loadingWidget;
|
|
|
|
|
final Widget? emptyWidget;
|
|
|
|
|
final String? emptyMessage;
|
|
|
|
|
final bool isLoading;
|
|
|
|
|
final bool hasMore;
|
|
|
|
|
final VoidCallback? onLoadMore;
|
|
|
|
|
final bool addAutomaticKeepAlives;
|
|
|
|
|
final bool addRepaintBoundaries;
|
|
|
|
|
|
|
|
|
|
const OptimizedSliverList({
|
|
|
|
|
super.key,
|
|
|
|
|
required this.items,
|
|
|
|
|
required this.itemBuilder,
|
|
|
|
|
this.loadingWidget,
|
|
|
|
|
this.emptyWidget,
|
|
|
|
|
this.emptyMessage,
|
|
|
|
|
this.isLoading = false,
|
|
|
|
|
this.hasMore = false,
|
|
|
|
|
this.onLoadMore,
|
|
|
|
|
this.addAutomaticKeepAlives = true,
|
|
|
|
|
this.addRepaintBoundaries = true,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
2025-10-10 18:49:35 +05:30
|
|
|
// Loading state
|
2025-08-10 01:20:45 +05:30
|
|
|
if (isLoading && items.isEmpty) {
|
|
|
|
|
return SliverToBoxAdapter(
|
|
|
|
|
child: loadingWidget ?? _buildDefaultLoadingWidget(),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-10 18:49:35 +05:30
|
|
|
// Empty state
|
2025-08-10 01:20:45 +05:30
|
|
|
if (items.isEmpty) {
|
|
|
|
|
return SliverToBoxAdapter(
|
|
|
|
|
child:
|
|
|
|
|
emptyWidget ??
|
2025-09-24 12:00:49 +05:30
|
|
|
Builder(
|
|
|
|
|
builder: (context) {
|
|
|
|
|
final l10n = AppLocalizations.of(context)!;
|
|
|
|
|
return ImprovedEmptyState(
|
|
|
|
|
title: l10n.noItems,
|
|
|
|
|
subtitle: emptyMessage ?? l10n.noItemsToDisplay,
|
|
|
|
|
icon: Icons.inbox_outlined,
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
),
|
2025-08-10 01:20:45 +05:30
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-10 18:49:35 +05:30
|
|
|
// List content
|
2025-08-10 01:20:45 +05:30
|
|
|
return SliverList(
|
|
|
|
|
delegate: SliverChildBuilderDelegate(
|
|
|
|
|
(context, index) {
|
|
|
|
|
if (index >= items.length) {
|
|
|
|
|
if (hasMore) {
|
2025-10-10 18:49:35 +05:30
|
|
|
// Trigger pagination once this placeholder is built
|
2025-08-10 01:20:45 +05:30
|
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
|
|
|
onLoadMore?.call();
|
|
|
|
|
});
|
|
|
|
|
return Container(
|
|
|
|
|
padding: const EdgeInsets.all(16.0),
|
|
|
|
|
alignment: Alignment.center,
|
|
|
|
|
child: const CircularProgressIndicator(),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
final item = items[index];
|
|
|
|
|
final widget = itemBuilder(context, item, index);
|
|
|
|
|
|
2025-10-10 18:49:35 +05:30
|
|
|
// Wrap in repaint boundary for perf
|
2025-08-10 01:20:45 +05:30
|
|
|
if (addRepaintBoundaries) {
|
|
|
|
|
return RepaintBoundary(child: widget);
|
|
|
|
|
}
|
|
|
|
|
return widget;
|
|
|
|
|
},
|
|
|
|
|
childCount: items.length + (hasMore ? 1 : 0),
|
|
|
|
|
addAutomaticKeepAlives: addAutomaticKeepAlives,
|
|
|
|
|
addRepaintBoundaries: addRepaintBoundaries,
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Widget _buildDefaultLoadingWidget() {
|
|
|
|
|
return Column(
|
|
|
|
|
children: List.generate(
|
|
|
|
|
5,
|
|
|
|
|
(index) => const Padding(
|
|
|
|
|
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
|
|
|
|
child: SkeletonLoader(height: 80),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-10 18:49:35 +05:30
|
|
|
/// Animated list with lightweight add/remove animations.
|
2025-08-10 01:20:45 +05:30
|
|
|
class OptimizedAnimatedList<T> extends ConsumerStatefulWidget {
|
|
|
|
|
final List<T> items;
|
|
|
|
|
final Widget Function(
|
|
|
|
|
BuildContext context,
|
|
|
|
|
T item,
|
|
|
|
|
int index,
|
|
|
|
|
Animation<double> animation,
|
|
|
|
|
)
|
|
|
|
|
itemBuilder;
|
|
|
|
|
final Duration animationDuration;
|
|
|
|
|
final Curve animationCurve;
|
|
|
|
|
final EdgeInsetsGeometry? padding;
|
|
|
|
|
final ScrollController? scrollController;
|
|
|
|
|
final bool shrinkWrap;
|
|
|
|
|
|
|
|
|
|
const OptimizedAnimatedList({
|
|
|
|
|
super.key,
|
|
|
|
|
required this.items,
|
|
|
|
|
required this.itemBuilder,
|
|
|
|
|
this.animationDuration = const Duration(milliseconds: 300),
|
|
|
|
|
this.animationCurve = Curves.easeInOut,
|
|
|
|
|
this.padding,
|
|
|
|
|
this.scrollController,
|
|
|
|
|
this.shrinkWrap = false,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
ConsumerState<OptimizedAnimatedList<T>> createState() =>
|
|
|
|
|
_OptimizedAnimatedListState<T>();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
class _OptimizedAnimatedListState<T>
|
|
|
|
|
extends ConsumerState<OptimizedAnimatedList<T>> {
|
|
|
|
|
final GlobalKey<AnimatedListState> _listKey = GlobalKey<AnimatedListState>();
|
|
|
|
|
late List<T> _items;
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
void initState() {
|
|
|
|
|
super.initState();
|
|
|
|
|
_items = List.from(widget.items);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
void didUpdateWidget(OptimizedAnimatedList<T> oldWidget) {
|
|
|
|
|
super.didUpdateWidget(oldWidget);
|
|
|
|
|
|
2025-10-10 18:49:35 +05:30
|
|
|
// Additions
|
2025-08-10 01:20:45 +05:30
|
|
|
for (int i = 0; i < widget.items.length; i++) {
|
|
|
|
|
if (i >= _items.length || widget.items[i] != _items[i]) {
|
|
|
|
|
_items.insert(i, widget.items[i]);
|
|
|
|
|
_listKey.currentState?.insertItem(
|
|
|
|
|
i,
|
|
|
|
|
duration: widget.animationDuration,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-10 18:49:35 +05:30
|
|
|
// Removals
|
2025-08-10 01:20:45 +05:30
|
|
|
for (int i = _items.length - 1; i >= widget.items.length; i--) {
|
|
|
|
|
final removedItem = _items[i];
|
|
|
|
|
_items.removeAt(i);
|
|
|
|
|
_listKey.currentState?.removeItem(
|
|
|
|
|
i,
|
|
|
|
|
(context, animation) =>
|
|
|
|
|
widget.itemBuilder(context, removedItem, i, animation),
|
|
|
|
|
duration: widget.animationDuration,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
Widget build(BuildContext context) {
|
|
|
|
|
return AnimatedList(
|
|
|
|
|
key: _listKey,
|
|
|
|
|
controller: widget.scrollController,
|
|
|
|
|
padding: widget.padding,
|
|
|
|
|
shrinkWrap: widget.shrinkWrap,
|
|
|
|
|
initialItemCount: _items.length,
|
|
|
|
|
itemBuilder: (context, index, animation) {
|
|
|
|
|
if (index >= _items.length) return const SizedBox.shrink();
|
|
|
|
|
return widget.itemBuilder(context, _items[index], index, animation);
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|