♻️ Refactored post loading

This commit is contained in:
2025-12-06 18:20:47 +08:00
parent 240509ceff
commit 16c7b7e764
11 changed files with 745 additions and 971 deletions

View File

@@ -7,6 +7,7 @@ abstract class PaginationController<T> {
int get fetchedCount;
bool get fetchedAll;
bool get isLoading;
FutureOr<List<T>> fetch();
@@ -32,11 +33,15 @@ mixin AsyncPaginationController<T> on AsyncNotifier<List<T>>
@override
bool get fetchedAll => totalCount != null && fetchedCount >= totalCount!;
@override
bool isLoading = false;
@override
FutureOr<List<T>> build() async => fetch();
@override
Future<void> refresh() async {
isLoading = true;
totalCount = null;
state = AsyncData<List<T>>([]);
@@ -44,12 +49,14 @@ mixin AsyncPaginationController<T> on AsyncNotifier<List<T>>
return await fetch();
});
state = newState;
isLoading = false;
}
@override
Future<void> fetchFurther() async {
if (fetchedAll) return;
isLoading = true;
state = AsyncLoading<List<T>>();
final newState = await AsyncValue.guard<List<T>>(() async {
@@ -58,6 +65,7 @@ mixin AsyncPaginationController<T> on AsyncNotifier<List<T>>
});
state = newState;
isLoading = false;
}
}
@@ -67,6 +75,7 @@ mixin AsyncPaginationFilter<F, T> on AsyncPaginationController<T>
Future<void> applyFilter(F filter) async {
if (currentFilter == filter) return;
// Reset the data
isLoading = true;
totalCount = null;
state = AsyncData<List<T>>([]);
currentFilter = filter;
@@ -75,5 +84,6 @@ mixin AsyncPaginationFilter<F, T> on AsyncPaginationController<T>
return await fetch();
});
state = newState;
isLoading = false;
}
}

View File

@@ -26,18 +26,22 @@ sealed class PostListQuery with _$PostListQuery {
}) = _PostListQuery;
}
final postListNotifierProvider = AsyncNotifierProvider.autoDispose
.family<PostListNotifier, List<SnPost>, PostListQuery>(
final postListProvider = AsyncNotifierProvider.autoDispose.family(
PostListNotifier.new,
);
);
class PostListNotifier extends AsyncNotifier<List<SnPost>>
with AsyncPaginationController<SnPost> {
final PostListQuery arg;
PostListNotifier(this.arg);
with
AsyncPaginationController<SnPost>,
AsyncPaginationFilter<PostListQuery, SnPost> {
static const int pageSize = 20;
final String? id;
PostListNotifier(this.id);
@override
PostListQuery currentFilter = PostListQuery();
@override
Future<List<SnPost>> fetch() async {
final client = ref.read(apiClientProvider);
@@ -45,20 +49,22 @@ class PostListNotifier extends AsyncNotifier<List<SnPost>>
final queryParams = {
'offset': fetchedCount,
'take': pageSize,
'replies': arg.includeReplies,
'orderDesc': arg.orderDesc,
if (arg.shuffle) 'shuffle': arg.shuffle,
if (arg.pubName != null) 'pub': arg.pubName,
if (arg.realm != null) 'realm': arg.realm,
if (arg.type != null) 'type': arg.type,
if (arg.tags != null) 'tags': arg.tags,
if (arg.categories != null) 'categories': arg.categories,
if (arg.pinned != null) 'pinned': arg.pinned,
if (arg.order != null) 'order': arg.order,
if (arg.periodStart != null) 'periodStart': arg.periodStart,
if (arg.periodEnd != null) 'periodEnd': arg.periodEnd,
if (arg.queryTerm != null) 'query': arg.queryTerm,
if (arg.mediaOnly != null) 'media': arg.mediaOnly,
'replies': currentFilter.includeReplies,
'orderDesc': currentFilter.orderDesc,
if (currentFilter.shuffle) 'shuffle': currentFilter.shuffle,
if (currentFilter.pubName != null) 'pub': currentFilter.pubName,
if (currentFilter.realm != null) 'realm': currentFilter.realm,
if (currentFilter.type != null) 'type': currentFilter.type,
if (currentFilter.tags != null) 'tags': currentFilter.tags,
if (currentFilter.categories != null)
'categories': currentFilter.categories,
if (currentFilter.pinned != null) 'pinned': currentFilter.pinned,
if (currentFilter.order != null) 'order': currentFilter.order,
if (currentFilter.periodStart != null)
'periodStart': currentFilter.periodStart,
if (currentFilter.periodEnd != null) 'periodEnd': currentFilter.periodEnd,
if (currentFilter.queryTerm != null) 'query': currentFilter.queryTerm,
if (currentFilter.mediaOnly != null) 'media': currentFilter.mediaOnly,
};
final response = await client.get(

View File

@@ -2,6 +2,7 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/pods/post/post_list.dart';
import 'package:island/widgets/app_scaffold.dart';
import 'package:island/widgets/post/post_list.dart';
@@ -20,7 +21,7 @@ class CreatorPostListScreen extends HookConsumerWidget {
key: ValueKey(refreshKey.value),
slivers: [
SliverPostList(
pubName: pubName,
query: PostListQuery(pubName: pubName),
itemType: PostItemType.creator,
maxWidth: 640,
backgroundColor: Theme.of(context).colorScheme.surfaceContainer,

View File

@@ -5,6 +5,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/post_category.dart';
import 'package:island/models/post_tag.dart';
import 'package:island/pods/network.dart';
import 'package:island/pods/post/post_list.dart';
import 'package:island/widgets/app_scaffold.dart';
import 'package:island/widgets/post/post_list.dart';
import 'package:island/widgets/response.dart';
@@ -82,15 +83,15 @@ class PostCategoryDetailScreen extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final postCategory =
isCategory ? ref.watch(postCategoryProvider(slug)) : null;
final postCategory = isCategory
? ref.watch(postCategoryProvider(slug))
: null;
final postTag = isCategory ? null : ref.watch(postTagProvider(slug));
final subscriptionStatus = ref.watch(
postCategorySubscriptionStatusProvider(slug, isCategory),
);
final postFilterTitle =
isCategory
final postFilterTitle = isCategory
? postCategory?.value?.categoryDisplayTitle ?? 'loading'
: postTag?.value?.name ?? postTag?.value?.slug ?? 'loading';
@@ -108,8 +109,7 @@ class PostCategoryDetailScreen extends HookConsumerWidget {
child: Card(
margin: EdgeInsets.only(top: 8),
child: postCategory!.when(
data:
(category) => Column(
data: (category) => Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
@@ -118,9 +118,7 @@ class PostCategoryDetailScreen extends HookConsumerWidget {
Text('A category'),
const Gap(8),
subscriptionStatus.when(
data:
(isSubscribed) =>
isSubscribed
data: (isSubscribed) => isSubscribed
? FilledButton.icon(
onPressed: () async {
await _unsubscribeFromCategoryOrTag(
@@ -129,9 +127,7 @@ class PostCategoryDetailScreen extends HookConsumerWidget {
isCategory: isCategory,
);
},
icon: const Icon(
Symbols.remove_circle,
),
icon: const Icon(Symbols.remove_circle),
label: Text('unsubscribe'.tr()),
)
: FilledButton.icon(
@@ -142,28 +138,20 @@ class PostCategoryDetailScreen extends HookConsumerWidget {
isCategory: isCategory,
);
},
icon: const Icon(
Symbols.add_circle,
),
icon: const Icon(Symbols.add_circle),
label: Text('subscribe'.tr()),
),
error:
(error, _) => Text(
'Error loading subscription status',
),
loading:
() =>
error: (error, _) =>
Text('Error loading subscription status'),
loading: () =>
CircularProgressIndicator().center(),
),
],
).padding(horizontal: 24, vertical: 16),
error:
(error, _) => ResponseErrorWidget(
error: (error, _) => ResponseErrorWidget(
error: error,
onRetry:
() => ref.invalidate(
postCategoryProvider(slug),
),
onRetry: () =>
ref.invalidate(postCategoryProvider(slug)),
),
loading: () => ResponseLoadingWidget(),
),
@@ -179,8 +167,7 @@ class PostCategoryDetailScreen extends HookConsumerWidget {
child: Card(
margin: EdgeInsets.only(top: 8),
child: postTag!.when(
data:
(tag) => Column(
data: (tag) => Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
@@ -189,9 +176,7 @@ class PostCategoryDetailScreen extends HookConsumerWidget {
Text('A tag'),
const Gap(8),
subscriptionStatus.when(
data:
(isSubscribed) =>
isSubscribed
data: (isSubscribed) => isSubscribed
? FilledButton.icon(
onPressed: () async {
await _unsubscribeFromCategoryOrTag(
@@ -200,9 +185,7 @@ class PostCategoryDetailScreen extends HookConsumerWidget {
isCategory: isCategory,
);
},
icon: const Icon(
Symbols.remove_circle,
),
icon: const Icon(Symbols.remove_circle),
label: Text('unsubscribe'.tr()),
)
: FilledButton.icon(
@@ -213,26 +196,19 @@ class PostCategoryDetailScreen extends HookConsumerWidget {
isCategory: isCategory,
);
},
icon: const Icon(
Symbols.add_circle,
),
icon: const Icon(Symbols.add_circle),
label: Text('subscribe'.tr()),
),
error:
(error, _) => Text(
'Error loading subscription status',
),
loading:
() =>
error: (error, _) =>
Text('Error loading subscription status'),
loading: () =>
CircularProgressIndicator().center(),
),
],
).padding(horizontal: 24, vertical: 16),
error:
(error, _) => ResponseErrorWidget(
error: (error, _) => ResponseErrorWidget(
error: error,
onRetry:
() => ref.invalidate(postTagProvider(slug)),
onRetry: () => ref.invalidate(postTagProvider(slug)),
),
loading: () => ResponseLoadingWidget(),
),
@@ -242,8 +218,11 @@ class PostCategoryDetailScreen extends HookConsumerWidget {
),
const SliverGap(4),
SliverPostList(
query: PostListQuery(
categories: isCategory ? [slug] : null,
tags: isCategory ? null : [slug],
),
maxWidth: 540 + 16,
),
SliverGap(MediaQuery.of(context).padding.bottom + 8),

View File

@@ -3,140 +3,18 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/post.dart';
import 'package:island/pods/network.dart';
import 'package:island/widgets/app_scaffold.dart';
import 'package:island/widgets/extended_refresh_indicator.dart';
import 'package:island/widgets/post/post_item.dart';
import 'package:island/widgets/posts/post_filter.dart';
import 'package:gap/gap.dart';
import 'package:island/pods/paging.dart';
import 'package:island/pods/post/post_list.dart';
import 'package:island/services/responsive.dart';
import 'package:island/widgets/paging/pagination_list.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:styled_widget/styled_widget.dart';
final postSearchProvider = AsyncNotifierProvider.autoDispose(
PostSearchNotifier.new,
);
class PostSearchNotifier extends AsyncNotifier<List<SnPost>>
with AsyncPaginationController<SnPost> {
static const int _pageSize = 20;
String _currentQuery = '';
String? _pubName;
String? _realm;
int? _type;
List<String>? _categories;
List<String>? _tags;
bool _shuffle = false;
bool? _pinned;
@override
FutureOr<List<SnPost>> build() async {
// Initial state is empty if no query/filters, or fetch if needed
// But original logic allowed initial empty state.
// Let's replicate original logic: return empty list initially if no query.
return [];
}
bool? _includeReplies;
bool _mediaOnly = false;
String? _queryTerm;
String? _order;
bool _orderDesc = true;
int? _periodStart;
int? _periodEnd;
Future<void> search(
String query, {
String? pubName,
String? realm,
int? type,
List<String>? categories,
List<String>? tags,
bool shuffle = false,
bool? pinned,
bool? includeReplies,
bool mediaOnly = false,
String? queryTerm,
String? order,
bool orderDesc = true,
int? periodStart,
int? periodEnd,
}) async {
_currentQuery = query.trim();
_pubName = pubName;
_realm = realm;
_type = type;
_categories = categories;
_tags = tags;
_shuffle = shuffle;
_pinned = pinned;
_includeReplies = includeReplies;
_mediaOnly = mediaOnly;
_queryTerm = queryTerm;
_order = order;
_orderDesc = orderDesc;
_periodStart = periodStart;
_periodEnd = periodEnd;
final hasFilters =
pubName != null ||
realm != null ||
type != null ||
categories != null ||
tags != null ||
shuffle ||
pinned != null ||
includeReplies != null ||
mediaOnly ||
queryTerm != null ||
order != null ||
periodStart != null ||
periodEnd != null;
if (_currentQuery.isEmpty && !hasFilters) {
state = const AsyncData([]);
totalCount = null;
return;
}
await refresh();
}
@override
Future<List<SnPost>> fetch() async {
final client = ref.read(apiClientProvider);
final response = await client.get(
'/sphere/posts',
queryParameters: {
'query': _currentQuery,
'offset': fetchedCount,
'take': _pageSize,
'vector': false,
if (_pubName != null) 'pub': _pubName,
if (_realm != null) 'realm': _realm,
if (_type != null) 'type': _type,
if (_tags != null) 'tags': _tags,
if (_categories != null) 'categories': _categories,
if (_shuffle) 'shuffle': true,
if (_pinned != null) 'pinned': _pinned,
if (_includeReplies != null) 'includeReplies': _includeReplies,
if (_mediaOnly) 'mediaOnly': true,
if (_queryTerm != null) 'queryTerm': _queryTerm,
if (_order != null) 'order': _order,
if (_orderDesc) 'orderDesc': true,
if (_periodStart != null) 'periodStart': _periodStart,
if (_periodEnd != null) 'periodEnd': _periodEnd,
},
);
totalCount = int.parse(response.headers.value('X-Total') ?? '0');
final data = response.data as List;
return data.map((json) => SnPost.fromJson(json)).toList();
}
}
const kSearchPostListId = 'search';
class PostSearchScreen extends HookConsumerWidget {
const PostSearchScreen({super.key});
@@ -149,22 +27,14 @@ class PostSearchScreen extends HookConsumerWidget {
final showFilters = useState(false);
final pubNameController = useTextEditingController();
final realmController = useTextEditingController();
final typeValue = useState<int?>(null);
final selectedCategories = useState<List<String>>([]);
final selectedTags = useState<List<String>>([]);
final shuffleValue = useState(false);
final pinnedValue = useState<bool?>(null);
// State variables for PostFilterWidget
final categoryTabController = useTabController(initialLength: 3);
final includeReplies = useState<bool?>(null);
final mediaOnly = useState(false);
final queryTerm = useState<String?>(null);
final order = useState<String?>('date');
final orderDesc = useState(true);
final periodStart = useState<int?>(null);
final periodEnd = useState<int?>(null);
final showAdvancedFilters = useState(false);
// Single query state
final queryState = useState(const PostListQuery());
final noti = ref.read(postListProvider(kSearchPostListId).notifier);
useEffect(() {
return () {
@@ -175,77 +45,32 @@ class PostSearchScreen extends HookConsumerWidget {
};
}, []);
void onSearchChanged(String query) {
if (debounceTimer.value?.isActive ?? false) debounceTimer.value!.cancel();
void onSearchChanged(String query, {bool skipDebounce = false}) {
queryState.value = queryState.value.copyWith(queryTerm: query);
if (skipDebounce) {
noti.applyFilter(queryState.value);
return;
}
if (debounceTimer.value?.isActive ?? false) debounceTimer.value!.cancel();
debounceTimer.value = Timer(debounce, () {
ref
.read(postSearchProvider.notifier)
.search(
query,
type: categoryTabController.index == 1
? 0
: (categoryTabController.index == 2 ? 1 : null),
includeReplies: includeReplies.value,
mediaOnly: mediaOnly.value,
queryTerm: queryTerm.value,
order: order.value,
orderDesc: orderDesc.value,
periodStart: periodStart.value,
periodEnd: periodEnd.value,
);
noti.applyFilter(queryState.value);
});
}
void onSearchWithFilters(String query) {
if (debounceTimer.value?.isActive ?? false) debounceTimer.value!.cancel();
debounceTimer.value = Timer(debounce, () {
ref
.read(postSearchProvider.notifier)
.search(
query,
pubName: pubNameController.text.isNotEmpty
? pubNameController.text
: null,
realm: realmController.text.isNotEmpty
? realmController.text
: null,
type: categoryTabController.index == 1
? 0
: (categoryTabController.index == 2 ? 1 : null),
categories: selectedCategories.value.isNotEmpty
? selectedCategories.value
: null,
tags: selectedTags.value.isNotEmpty ? selectedTags.value : null,
shuffle: shuffleValue.value,
pinned: pinnedValue.value,
includeReplies: includeReplies.value,
mediaOnly: mediaOnly.value,
queryTerm: queryTerm.value,
order: order.value,
orderDesc: orderDesc.value,
periodStart: periodStart.value,
periodEnd: periodEnd.value,
);
});
}
void toggleFilters() {
void toggleFilterDisplay() {
showFilters.value = !showFilters.value;
}
Widget buildFilterPanel() {
return PostFilterWidget(
categoryTabController: categoryTabController,
includeReplies: includeReplies,
mediaOnly: mediaOnly,
queryTerm: queryTerm,
order: order,
orderDesc: orderDesc,
periodStart: periodStart,
periodEnd: periodEnd,
showAdvancedFilters: showAdvancedFilters,
initialQuery: queryState.value,
onQueryChanged: (newQuery) {
queryState.value = newQuery;
noti.applyFilter(newQuery);
},
hideSearch: true,
);
}
@@ -272,7 +97,7 @@ class PostSearchScreen extends HookConsumerWidget {
),
onChanged: onSearchChanged,
onSubmitted: (value) {
onSearchWithFilters(value);
onSearchChanged(value, skipDebounce: true);
},
autofocus: true,
),
@@ -283,7 +108,7 @@ class PostSearchScreen extends HookConsumerWidget {
? Icons.filter_alt
: Icons.filter_alt_outlined,
),
onPressed: toggleFilters,
onPressed: toggleFilterDisplay,
tooltip: 'toggleFilters'.tr(),
),
],
@@ -291,17 +116,23 @@ class PostSearchScreen extends HookConsumerWidget {
),
body: Consumer(
builder: (context, ref, child) {
final searchState = ref.watch(postSearchProvider);
final searchState = ref.watch(postListProvider(kSearchPostListId));
return isWideScreen(context)
? Row(
children: [
Flexible(
flex: 4,
child: ExtendedRefreshIndicator(
onRefresh: noti.refresh,
child: CustomScrollView(
slivers: [
SliverGap(16),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 8,
),
child: SearchBar(
elevation: WidgetStateProperty.all(4),
controller: searchController,
@@ -312,17 +143,20 @@ class PostSearchScreen extends HookConsumerWidget {
),
onChanged: onSearchChanged,
onSubmitted: (value) {
onSearchWithFilters(value);
onSearchChanged(value, skipDebounce: true);
},
),
),
),
const SliverGap(16),
if (showFilters.value && !isWideScreen(context))
SliverToBoxAdapter(child: buildFilterPanel()),
// Use PaginationList with isSliver=true
PaginationList(
provider: postSearchProvider,
notifier: postSearchProvider.notifier,
provider: postListProvider(kSearchPostListId),
notifier: postListProvider(
kSearchPostListId,
).notifier,
isSliver: true,
isRefreshable: false,
itemBuilder: (context, index, post) {
@@ -342,12 +176,17 @@ class PostSearchScreen extends HookConsumerWidget {
searchController.text.isNotEmpty &&
!searchState.isLoading)
SliverFillRemaining(
child: Center(child: Text('noResultsFound'.tr())),
child: Center(
child: Text('noResultsFound'.tr()),
),
),
SliverGap(
MediaQuery.of(context).padding.bottom + 16,
),
SliverGap(MediaQuery.of(context).padding.bottom + 16),
],
).padding(left: 8),
),
),
Flexible(
flex: 3,
child: Align(
@@ -382,7 +221,7 @@ class PostSearchScreen extends HookConsumerWidget {
Symbols.filter_alt,
fill: showFilters.value ? 1 : null,
),
onPressed: toggleFilters,
onPressed: toggleFilterDisplay,
tooltip: 'toggleFilters'.tr(),
),
const Gap(4),
@@ -412,8 +251,8 @@ class PostSearchScreen extends HookConsumerWidget {
),
// Use PaginationList with isSliver=true
PaginationList(
provider: postSearchProvider,
notifier: postSearchProvider.notifier,
provider: postListProvider(kSearchPostListId),
notifier: postListProvider(kSearchPostListId).notifier,
isSliver: true,
isRefreshable: false,
itemBuilder: (context, index, post) {

View File

@@ -11,6 +11,7 @@ import 'package:island/models/account.dart';
import 'package:island/models/heatmap.dart';
import 'package:island/pods/config.dart';
import 'package:island/pods/network.dart';
import 'package:island/pods/post/post_list.dart';
import 'package:island/services/color.dart';
import 'package:island/services/responsive.dart';
import 'package:island/widgets/account/account_name.dart';
@@ -344,45 +345,6 @@ class _PublisherHeatmapWidget extends StatelessWidget {
}
}
class _PublisherCategoryTabWidget extends StatelessWidget {
final TabController categoryTabController;
final ValueNotifier<bool?> includeReplies;
final ValueNotifier<bool> mediaOnly;
final ValueNotifier<String?> queryTerm;
final ValueNotifier<String?> order;
final ValueNotifier<bool> orderDesc;
final ValueNotifier<int?> periodStart;
final ValueNotifier<int?> periodEnd;
final ValueNotifier<bool> showAdvancedFilters;
const _PublisherCategoryTabWidget({
required this.categoryTabController,
required this.includeReplies,
required this.mediaOnly,
required this.queryTerm,
required this.order,
required this.orderDesc,
required this.periodStart,
required this.periodEnd,
required this.showAdvancedFilters,
});
@override
Widget build(BuildContext context) {
return PostFilterWidget(
categoryTabController: categoryTabController,
includeReplies: includeReplies,
mediaOnly: mediaOnly,
queryTerm: queryTerm,
order: order,
orderDesc: orderDesc,
periodStart: periodStart,
periodEnd: periodEnd,
showAdvancedFilters: showAdvancedFilters,
);
}
}
@riverpod
Future<SnPublisher> publisher(Ref ref, String uname) async {
final apiClient = ref.watch(apiClientProvider);
@@ -454,24 +416,22 @@ class PublisherProfileScreen extends HookConsumerWidget {
);
final categoryTabController = useTabController(initialLength: 3);
final categoryTab = useState(0);
categoryTabController.addListener(() {
categoryTab.value = categoryTabController.index;
});
final includeReplies = useState<bool?>(null);
final mediaOnly = useState(false);
final queryTerm = useState<String?>(null);
final order = useState<String?>('date'); // 'popularity' or 'date'
final orderDesc = useState(
true,
); // true for descending, false for ascending
final periodStart = useState<int?>(null);
final periodEnd = useState<int?>(null);
final showAdvancedFilters = useState(false);
final queryState = useState(PostListQuery(pubName: name));
final subscribing = useState(false);
final isPinnedExpanded = useState(true);
useEffect(() {
final index = switch (queryState.value.type) {
0 => 1,
1 => 2,
_ => 0,
};
categoryTabController.index = index;
return null;
}, []);
Future<void> subscribe() async {
final apiClient = ref.watch(apiClientProvider);
subscribing.value = true;
@@ -564,37 +524,22 @@ class PublisherProfileScreen extends HookConsumerWidget {
),
...[
if (isPinnedExpanded.value)
SliverPostList(pubName: name, pinned: true),
SliverPostList(
query: PostListQuery(pubName: name, pinned: true),
queryKey: 'publisher-$name-pinned',
),
],
SliverToBoxAdapter(
child: _PublisherCategoryTabWidget(
child: PostFilterWidget(
categoryTabController: categoryTabController,
includeReplies: includeReplies,
mediaOnly: mediaOnly,
queryTerm: queryTerm,
order: order,
orderDesc: orderDesc,
periodStart: periodStart,
periodEnd: periodEnd,
showAdvancedFilters: showAdvancedFilters,
initialQuery: queryState.value,
onQueryChanged: (newQuery) =>
queryState.value = newQuery,
),
),
SliverPostList(
key: ValueKey(
'${categoryTab.value}-${includeReplies.value}-${mediaOnly.value}-${queryTerm.value}-${order.value}-${orderDesc.value}-${periodStart.value}-${periodEnd.value}',
),
pubName: name,
pinned: false,
type: categoryTab.value == 1
? 0
: (categoryTab.value == 2 ? 1 : null),
includeReplies: includeReplies.value,
mediaOnly: mediaOnly.value,
queryTerm: queryTerm.value,
order: order.value,
orderDesc: orderDesc.value,
periodStart: periodStart.value,
periodEnd: periodEnd.value,
query: queryState.value,
queryKey: 'publisher-$name',
),
SliverGap(MediaQuery.of(context).padding.bottom + 16),
],
@@ -704,37 +649,22 @@ class PublisherProfileScreen extends HookConsumerWidget {
),
...[
if (isPinnedExpanded.value)
SliverPostList(pubName: name, pinned: true),
SliverPostList(
query: PostListQuery(pubName: name, pinned: true),
queryKey: 'publisher-$name-pinned',
),
],
SliverToBoxAdapter(
child: _PublisherCategoryTabWidget(
child: PostFilterWidget(
categoryTabController: categoryTabController,
includeReplies: includeReplies,
mediaOnly: mediaOnly,
queryTerm: queryTerm,
order: order,
orderDesc: orderDesc,
periodStart: periodStart,
periodEnd: periodEnd,
showAdvancedFilters: showAdvancedFilters,
initialQuery: queryState.value,
onQueryChanged: (newQuery) => queryState.value = newQuery,
),
),
SliverPostList(
key: ValueKey(
'${categoryTab.value}-${includeReplies.value}-${mediaOnly.value}-${queryTerm.value}-${order.value}-${orderDesc.value}-${periodStart.value}-${periodEnd.value}',
),
pubName: name,
pinned: false,
type: categoryTab.value == 1
? 0
: (categoryTab.value == 2 ? 1 : null),
includeReplies: includeReplies.value,
mediaOnly: mediaOnly.value,
queryTerm: queryTerm.value,
order: order.value,
orderDesc: orderDesc.value,
periodStart: periodStart.value,
periodEnd: periodEnd.value,
key: ValueKey(queryState.value),
query: queryState.value,
queryKey: 'publisher-$name',
),
SliverGap(MediaQuery.of(context).padding.bottom + 16),
],

View File

@@ -1,5 +1,6 @@
import 'package:dio/dio.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:island/pods/post/post_list.dart';
import 'package:island/screens/chat/chat.dart';
import 'package:flutter/material.dart';
import 'package:island/models/chat.dart';
@@ -171,11 +172,9 @@ class RealmDetailScreen extends HookConsumerWidget {
return AppScaffold(
isNoBackground: false,
appBar:
isWideScreen(context)
appBar: isWideScreen(context)
? realmState.when(
data:
(realm) => AppBar(
data: (realm) => AppBar(
foregroundColor: appbarColor.value,
leading: PageBackButton(
color: appbarColor.value,
@@ -184,14 +183,10 @@ class RealmDetailScreen extends HookConsumerWidget {
flexibleSpace: Stack(
children: [
Positioned.fill(
child:
realm!.background?.id != null
? CloudImageWidget(
fileId: realm.background!.id,
)
child: realm!.background?.id != null
? CloudImageWidget(fileId: realm.background!.id)
: Container(
color:
Theme.of(
color: Theme.of(
context,
).appBarTheme.backgroundColor,
),
@@ -202,9 +197,7 @@ class RealmDetailScreen extends HookConsumerWidget {
style: TextStyle(
color:
appbarColor.value ??
Theme.of(
context,
).appBarTheme.foregroundColor,
Theme.of(context).appBarTheme.foregroundColor,
shadows: [iconShadow],
),
),
@@ -219,16 +212,12 @@ class RealmDetailScreen extends HookConsumerWidget {
showModalBottomSheet(
isScrollControlled: true,
context: context,
builder:
(context) =>
builder: (context) =>
_RealmMemberListSheet(realmSlug: slug),
);
},
),
_RealmActionMenu(
realmSlug: slug,
iconShadow: iconShadow,
),
_RealmActionMenu(realmSlug: slug, iconShadow: iconShadow),
const Gap(8),
],
),
@@ -239,17 +228,19 @@ class RealmDetailScreen extends HookConsumerWidget {
body: realmState.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, _) => Center(child: Text('Error: $error')),
data:
(realm) =>
isWideScreen(context)
data: (realm) => isWideScreen(context)
? Row(
children: [
Flexible(
flex: 3,
child: CustomScrollView(
slivers: [
SliverPostList(realm: slug, pinned: true),
SliverPostList(realm: slug, pinned: false),
SliverPostList(
query: PostListQuery(realm: slug, pinned: true),
),
SliverPostList(
query: PostListQuery(realm: slug, pinned: false),
),
],
),
),
@@ -260,14 +251,11 @@ class RealmDetailScreen extends HookConsumerWidget {
realmIdentity.when(
loading: () => const SizedBox.shrink(),
error: (_, _) => const SizedBox.shrink(),
data:
(identity) => Column(
crossAxisAlignment:
CrossAxisAlignment.stretch,
data: (identity) => Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
realmDescriptionWidget(realm!),
if (identity == null &&
realm.isCommunity)
if (identity == null && realm.isCommunity)
realmActionWidget(realm)
else
const SizedBox.shrink(),
@@ -293,14 +281,10 @@ class RealmDetailScreen extends HookConsumerWidget {
flexibleSpace: Stack(
children: [
Positioned.fill(
child:
realm!.background?.id != null
? CloudImageWidget(
fileId: realm.background!.id,
)
child: realm!.background?.id != null
? CloudImageWidget(fileId: realm.background!.id)
: Container(
color:
Theme.of(
color: Theme.of(
context,
).appBarTheme.backgroundColor,
),
@@ -311,9 +295,7 @@ class RealmDetailScreen extends HookConsumerWidget {
style: TextStyle(
color:
appbarColor.value ??
Theme.of(
context,
).appBarTheme.foregroundColor,
Theme.of(context).appBarTheme.foregroundColor,
shadows: [iconShadow],
),
),
@@ -329,17 +311,12 @@ class RealmDetailScreen extends HookConsumerWidget {
showModalBottomSheet(
isScrollControlled: true,
context: context,
builder:
(context) => _RealmMemberListSheet(
realmSlug: slug,
),
builder: (context) =>
_RealmMemberListSheet(realmSlug: slug),
);
},
),
_RealmActionMenu(
realmSlug: slug,
iconShadow: iconShadow,
),
_RealmActionMenu(realmSlug: slug, iconShadow: iconShadow),
const Gap(8),
],
),
@@ -348,10 +325,8 @@ class RealmDetailScreen extends HookConsumerWidget {
child: realmIdentity.when(
loading: () => const SizedBox.shrink(),
error: (_, _) => const SizedBox.shrink(),
data:
(identity) => Column(
crossAxisAlignment:
CrossAxisAlignment.stretch,
data: (identity) => Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
realmDescriptionWidget(realm),
if (identity == null && realm.isCommunity)
@@ -362,11 +337,13 @@ class RealmDetailScreen extends HookConsumerWidget {
),
),
),
SliverToBoxAdapter(
child: realmChatRoomListWidget(realm),
SliverToBoxAdapter(child: realmChatRoomListWidget(realm)),
SliverPostList(
query: PostListQuery(realm: slug, pinned: true),
),
SliverPostList(
query: PostListQuery(realm: slug, pinned: false),
),
SliverPostList(realm: slug, pinned: true),
SliverPostList(realm: slug, pinned: false),
],
),
),
@@ -391,8 +368,7 @@ class _RealmActionMenu extends HookConsumerWidget {
return PopupMenuButton(
icon: Icon(Icons.more_vert, shadows: [iconShadow]),
itemBuilder:
(context) => [
itemBuilder: (context) => [
if (isModerator)
PopupMenuItem(
onTap: () {
@@ -413,9 +389,7 @@ class _RealmActionMenu extends HookConsumerWidget {
),
),
realmIdentity.when(
data:
(identity) =>
(identity?.role ?? 0) >= 100
data: (identity) => (identity?.role ?? 0) >= 100
? PopupMenuItem(
child: Row(
children: [
@@ -478,13 +452,11 @@ class _RealmActionMenu extends HookConsumerWidget {
});
},
),
loading:
() => const PopupMenuItem(
loading: () => const PopupMenuItem(
enabled: false,
child: Center(child: CircularProgressIndicator()),
),
error:
(_, _) => PopupMenuItem(
error: (_, _) => PopupMenuItem(
child: Row(
children: [
Icon(
@@ -494,22 +466,17 @@ class _RealmActionMenu extends HookConsumerWidget {
const Gap(12),
Text(
'leaveRealm',
style: TextStyle(
color: Theme.of(context).colorScheme.error,
),
style: TextStyle(color: Theme.of(context).colorScheme.error),
).tr(),
],
),
onTap: () {
showConfirmAlert(
'leaveRealmHint'.tr(),
'leaveRealm'.tr(),
).then((confirm) async {
showConfirmAlert('leaveRealmHint'.tr(), 'leaveRealm'.tr()).then((
confirm,
) async {
if (confirm) {
final client = ref.watch(apiClientProvider);
await client.delete(
'/pass/realms/$realmSlug/members/me',
);
await client.delete('/pass/realms/$realmSlug/members/me');
ref.invalidate(realmsJoinedProvider);
if (context.mounted) {
context.pop(true);
@@ -684,8 +651,7 @@ class _RealmMemberListSheet extends HookConsumerWidget {
showModalBottomSheet(
isScrollControlled: true,
context: context,
builder:
(context) => _RealmMemberRoleSheet(
builder: (context) => _RealmMemberRoleSheet(
realmSlug: realmSlug,
member: member,
),
@@ -809,12 +775,8 @@ class _RealmMemberRoleSheet extends HookConsumerWidget {
onSelected: (int selection) {
roleController.text = selection.toString();
},
fieldViewBuilder: (
context,
controller,
focusNode,
onFieldSubmitted,
) {
fieldViewBuilder:
(context, controller, focusNode, onFieldSubmitted) {
return TextField(
controller: controller,
focusNode: focusNode,

View File

@@ -39,7 +39,7 @@ class PaginationList<T> extends HookConsumerWidget {
final data = ref.watch(provider);
final noti = ref.watch(notifier);
if (data.isLoading && data.value?.isEmpty == true) {
if ((data.isLoading || noti.isLoading) && data.value?.isEmpty == true) {
final content = ResponseLoadingWidget();
return isSliver ? SliverFillRemaining(child: content) : content;
}
@@ -115,7 +115,7 @@ class PaginationWidget<T> extends HookConsumerWidget {
final data = ref.watch(provider);
final noti = ref.watch(notifier);
if (data.isLoading && data.value?.isEmpty == true) {
if ((data.isLoading || noti.isLoading) && data.value?.isEmpty == true) {
final content = ResponseLoadingWidget();
return isSliver ? SliverFillRemaining(child: content) : content;
}

View File

@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/post.dart';
import 'package:island/pods/post/post_list.dart';
@@ -17,21 +18,7 @@ enum PostItemType {
}
class SliverPostList extends HookConsumerWidget {
final String? pubName;
final String? realm;
final int? type;
final List<String>? categories;
final List<String>? tags;
final bool shuffle;
final bool? pinned;
final bool? includeReplies;
final bool? mediaOnly;
final String? queryTerm;
// Can be "populaurity", other value will be treated as "date"
final String? order;
final int? periodStart;
final int? periodEnd;
final bool? orderDesc;
final PostListQuery? query;
final PostItemType itemType;
final Color? backgroundColor;
final EdgeInsets? padding;
@@ -39,23 +26,11 @@ class SliverPostList extends HookConsumerWidget {
final Function? onRefresh;
final Function(SnPost)? onUpdate;
final double? maxWidth;
final String? queryKey;
const SliverPostList({
super.key,
this.pubName,
this.realm,
this.type,
this.categories,
this.tags,
this.shuffle = false,
this.pinned,
this.includeReplies,
this.mediaOnly,
this.queryTerm,
this.order,
this.orderDesc = true,
this.periodStart,
this.periodEnd,
this.query,
this.itemType = PostItemType.regular,
this.backgroundColor,
this.padding,
@@ -63,29 +38,19 @@ class SliverPostList extends HookConsumerWidget {
this.onRefresh,
this.onUpdate,
this.maxWidth,
this.queryKey,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final params = PostListQuery(
pubName: pubName,
realm: realm,
type: type,
categories: categories,
tags: tags,
shuffle: shuffle,
pinned: pinned,
includeReplies: includeReplies,
mediaOnly: mediaOnly,
queryTerm: queryTerm,
order: order,
periodStart: periodStart,
periodEnd: periodEnd,
orderDesc: orderDesc ?? true,
);
final provider = postListNotifierProvider(params);
final provider = postListProvider(queryKey);
final notifier = provider.notifier;
useEffect(() {
ref.read(notifier).applyFilter(query!);
return null;
}, [query]);
return PaginationList(
provider: provider,
notifier: notifier,

View File

@@ -3,26 +3,29 @@ import 'package:flutter/material.dart';
import 'package:flutter_card_swiper/flutter_card_swiper.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/pods/post/post_list.dart';
import 'package:island/widgets/app_scaffold.dart';
import 'package:island/widgets/post/post_item.dart';
import 'package:island/widgets/post/post_list.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:styled_widget/styled_widget.dart';
const kShufflePostListId = 'shuffle';
class PostShuffleScreen extends HookConsumerWidget {
const PostShuffleScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
const params = PostListQuery(shuffle: true);
final postListState = ref.watch(postListNotifierProvider(params));
const query = PostListQuery(shuffle: true);
final postListState = ref.watch(postListProvider(kShufflePostListId));
final postListNotifier = ref.watch(
postListNotifierProvider(params).notifier,
postListProvider(kShufflePostListId).notifier,
);
final cardSwiperController = useMemoized(() => CardSwiperController(), []);
useEffect(() {
postListNotifier.applyFilter(query);
return cardSwiperController.dispose;
}, []);
@@ -46,7 +49,8 @@ class PostShuffleScreen extends HookConsumerWidget {
controller: cardSwiperController,
cardsCount: items.length,
isLoop: false,
cardBuilder: (
cardBuilder:
(
context,
index,
horizontalOffsetPercentage,
@@ -62,7 +66,9 @@ class PostShuffleScreen extends HookConsumerWidget {
borderRadius: const BorderRadius.all(
Radius.circular(8),
),
child: PostActionableItem(item: items[index]),
child: PostActionableItem(
item: items[index],
),
),
),
),
@@ -91,8 +97,7 @@ class PostShuffleScreen extends HookConsumerWidget {
bottom: MediaQuery.of(context).padding.bottom,
),
height: kBottomControlHeight,
child:
Row(
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
IconButton(

View File

@@ -1,34 +1,89 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:island/pods/post/post_list.dart';
import 'package:material_symbols_icons/symbols.dart';
class PostFilterWidget extends StatelessWidget {
class PostFilterWidget extends StatefulWidget {
final TabController categoryTabController;
final ValueNotifier<bool?> includeReplies;
final ValueNotifier<bool> mediaOnly;
final ValueNotifier<String?> queryTerm;
final ValueNotifier<String?> order;
final ValueNotifier<bool> orderDesc;
final ValueNotifier<int?> periodStart;
final ValueNotifier<int?> periodEnd;
final ValueNotifier<bool> showAdvancedFilters;
final PostListQuery initialQuery;
final ValueChanged<PostListQuery> onQueryChanged;
final bool hideSearch;
const PostFilterWidget({
super.key,
required this.categoryTabController,
required this.includeReplies,
required this.mediaOnly,
required this.queryTerm,
required this.order,
required this.orderDesc,
required this.periodStart,
required this.periodEnd,
required this.showAdvancedFilters,
required this.initialQuery,
required this.onQueryChanged,
this.hideSearch = false,
});
@override
State<PostFilterWidget> createState() => _PostFilterWidgetState();
}
class _PostFilterWidgetState extends State<PostFilterWidget> {
late bool? _includeReplies;
late bool _mediaOnly;
late String? _queryTerm;
late String? _order;
late bool _orderDesc;
late int? _periodStart;
late int? _periodEnd;
late int? _type;
late bool _showAdvancedFilters;
late TextEditingController _searchController;
@override
void initState() {
super.initState();
_includeReplies = widget.initialQuery.includeReplies;
_mediaOnly = widget.initialQuery.mediaOnly ?? false;
_queryTerm = widget.initialQuery.queryTerm;
_order = widget.initialQuery.order;
_orderDesc = widget.initialQuery.orderDesc;
_periodStart = widget.initialQuery.periodStart;
_periodEnd = widget.initialQuery.periodEnd;
_type = widget.initialQuery.type;
_showAdvancedFilters = false;
_searchController = TextEditingController(text: _queryTerm);
widget.categoryTabController.addListener(_onTabChanged);
}
@override
void dispose() {
widget.categoryTabController.removeListener(_onTabChanged);
_searchController.dispose();
super.dispose();
}
void _onTabChanged() {
final tabIndex = widget.categoryTabController.index;
setState(() {
_type = switch (tabIndex) {
1 => 0,
2 => 1,
_ => null,
};
});
_updateQuery();
}
void _updateQuery() {
final newQuery = widget.initialQuery.copyWith(
includeReplies: _includeReplies,
mediaOnly: _mediaOnly,
queryTerm: _queryTerm,
order: _order,
periodStart: _periodStart,
periodEnd: _periodEnd,
orderDesc: _orderDesc,
type: _type,
);
widget.onQueryChanged(newQuery);
}
@override
Widget build(BuildContext context) {
return Card(
@@ -36,7 +91,7 @@ class PostFilterWidget extends StatelessWidget {
child: Column(
children: [
TabBar(
controller: categoryTabController,
controller: widget.categoryTabController,
dividerColor: Colors.transparent,
splashBorderRadius: const BorderRadius.all(Radius.circular(8)),
tabs: [
@@ -53,17 +108,20 @@ class PostFilterWidget extends StatelessWidget {
Expanded(
child: CheckboxListTile(
title: Text('reply'.tr()),
value: includeReplies.value,
value: _includeReplies,
tristate: true,
onChanged: (value) {
// Cycle through: null -> false -> true -> null
if (includeReplies.value == null) {
includeReplies.value = false;
} else if (includeReplies.value == false) {
includeReplies.value = true;
setState(() {
if (_includeReplies == null) {
_includeReplies = false;
} else if (_includeReplies == false) {
_includeReplies = true;
} else {
includeReplies.value = null;
_includeReplies = null;
}
});
_updateQuery();
},
dense: true,
controlAffinity: ListTileControlAffinity.leading,
@@ -73,11 +131,14 @@ class PostFilterWidget extends StatelessWidget {
Expanded(
child: CheckboxListTile(
title: Text('attachments'.tr()),
value: mediaOnly.value,
value: _mediaOnly,
onChanged: (value) {
setState(() {
if (value != null) {
mediaOnly.value = value;
_mediaOnly = value;
}
});
_updateQuery();
},
dense: true,
controlAffinity: ListTileControlAffinity.leading,
@@ -88,11 +149,14 @@ class PostFilterWidget extends StatelessWidget {
),
CheckboxListTile(
title: Text('descendingOrder'.tr()),
value: orderDesc.value,
value: _orderDesc,
onChanged: (value) {
setState(() {
if (value != null) {
orderDesc.value = value;
_orderDesc = value;
}
});
_updateQuery();
},
dense: true,
controlAffinity: ListTileControlAffinity.leading,
@@ -109,23 +173,24 @@ class PostFilterWidget extends StatelessWidget {
borderRadius: BorderRadius.all(const Radius.circular(8)),
),
trailing: Icon(
showAdvancedFilters.value
? Symbols.expand_less
: Symbols.expand_more,
_showAdvancedFilters ? Symbols.expand_less : Symbols.expand_more,
),
onTap: () {
showAdvancedFilters.value = !showAdvancedFilters.value;
setState(() {
_showAdvancedFilters = !_showAdvancedFilters;
});
},
),
if (showAdvancedFilters.value) ...[
if (_showAdvancedFilters) ...[
const Divider(height: 1),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (!hideSearch)
if (!widget.hideSearch)
TextField(
controller: _searchController,
decoration: InputDecoration(
labelText: 'search'.tr(),
hintText: 'searchPosts'.tr(),
@@ -139,10 +204,13 @@ class PostFilterWidget extends StatelessWidget {
),
),
onChanged: (value) {
queryTerm.value = value.isEmpty ? null : value;
setState(() {
_queryTerm = value.isEmpty ? null : value;
});
_updateQuery();
},
),
if (!hideSearch) const Gap(12),
if (!widget.hideSearch) const Gap(12),
DropdownButtonFormField<String>(
decoration: InputDecoration(
labelText: 'sortBy'.tr(),
@@ -154,7 +222,7 @@ class PostFilterWidget extends StatelessWidget {
vertical: 8,
),
),
value: order.value,
value: _order,
items: [
DropdownMenuItem(value: 'date', child: Text('date'.tr())),
DropdownMenuItem(
@@ -163,7 +231,10 @@ class PostFilterWidget extends StatelessWidget {
),
],
onChanged: (value) {
order.value = value;
setState(() {
_order = value;
});
_updateQuery();
},
),
const Gap(12),
@@ -174,9 +245,9 @@ class PostFilterWidget extends StatelessWidget {
onTap: () async {
final pickedDate = await showDatePicker(
context: context,
initialDate: periodStart.value != null
initialDate: _periodStart != null
? DateTime.fromMillisecondsSinceEpoch(
periodStart.value! * 1000,
_periodStart! * 1000,
)
: DateTime.now(),
firstDate: DateTime(2000),
@@ -185,8 +256,11 @@ class PostFilterWidget extends StatelessWidget {
),
);
if (pickedDate != null) {
periodStart.value =
setState(() {
_periodStart =
pickedDate.millisecondsSinceEpoch ~/ 1000;
});
_updateQuery();
}
},
child: InputDecorator(
@@ -204,9 +278,9 @@ class PostFilterWidget extends StatelessWidget {
suffixIcon: const Icon(Symbols.calendar_today),
),
child: Text(
periodStart.value != null
_periodStart != null
? DateTime.fromMillisecondsSinceEpoch(
periodStart.value! * 1000,
_periodStart! * 1000,
).toString().split(' ')[0]
: 'selectDate'.tr(),
),
@@ -219,9 +293,9 @@ class PostFilterWidget extends StatelessWidget {
onTap: () async {
final pickedDate = await showDatePicker(
context: context,
initialDate: periodEnd.value != null
initialDate: _periodEnd != null
? DateTime.fromMillisecondsSinceEpoch(
periodEnd.value! * 1000,
_periodEnd! * 1000,
)
: DateTime.now(),
firstDate: DateTime(2000),
@@ -230,8 +304,11 @@ class PostFilterWidget extends StatelessWidget {
),
);
if (pickedDate != null) {
periodEnd.value =
setState(() {
_periodEnd =
pickedDate.millisecondsSinceEpoch ~/ 1000;
});
_updateQuery();
}
},
child: InputDecorator(
@@ -249,9 +326,9 @@ class PostFilterWidget extends StatelessWidget {
suffixIcon: const Icon(Symbols.calendar_today),
),
child: Text(
periodEnd.value != null
_periodEnd != null
? DateTime.fromMillisecondsSinceEpoch(
periodEnd.value! * 1000,
_periodEnd! * 1000,
).toString().split(' ')[0]
: 'selectDate'.tr(),
),