Compare commits

...

2 Commits

Author SHA1 Message Date
2ff60fc4ff 💫 List loading state switch animation 2025-12-06 19:54:34 +08:00
ea93aa144e 🐛 Fix some bugs in post search UI 2025-12-06 19:47:36 +08:00
2 changed files with 220 additions and 127 deletions

View File

@@ -79,43 +79,21 @@ class PostSearchScreen extends HookConsumerWidget {
return AppScaffold( return AppScaffold(
isNoBackground: false, isNoBackground: false,
appBar: isWideScreen(context) appBar: AppBar(
? null title: Text('searchPosts'.tr()),
: AppBar( actions: [
title: Row( if (!isWideScreen(context))
children: [ IconButton(
Expanded( icon: Icon(
child: TextField( showFilters.value
controller: searchController, ? Icons.filter_alt
decoration: InputDecoration( : Icons.filter_alt_outlined,
hintText: 'search'.tr(),
border: InputBorder.none,
hintStyle: TextStyle(
color: Theme.of(context).appBarTheme.foregroundColor,
),
),
style: TextStyle(
color: Theme.of(context).appBarTheme.foregroundColor,
),
onChanged: onSearchChanged,
onSubmitted: (value) {
onSearchChanged(value, skipDebounce: true);
},
autofocus: true,
),
),
IconButton(
icon: Icon(
showFilters.value
? Icons.filter_alt
: Icons.filter_alt_outlined,
),
onPressed: toggleFilterDisplay,
tooltip: 'toggleFilters'.tr(),
),
],
), ),
onPressed: toggleFilterDisplay,
tooltip: 'toggleFilters'.tr(),
), ),
],
),
body: Consumer( body: Consumer(
builder: (context, ref, child) { builder: (context, ref, child) {
final searchState = ref.watch( final searchState = ref.watch(
@@ -152,10 +130,7 @@ class PostSearchScreen extends HookConsumerWidget {
), ),
), ),
), ),
const SliverGap(16), const SliverGap(12),
if (showFilters.value && !isWideScreen(context))
SliverToBoxAdapter(child: buildFilterPanel()),
// Use PaginationList with isSliver=true
PaginationList( PaginationList(
provider: postListProvider( provider: postListProvider(
PostListQueryConfig(id: kSearchPostListId), PostListQueryConfig(id: kSearchPostListId),
@@ -246,6 +221,28 @@ class PostSearchScreen extends HookConsumerWidget {
) )
: CustomScrollView( : CustomScrollView(
slivers: [ slivers: [
const SliverGap(4),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
child: SearchBar(
elevation: WidgetStateProperty.all(4),
controller: searchController,
hintText: 'search'.tr(),
leading: const Icon(Icons.search),
padding: WidgetStateProperty.all(
const EdgeInsets.symmetric(horizontal: 24),
),
onChanged: onSearchChanged,
onSubmitted: (value) {
onSearchChanged(value, skipDebounce: true);
},
),
),
),
if (showFilters.value) if (showFilters.value)
SliverToBoxAdapter( SliverToBoxAdapter(
child: Center( child: Center(
@@ -255,7 +252,6 @@ class PostSearchScreen extends HookConsumerWidget {
), ),
), ),
), ),
// Use PaginationList with isSliver=true
PaginationList( PaginationList(
provider: postListProvider( provider: postListProvider(
PostListQueryConfig(id: kSearchPostListId), PostListQueryConfig(id: kSearchPostListId),

View File

@@ -39,70 +39,115 @@ class PaginationList<T> extends HookConsumerWidget {
final data = ref.watch(provider); final data = ref.watch(provider);
final noti = ref.watch(notifier); final noti = ref.watch(notifier);
if ((data.isLoading || noti.isLoading) && data.value?.isEmpty == true) { if (isSliver) {
final content = List<Widget>.generate( // For slivers, return widgets directly without animation
10, if ((data.isLoading || noti.isLoading) && data.value?.isEmpty == true) {
(_) => Skeletonizer( final content = List<Widget>.generate(
enabled: true, 10,
effect: ShimmerEffect( (_) => Skeletonizer(
baseColor: Theme.of(context).colorScheme.surfaceContainerHigh, enabled: true,
highlightColor: Theme.of( effect: ShimmerEffect(
context, baseColor: Theme.of(context).colorScheme.surfaceContainerHigh,
).colorScheme.surfaceContainerHighest, highlightColor: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
),
containersColor: Theme.of(context).colorScheme.surfaceContainerLow,
child: footerSkeletonChild ?? const _DefaultSkeletonChild(),
), ),
containersColor: Theme.of(context).colorScheme.surfaceContainerLow, );
child: footerSkeletonChild ?? const _DefaultSkeletonChild(), return SliverList.list(children: content);
), }
if (data.hasError) {
final content = ResponseErrorWidget(
error: data.error,
onRetry: noti.refresh,
);
return SliverFillRemaining(child: content);
}
final listView = SuperSliverList.builder(
itemCount: (data.value?.length ?? 0) + 1,
itemBuilder: (context, idx) {
if (idx == data.value?.length) {
return PaginationListFooter(
noti: noti,
data: data,
skeletonChild: footerSkeletonChild,
);
}
final entry = data.value?[idx];
if (entry != null) return itemBuilder(context, idx, entry);
return null;
},
); );
return isSliver
? SliverList.list(children: content) return isRefreshable
: ListView(children: content); ? ExtendedRefreshIndicator(onRefresh: noti.refresh, child: listView)
: listView;
} }
if (data.hasError) { // For non-slivers, use AnimatedSwitcher for smooth transitions
final content = ResponseErrorWidget( Widget buildContent() {
error: data.error, if ((data.isLoading || noti.isLoading) && data.value?.isEmpty == true) {
onRetry: noti.refresh, final content = List<Widget>.generate(
10,
(_) => Skeletonizer(
enabled: true,
effect: ShimmerEffect(
baseColor: Theme.of(context).colorScheme.surfaceContainerHigh,
highlightColor: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
),
containersColor: Theme.of(context).colorScheme.surfaceContainerLow,
child: footerSkeletonChild ?? const _DefaultSkeletonChild(),
),
);
return SizedBox(
key: const ValueKey('loading'),
child: ListView(children: content),
);
}
if (data.hasError) {
final content = ResponseErrorWidget(
error: data.error,
onRetry: noti.refresh,
);
return SizedBox(key: const ValueKey('error'), child: content);
}
final listView = SuperListView.builder(
padding: padding,
itemCount: (data.value?.length ?? 0) + 1,
itemBuilder: (context, idx) {
if (idx == data.value?.length) {
return PaginationListFooter(
noti: noti,
data: data,
skeletonChild: footerSkeletonChild,
);
}
final entry = data.value?[idx];
if (entry != null) return itemBuilder(context, idx, entry);
return null;
},
);
return SizedBox(
key: const ValueKey('data'),
child: isRefreshable
? ExtendedRefreshIndicator(onRefresh: noti.refresh, child: listView)
: listView,
); );
return isSliver ? SliverFillRemaining(child: content) : content;
} }
final listView = isSliver return AnimatedSwitcher(
? SuperSliverList.builder( duration: const Duration(milliseconds: 300),
itemCount: (data.value?.length ?? 0) + 1, child: buildContent(),
itemBuilder: (context, idx) { );
if (idx == data.value?.length) {
return PaginationListFooter(
noti: noti,
data: data,
skeletonChild: footerSkeletonChild,
);
}
final entry = data.value?[idx];
if (entry != null) return itemBuilder(context, idx, entry);
return null;
},
)
: SuperListView.builder(
padding: padding,
itemCount: (data.value?.length ?? 0) + 1,
itemBuilder: (context, idx) {
if (idx == data.value?.length) {
return PaginationListFooter(
noti: noti,
data: data,
skeletonChild: footerSkeletonChild,
);
}
final entry = data.value?[idx];
if (entry != null) return itemBuilder(context, idx, entry);
return null;
},
);
return isRefreshable
? ExtendedRefreshIndicator(onRefresh: noti.refresh, child: listView)
: listView;
} }
} }
@@ -130,44 +175,96 @@ class PaginationWidget<T> extends HookConsumerWidget {
final data = ref.watch(provider); final data = ref.watch(provider);
final noti = ref.watch(notifier); final noti = ref.watch(notifier);
if ((data.isLoading || noti.isLoading) && data.value?.isEmpty == true) { if (isSliver) {
final content = List<Widget>.generate( // For slivers, return widgets directly without animation
10, if ((data.isLoading || noti.isLoading) && data.value?.isEmpty == true) {
(_) => Skeletonizer( final content = List<Widget>.generate(
enabled: true, 10,
effect: ShimmerEffect( (_) => Skeletonizer(
baseColor: Theme.of(context).colorScheme.surfaceContainerHigh, enabled: true,
highlightColor: Theme.of( effect: ShimmerEffect(
context, baseColor: Theme.of(context).colorScheme.surfaceContainerHigh,
).colorScheme.surfaceContainerHighest, highlightColor: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
),
containersColor: Theme.of(context).colorScheme.surfaceContainerLow,
child: footerSkeletonChild ?? const _DefaultSkeletonChild(),
), ),
containersColor: Theme.of(context).colorScheme.surfaceContainerLow, );
child: footerSkeletonChild ?? const _DefaultSkeletonChild(), return SliverList.list(children: content);
), }
if (data.hasError) {
final content = ResponseErrorWidget(
error: data.error,
onRetry: noti.refresh,
);
return SliverFillRemaining(child: content);
}
final footer = PaginationListFooter(
noti: noti,
data: data,
skeletonChild: footerSkeletonChild,
); );
return isSliver final content = contentBuilder(data.value ?? [], footer);
? SliverList.list(children: content)
: ListView(children: content); return isRefreshable
? ExtendedRefreshIndicator(onRefresh: noti.refresh, child: content)
: content;
} }
if (data.hasError) { // For non-slivers, use AnimatedSwitcher for smooth transitions
final content = ResponseErrorWidget( Widget buildContent() {
error: data.error, if ((data.isLoading || noti.isLoading) && data.value?.isEmpty == true) {
onRetry: noti.refresh, final content = List<Widget>.generate(
10,
(_) => Skeletonizer(
enabled: true,
effect: ShimmerEffect(
baseColor: Theme.of(context).colorScheme.surfaceContainerHigh,
highlightColor: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
),
containersColor: Theme.of(context).colorScheme.surfaceContainerLow,
child: footerSkeletonChild ?? const _DefaultSkeletonChild(),
),
);
return SizedBox(
key: const ValueKey('loading'),
child: ListView(children: content),
);
}
if (data.hasError) {
final content = ResponseErrorWidget(
error: data.error,
onRetry: noti.refresh,
);
return SizedBox(key: const ValueKey('error'), child: content);
}
final footer = PaginationListFooter(
noti: noti,
data: data,
skeletonChild: footerSkeletonChild,
);
final content = contentBuilder(data.value ?? [], footer);
return SizedBox(
key: const ValueKey('data'),
child: isRefreshable
? ExtendedRefreshIndicator(onRefresh: noti.refresh, child: content)
: content,
); );
return isSliver ? SliverFillRemaining(child: content) : content;
} }
final footer = PaginationListFooter( return AnimatedSwitcher(
noti: noti, duration: const Duration(milliseconds: 300),
data: data, child: buildContent(),
skeletonChild: footerSkeletonChild,
); );
final content = contentBuilder(data.value ?? [], footer);
return isRefreshable
? ExtendedRefreshIndicator(onRefresh: noti.refresh, child: content)
: content;
} }
} }