♻️ 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

@@ -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,17 +83,17 @@ 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
? postCategory?.value?.categoryDisplayTitle ?? 'loading'
: postTag?.value?.name ?? postTag?.value?.slug ?? 'loading';
final postFilterTitle = isCategory
? postCategory?.value?.categoryDisplayTitle ?? 'loading'
: postTag?.value?.name ?? postTag?.value?.slug ?? 'loading';
return AppScaffold(
isNoBackground: false,
@@ -108,63 +109,50 @@ class PostCategoryDetailScreen extends HookConsumerWidget {
child: Card(
margin: EdgeInsets.only(top: 8),
child: postCategory!.when(
data:
(category) => Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
category.categoryDisplayTitle,
).bold().fontSize(15),
Text('A category'),
const Gap(8),
subscriptionStatus.when(
data:
(isSubscribed) =>
isSubscribed
? FilledButton.icon(
onPressed: () async {
await _unsubscribeFromCategoryOrTag(
ref,
slug: slug,
isCategory: isCategory,
);
},
icon: const Icon(
Symbols.remove_circle,
),
label: Text('unsubscribe'.tr()),
)
: FilledButton.icon(
onPressed: () async {
await _subscribeToCategoryOrTag(
ref,
slug: slug,
isCategory: isCategory,
);
},
icon: const Icon(
Symbols.add_circle,
),
label: Text('subscribe'.tr()),
),
error:
(error, _) => Text(
'Error loading subscription status',
),
loading:
() =>
CircularProgressIndicator().center(),
),
],
).padding(horizontal: 24, vertical: 16),
error:
(error, _) => ResponseErrorWidget(
error: error,
onRetry:
() => ref.invalidate(
postCategoryProvider(slug),
),
data: (category) => Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
category.categoryDisplayTitle,
).bold().fontSize(15),
Text('A category'),
const Gap(8),
subscriptionStatus.when(
data: (isSubscribed) => isSubscribed
? FilledButton.icon(
onPressed: () async {
await _unsubscribeFromCategoryOrTag(
ref,
slug: slug,
isCategory: isCategory,
);
},
icon: const Icon(Symbols.remove_circle),
label: Text('unsubscribe'.tr()),
)
: FilledButton.icon(
onPressed: () async {
await _subscribeToCategoryOrTag(
ref,
slug: slug,
isCategory: isCategory,
);
},
icon: const Icon(Symbols.add_circle),
label: Text('subscribe'.tr()),
),
error: (error, _) =>
Text('Error loading subscription status'),
loading: () =>
CircularProgressIndicator().center(),
),
],
).padding(horizontal: 24, vertical: 16),
error: (error, _) => ResponseErrorWidget(
error: error,
onRetry: () =>
ref.invalidate(postCategoryProvider(slug)),
),
loading: () => ResponseLoadingWidget(),
),
),
@@ -179,61 +167,49 @@ class PostCategoryDetailScreen extends HookConsumerWidget {
child: Card(
margin: EdgeInsets.only(top: 8),
child: postTag!.when(
data:
(tag) => Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
tag.name ?? '#${tag.slug}',
).bold().fontSize(15),
Text('A tag'),
const Gap(8),
subscriptionStatus.when(
data:
(isSubscribed) =>
isSubscribed
? FilledButton.icon(
onPressed: () async {
await _unsubscribeFromCategoryOrTag(
ref,
slug: slug,
isCategory: isCategory,
);
},
icon: const Icon(
Symbols.remove_circle,
),
label: Text('unsubscribe'.tr()),
)
: FilledButton.icon(
onPressed: () async {
await _subscribeToCategoryOrTag(
ref,
slug: slug,
isCategory: isCategory,
);
},
icon: const Icon(
Symbols.add_circle,
),
label: Text('subscribe'.tr()),
),
error:
(error, _) => Text(
'Error loading subscription status',
),
loading:
() =>
CircularProgressIndicator().center(),
),
],
).padding(horizontal: 24, vertical: 16),
error:
(error, _) => ResponseErrorWidget(
error: error,
onRetry:
() => ref.invalidate(postTagProvider(slug)),
data: (tag) => Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
tag.name ?? '#${tag.slug}',
).bold().fontSize(15),
Text('A tag'),
const Gap(8),
subscriptionStatus.when(
data: (isSubscribed) => isSubscribed
? FilledButton.icon(
onPressed: () async {
await _unsubscribeFromCategoryOrTag(
ref,
slug: slug,
isCategory: isCategory,
);
},
icon: const Icon(Symbols.remove_circle),
label: Text('unsubscribe'.tr()),
)
: FilledButton.icon(
onPressed: () async {
await _subscribeToCategoryOrTag(
ref,
slug: slug,
isCategory: isCategory,
);
},
icon: const Icon(Symbols.add_circle),
label: Text('subscribe'.tr()),
),
error: (error, _) =>
Text('Error loading subscription status'),
loading: () =>
CircularProgressIndicator().center(),
),
],
).padding(horizontal: 24, vertical: 16),
error: (error, _) => ResponseErrorWidget(
error: error,
onRetry: () => ref.invalidate(postTagProvider(slug)),
),
loading: () => ResponseLoadingWidget(),
),
),
@@ -242,8 +218,11 @@ class PostCategoryDetailScreen extends HookConsumerWidget {
),
const SliverGap(4),
SliverPostList(
categories: isCategory ? [slug] : null,
tags: isCategory ? null : [slug],
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,62 +116,76 @@ 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: CustomScrollView(
slivers: [
SliverGap(16),
SliverToBoxAdapter(
child: SearchBar(
elevation: WidgetStateProperty.all(4),
controller: searchController,
hintText: 'search'.tr(),
leading: const Icon(Icons.search),
padding: WidgetStateProperty.all(
const EdgeInsets.symmetric(horizontal: 24),
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,
hintText: 'search'.tr(),
leading: const Icon(Icons.search),
padding: WidgetStateProperty.all(
const EdgeInsets.symmetric(horizontal: 24),
),
onChanged: onSearchChanged,
onSubmitted: (value) {
onSearchChanged(value, skipDebounce: true);
},
),
),
onChanged: onSearchChanged,
onSubmitted: (value) {
onSearchWithFilters(value);
),
const SliverGap(16),
if (showFilters.value && !isWideScreen(context))
SliverToBoxAdapter(child: buildFilterPanel()),
// Use PaginationList with isSliver=true
PaginationList(
provider: postListProvider(kSearchPostListId),
notifier: postListProvider(
kSearchPostListId,
).notifier,
isSliver: true,
isRefreshable: false,
itemBuilder: (context, index, post) {
return Card(
margin: EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
child: PostActionableItem(
item: post,
borderRadius: 8,
),
);
},
),
),
const SliverGap(16),
if (showFilters.value && !isWideScreen(context))
SliverToBoxAdapter(child: buildFilterPanel()),
// Use PaginationList with isSliver=true
PaginationList(
provider: postSearchProvider,
notifier: postSearchProvider.notifier,
isSliver: true,
isRefreshable: false,
itemBuilder: (context, index, post) {
return Card(
margin: EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
if (searchState.value?.isEmpty == true &&
searchController.text.isNotEmpty &&
!searchState.isLoading)
SliverFillRemaining(
child: Center(
child: Text('noResultsFound'.tr()),
),
child: PostActionableItem(
item: post,
borderRadius: 8,
),
);
},
),
if (searchState.value?.isEmpty == true &&
searchController.text.isNotEmpty &&
!searchState.isLoading)
SliverFillRemaining(
child: Center(child: Text('noResultsFound'.tr())),
),
SliverGap(
MediaQuery.of(context).padding.bottom + 16,
),
SliverGap(MediaQuery.of(context).padding.bottom + 16),
],
).padding(left: 8),
],
).padding(left: 8),
),
),
Flexible(
flex: 3,
@@ -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,204 +172,180 @@ class RealmDetailScreen extends HookConsumerWidget {
return AppScaffold(
isNoBackground: false,
appBar:
isWideScreen(context)
? realmState.when(
data:
(realm) => AppBar(
foregroundColor: appbarColor.value,
leading: PageBackButton(
color: appbarColor.value,
shadows: [iconShadow],
),
flexibleSpace: Stack(
children: [
Positioned.fill(
child:
realm!.background?.id != null
? CloudImageWidget(
fileId: realm.background!.id,
)
: Container(
color:
Theme.of(
context,
).appBarTheme.backgroundColor,
),
),
FlexibleSpaceBar(
title: Text(
realm.name,
style: TextStyle(
color:
appbarColor.value ??
Theme.of(
context,
).appBarTheme.foregroundColor,
shadows: [iconShadow],
),
appBar: isWideScreen(context)
? realmState.when(
data: (realm) => AppBar(
foregroundColor: appbarColor.value,
leading: PageBackButton(
color: appbarColor.value,
shadows: [iconShadow],
),
flexibleSpace: Stack(
children: [
Positioned.fill(
child: realm!.background?.id != null
? CloudImageWidget(fileId: realm.background!.id)
: Container(
color: Theme.of(
context,
).appBarTheme.backgroundColor,
),
background: Container(),
),
],
),
actions: [
IconButton(
icon: Icon(Icons.people, shadows: [iconShadow]),
onPressed: () {
showModalBottomSheet(
isScrollControlled: true,
context: context,
builder:
(context) =>
_RealmMemberListSheet(realmSlug: slug),
);
},
),
_RealmActionMenu(
realmSlug: slug,
iconShadow: iconShadow,
),
const Gap(8),
],
),
error: (_, _) => AppBar(leading: PageBackButton()),
loading: () => AppBar(leading: PageBackButton()),
)
: null,
FlexibleSpaceBar(
title: Text(
realm.name,
style: TextStyle(
color:
appbarColor.value ??
Theme.of(context).appBarTheme.foregroundColor,
shadows: [iconShadow],
),
),
background: Container(),
),
],
),
actions: [
IconButton(
icon: Icon(Icons.people, shadows: [iconShadow]),
onPressed: () {
showModalBottomSheet(
isScrollControlled: true,
context: context,
builder: (context) =>
_RealmMemberListSheet(realmSlug: slug),
);
},
),
_RealmActionMenu(realmSlug: slug, iconShadow: iconShadow),
const Gap(8),
],
),
error: (_, _) => AppBar(leading: PageBackButton()),
loading: () => AppBar(leading: PageBackButton()),
)
: null,
body: realmState.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, _) => Center(child: Text('Error: $error')),
data:
(realm) =>
isWideScreen(context)
? Row(
children: [
Flexible(
flex: 3,
child: CustomScrollView(
slivers: [
SliverPostList(realm: slug, pinned: true),
SliverPostList(realm: slug, pinned: false),
],
),
),
Flexible(
flex: 2,
child: Column(
children: [
realmIdentity.when(
loading: () => const SizedBox.shrink(),
error: (_, _) => const SizedBox.shrink(),
data:
(identity) => Column(
crossAxisAlignment:
CrossAxisAlignment.stretch,
children: [
realmDescriptionWidget(realm!),
if (identity == null &&
realm.isCommunity)
realmActionWidget(realm)
else
const SizedBox.shrink(),
],
),
),
realmChatRoomListWidget(realm!),
],
),
),
],
).padding(horizontal: 8, top: 8)
: CustomScrollView(
data: (realm) => isWideScreen(context)
? Row(
children: [
Flexible(
flex: 3,
child: CustomScrollView(
slivers: [
SliverAppBar(
expandedHeight: 180,
pinned: true,
foregroundColor: appbarColor.value,
leading: PageBackButton(
color: appbarColor.value,
shadows: [iconShadow],
),
flexibleSpace: Stack(
children: [
Positioned.fill(
child:
realm!.background?.id != null
? CloudImageWidget(
fileId: realm.background!.id,
)
: Container(
color:
Theme.of(
context,
).appBarTheme.backgroundColor,
),
),
FlexibleSpaceBar(
title: Text(
realm.name,
style: TextStyle(
color:
appbarColor.value ??
Theme.of(
context,
).appBarTheme.foregroundColor,
shadows: [iconShadow],
),
),
background:
Container(), // Empty container since background is handled by Stack
),
],
),
actions: [
IconButton(
icon: Icon(Icons.people, shadows: [iconShadow]),
onPressed: () {
showModalBottomSheet(
isScrollControlled: true,
context: context,
builder:
(context) => _RealmMemberListSheet(
realmSlug: slug,
),
);
},
),
_RealmActionMenu(
realmSlug: slug,
iconShadow: iconShadow,
),
const Gap(8),
],
SliverPostList(
query: PostListQuery(realm: slug, pinned: true),
),
SliverGap(4),
SliverToBoxAdapter(
child: realmIdentity.when(
loading: () => const SizedBox.shrink(),
error: (_, _) => const SizedBox.shrink(),
data:
(identity) => Column(
crossAxisAlignment:
CrossAxisAlignment.stretch,
children: [
realmDescriptionWidget(realm),
if (identity == null && realm.isCommunity)
realmActionWidget(realm)
else
const SizedBox.shrink(),
],
),
),
SliverPostList(
query: PostListQuery(realm: slug, pinned: false),
),
SliverToBoxAdapter(
child: realmChatRoomListWidget(realm),
),
SliverPostList(realm: slug, pinned: true),
SliverPostList(realm: slug, pinned: false),
],
),
),
Flexible(
flex: 2,
child: Column(
children: [
realmIdentity.when(
loading: () => const SizedBox.shrink(),
error: (_, _) => const SizedBox.shrink(),
data: (identity) => Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
realmDescriptionWidget(realm!),
if (identity == null && realm.isCommunity)
realmActionWidget(realm)
else
const SizedBox.shrink(),
],
),
),
realmChatRoomListWidget(realm!),
],
),
),
],
).padding(horizontal: 8, top: 8)
: CustomScrollView(
slivers: [
SliverAppBar(
expandedHeight: 180,
pinned: true,
foregroundColor: appbarColor.value,
leading: PageBackButton(
color: appbarColor.value,
shadows: [iconShadow],
),
flexibleSpace: Stack(
children: [
Positioned.fill(
child: realm!.background?.id != null
? CloudImageWidget(fileId: realm.background!.id)
: Container(
color: Theme.of(
context,
).appBarTheme.backgroundColor,
),
),
FlexibleSpaceBar(
title: Text(
realm.name,
style: TextStyle(
color:
appbarColor.value ??
Theme.of(context).appBarTheme.foregroundColor,
shadows: [iconShadow],
),
),
background:
Container(), // Empty container since background is handled by Stack
),
],
),
actions: [
IconButton(
icon: Icon(Icons.people, shadows: [iconShadow]),
onPressed: () {
showModalBottomSheet(
isScrollControlled: true,
context: context,
builder: (context) =>
_RealmMemberListSheet(realmSlug: slug),
);
},
),
_RealmActionMenu(realmSlug: slug, iconShadow: iconShadow),
const Gap(8),
],
),
SliverGap(4),
SliverToBoxAdapter(
child: realmIdentity.when(
loading: () => const SizedBox.shrink(),
error: (_, _) => const SizedBox.shrink(),
data: (identity) => Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
realmDescriptionWidget(realm),
if (identity == null && realm.isCommunity)
realmActionWidget(realm)
else
const SizedBox.shrink(),
],
),
),
),
SliverToBoxAdapter(child: realmChatRoomListWidget(realm)),
SliverPostList(
query: PostListQuery(realm: slug, pinned: true),
),
SliverPostList(
query: PostListQuery(realm: slug, pinned: false),
),
],
),
),
);
}
@@ -391,135 +368,125 @@ class _RealmActionMenu extends HookConsumerWidget {
return PopupMenuButton(
icon: Icon(Icons.more_vert, shadows: [iconShadow]),
itemBuilder:
(context) => [
if (isModerator)
PopupMenuItem(
onTap: () {
context.pushReplacementNamed(
'realmEdit',
pathParameters: {'slug': realmSlug},
);
},
child: Row(
children: [
Icon(
Icons.edit,
color: Theme.of(context).colorScheme.onSecondaryContainer,
),
const Gap(12),
const Text('editRealm').tr(),
],
itemBuilder: (context) => [
if (isModerator)
PopupMenuItem(
onTap: () {
context.pushReplacementNamed(
'realmEdit',
pathParameters: {'slug': realmSlug},
);
},
child: Row(
children: [
Icon(
Icons.edit,
color: Theme.of(context).colorScheme.onSecondaryContainer,
),
),
realmIdentity.when(
data:
(identity) =>
(identity?.role ?? 0) >= 100
? PopupMenuItem(
child: Row(
children: [
const Icon(Icons.delete, color: Colors.red),
const Gap(12),
const Text(
'deleteRealm',
style: TextStyle(color: Colors.red),
).tr(),
],
),
onTap: () {
showConfirmAlert(
'deleteRealmHint'.tr(),
'deleteRealm'.tr(),
isDanger: true,
).then((confirm) {
if (confirm) {
final client = ref.watch(apiClientProvider);
client.delete('/pass/realms/$realmSlug');
ref.invalidate(realmsJoinedProvider);
if (context.mounted) {
context.pop(true);
}
}
});
},
)
: PopupMenuItem(
child: Row(
children: [
Icon(
Icons.exit_to_app,
color: Theme.of(context).colorScheme.error,
),
const Gap(12),
Text(
'leaveRealm',
style: TextStyle(
color: Theme.of(context).colorScheme.error,
),
).tr(),
],
),
onTap: () {
showConfirmAlert(
'leaveRealmHint'.tr(),
'leaveRealm'.tr(),
).then((confirm) async {
if (confirm) {
final client = ref.watch(apiClientProvider);
await client.delete(
'/pass/realms/$realmSlug/members/me',
);
ref.invalidate(realmsJoinedProvider);
if (context.mounted) {
context.pop(true);
}
}
});
},
),
loading:
() => const PopupMenuItem(
enabled: false,
child: Center(child: CircularProgressIndicator()),
const Gap(12),
const Text('editRealm').tr(),
],
),
),
realmIdentity.when(
data: (identity) => (identity?.role ?? 0) >= 100
? PopupMenuItem(
child: Row(
children: [
const Icon(Icons.delete, color: Colors.red),
const Gap(12),
const Text(
'deleteRealm',
style: TextStyle(color: Colors.red),
).tr(),
],
),
error:
(_, _) => PopupMenuItem(
child: Row(
children: [
Icon(
Icons.exit_to_app,
onTap: () {
showConfirmAlert(
'deleteRealmHint'.tr(),
'deleteRealm'.tr(),
isDanger: true,
).then((confirm) {
if (confirm) {
final client = ref.watch(apiClientProvider);
client.delete('/pass/realms/$realmSlug');
ref.invalidate(realmsJoinedProvider);
if (context.mounted) {
context.pop(true);
}
}
});
},
)
: PopupMenuItem(
child: Row(
children: [
Icon(
Icons.exit_to_app,
color: Theme.of(context).colorScheme.error,
),
const Gap(12),
Text(
'leaveRealm',
style: TextStyle(
color: Theme.of(context).colorScheme.error,
),
const Gap(12),
Text(
'leaveRealm',
style: TextStyle(
color: Theme.of(context).colorScheme.error,
),
).tr(),
],
),
onTap: () {
showConfirmAlert(
'leaveRealmHint'.tr(),
'leaveRealm'.tr(),
).then((confirm) async {
if (confirm) {
final client = ref.watch(apiClientProvider);
await client.delete(
'/pass/realms/$realmSlug/members/me',
);
ref.invalidate(realmsJoinedProvider);
if (context.mounted) {
context.pop(true);
}
}
});
},
).tr(),
],
),
onTap: () {
showConfirmAlert(
'leaveRealmHint'.tr(),
'leaveRealm'.tr(),
).then((confirm) async {
if (confirm) {
final client = ref.watch(apiClientProvider);
await client.delete(
'/pass/realms/$realmSlug/members/me',
);
ref.invalidate(realmsJoinedProvider);
if (context.mounted) {
context.pop(true);
}
}
});
},
),
loading: () => const PopupMenuItem(
enabled: false,
child: Center(child: CircularProgressIndicator()),
),
error: (_, _) => PopupMenuItem(
child: Row(
children: [
Icon(
Icons.exit_to_app,
color: Theme.of(context).colorScheme.error,
),
const Gap(12),
Text(
'leaveRealm',
style: TextStyle(color: Theme.of(context).colorScheme.error),
).tr(),
],
),
],
onTap: () {
showConfirmAlert('leaveRealmHint'.tr(), 'leaveRealm'.tr()).then((
confirm,
) async {
if (confirm) {
final client = ref.watch(apiClientProvider);
await client.delete('/pass/realms/$realmSlug/members/me');
ref.invalidate(realmsJoinedProvider);
if (context.mounted) {
context.pop(true);
}
}
});
},
),
),
],
);
}
}
@@ -684,11 +651,10 @@ class _RealmMemberListSheet extends HookConsumerWidget {
showModalBottomSheet(
isScrollControlled: true,
context: context,
builder:
(context) => _RealmMemberRoleSheet(
realmSlug: realmSlug,
member: member,
),
builder: (context) => _RealmMemberRoleSheet(
realmSlug: realmSlug,
member: member,
),
).then((value) {
if (value != null) {
// Refresh the provider
@@ -809,23 +775,19 @@ class _RealmMemberRoleSheet extends HookConsumerWidget {
onSelected: (int selection) {
roleController.text = selection.toString();
},
fieldViewBuilder: (
context,
controller,
focusNode,
onFieldSubmitted,
) {
return TextField(
controller: controller,
focusNode: focusNode,
keyboardType: TextInputType.number,
decoration: InputDecoration(
labelText: 'memberRole'.tr(),
helperText: 'memberRoleHint'.tr(),
),
onTapOutside: (event) => focusNode.unfocus(),
);
},
fieldViewBuilder:
(context, controller, focusNode, onFieldSubmitted) {
return TextField(
controller: controller,
focusNode: focusNode,
keyboardType: TextInputType.number,
decoration: InputDecoration(
labelText: 'memberRole'.tr(),
helperText: 'memberRoleHint'.tr(),
),
onTapOutside: (event) => focusNode.unfocus(),
);
},
),
const Gap(16),
FilledButton.icon(