Feed discover and subscription

This commit is contained in:
2025-08-24 13:55:06 +08:00
parent d7dcde898c
commit 4fdc8eb1d0
5 changed files with 364 additions and 239 deletions

View File

@@ -907,5 +907,12 @@
"copyKeyHint": "Please copy this key and store it somewhere safe. You will not be able to see it again.", "copyKeyHint": "Please copy this key and store it somewhere safe. You will not be able to see it again.",
"rotateKey": "Rotate Key", "rotateKey": "Rotate Key",
"rotateBotKey": "Rotate Bot Key", "rotateBotKey": "Rotate Bot Key",
"rotateBotKeyHint": "Are you sure you want to rotate this key? The old key will become invalid immediately. This action cannot be undone." "rotateBotKeyHint": "Are you sure you want to rotate this key? The old key will become invalid immediately. This action cannot be undone.",
"webFeedArticleCount": {
"zero": "No articles",
"one": "{} article",
"other": "{} articles"
},
"webFeedSubscribed": "The feed has been subscribed",
"webFeedUnsubscribed": "The feed has been unsubscribed"
} }

View File

@@ -1,27 +1,75 @@
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart'; import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/webfeed.dart'; import 'package:island/models/webfeed.dart';
import 'package:island/pods/network.dart'; import 'package:island/pods/network.dart';
import 'package:island/widgets/alert.dart'; import 'package:island/widgets/alert.dart';
import 'package:island/widgets/app_scaffold.dart'; import 'package:island/widgets/app_scaffold.dart';
import 'package:island/widgets/web_article_card.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:riverpod_paging_utils/riverpod_paging_utils.dart';
import 'package:styled_widget/styled_widget.dart'; import 'package:styled_widget/styled_widget.dart';
part 'feed_detail.g.dart'; part 'feed_detail.g.dart';
@riverpod
Future<SnWebFeed> marketplaceWebFeed(Ref ref, String feedId) async {
final apiClient = ref.watch(apiClientProvider);
final resp = await apiClient.get('/sphere/feeds/$feedId');
return SnWebFeed.fromJson(resp.data);
}
/// Provider for web feed articles content /// Provider for web feed articles content
@riverpod @riverpod
Future<List<SnWebArticle>> marketplaceWebFeedContent( class MarketplaceWebFeedContentNotifier
Ref ref, { extends _$MarketplaceWebFeedContentNotifier
required String feedId, with CursorPagingNotifierMixin<SnWebArticle> {
static const int _pageSize = 20;
@override
Future<CursorPagingData<SnWebArticle>> build(String feedId) async {
_feedId = feedId;
return fetch(cursor: null);
}
late final String _feedId;
ValueNotifier<int> totalCount = ValueNotifier(0);
@override
Future<CursorPagingData<SnWebArticle>> fetch({
required String? cursor,
}) async { }) async {
final apiClient = ref.watch(apiClientProvider); final client = ref.read(apiClientProvider);
final resp = await apiClient.get('/sphere/feeds/$feedId/articles'); final offset = cursor == null ? 0 : int.parse(cursor);
return (resp.data as List).map((e) => SnWebArticle.fromJson(e)).toList();
final queryParams = {'offset': offset, 'take': _pageSize};
final response = await client.get(
'/sphere/feeds/$_feedId/articles',
queryParameters: queryParams,
);
final total = int.parse(response.headers.value('X-Total') ?? '0');
totalCount.value = total;
final List<dynamic> data = response.data;
final articles = data.map((json) => SnWebArticle.fromJson(json)).toList();
final hasMore = offset + articles.length < total;
final nextCursor = hasMore ? (offset + articles.length).toString() : null;
return CursorPagingData(
items: articles,
hasMore: hasMore,
nextCursor: nextCursor,
);
}
void dispose() {
totalCount.dispose();
}
} }
/// Provider for web feed subscription status /// Provider for web feed subscription status
@@ -49,11 +97,7 @@ class MarketplaceWebFeedDetailScreen extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
// TODO: Need to create a web feed provider similar to stickerPackProvider final feed = ref.watch(marketplaceWebFeedProvider(id));
// For now, we'll fetch the feed directly
final feedContent = ref.watch(
marketplaceWebFeedContentProvider(feedId: id),
);
final subscribed = ref.watch( final subscribed = ref.watch(
marketplaceWebFeedSubscriptionProvider(feedId: id), marketplaceWebFeedSubscriptionProvider(feedId: id),
); );
@@ -65,7 +109,7 @@ class MarketplaceWebFeedDetailScreen extends HookConsumerWidget {
HapticFeedback.selectionClick(); HapticFeedback.selectionClick();
ref.invalidate(marketplaceWebFeedSubscriptionProvider(feedId: id)); ref.invalidate(marketplaceWebFeedSubscriptionProvider(feedId: id));
if (!context.mounted) return; if (!context.mounted) return;
showSnackBar('feedSubscribed'.tr()); showSnackBar('webFeedSubscribed'.tr());
} }
// Unsubscribe from web feed // Unsubscribe from web feed
@@ -75,86 +119,94 @@ class MarketplaceWebFeedDetailScreen extends HookConsumerWidget {
HapticFeedback.selectionClick(); HapticFeedback.selectionClick();
ref.invalidate(marketplaceWebFeedSubscriptionProvider(feedId: id)); ref.invalidate(marketplaceWebFeedSubscriptionProvider(feedId: id));
if (!context.mounted) return; if (!context.mounted) return;
showSnackBar('feedUnsubscribed'.tr()); showSnackBar('webFeedUnsubscribed'.tr());
} }
// TODO: Replace with actual feed data provider once created final feedNotifier = ref.watch(
final dummyFeed = SnWebFeed( marketplaceWebFeedContentNotifierProvider(id).notifier,
id: id,
url: 'https://example.com',
title: 'Loading...',
publisherId: 'publisher-id',
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
); );
useEffect(() {
return feedNotifier.dispose;
}, []);
return AppScaffold( return AppScaffold(
appBar: AppBar(title: Text(dummyFeed.title)), appBar: AppBar(title: Text(feed.value?.title ?? 'loading'.tr())),
body: Column( body: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
// Feed meta // Feed meta
Column( feed
.when(
data:
(data) => Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Text(dummyFeed.description ?? ''), Text(data.description ?? 'descriptionNone'.tr()),
Row( Row(
spacing: 4, spacing: 4,
children: [ children: [
const Icon(Symbols.rss_feed, size: 16), const Icon(Symbols.rss_feed, size: 16),
Text('${feedContent.value?.length ?? 0} articles'), ListenableBuilder(
listenable: feedNotifier.totalCount,
builder:
(context, _) => Text(
'webFeedArticleCount'.plural(
feedNotifier.totalCount.value,
),
),
),
], ],
).opacity(0.85), ).opacity(0.85),
Row( Row(
spacing: 4, spacing: 4,
children: [ children: [
const Icon(Symbols.link, size: 16), const Icon(Symbols.link, size: 16),
SelectableText(dummyFeed.url), SelectableText(data.url),
], ],
).opacity(0.85), ).opacity(0.85),
], ],
).padding(horizontal: 24, vertical: 24), ),
error: (err, _) => Text(err.toString()),
loading: () => CircularProgressIndicator().center(),
)
.padding(horizontal: 24, vertical: 24),
const Divider(height: 1), const Divider(height: 1),
// Articles list // Articles list
Expanded( Expanded(
child: feedContent.when( child: PagingHelperView(
data: provider: marketplaceWebFeedContentNotifierProvider(id),
(articles) => RefreshIndicator( futureRefreshable:
onRefresh: marketplaceWebFeedContentNotifierProvider(id).future,
() => ref.refresh( notifierRefreshable:
marketplaceWebFeedContentProvider(feedId: id).future, marketplaceWebFeedContentNotifierProvider(id).notifier,
), contentBuilder:
child: ListView.builder( (data, widgetCount, endItemView) => ListView.separated(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: 24, horizontal: 24,
vertical: 20, vertical: 20,
), ),
itemCount: articles.length, itemCount: widgetCount,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final article = articles[index]; if (index == widgetCount - 1) {
return Card( return endItemView;
child: ListTile( }
title: Text(article.title),
subtitle: Text(article.author ?? ''), final article = data.items[index];
trailing: const Icon(Symbols.open_in_new), return WebArticleCard(article: article);
onTap: () {
// TODO: Navigate to article detail or open URL
},
),
);
}, },
separatorBuilder: (context, index) => const Gap(12),
), ),
), ),
error:
(err, _) =>
Text(
'Error: $err',
).textAlignment(TextAlign.center).center(),
loading: () => const CircularProgressIndicator().center(),
), ),
Container(
padding: EdgeInsets.only(
bottom: 16 + MediaQuery.of(context).padding.bottom,
left: 24,
right: 24,
top: 16,
), ),
Padding( color: Theme.of(context).colorScheme.surfaceContainer,
padding: EdgeInsets.symmetric(horizontal: 24, vertical: 8),
child: subscribed.when( child: subscribed.when(
data: data:
(isSubscribed) => FilledButton.icon( (isSubscribed) => FilledButton.icon(
@@ -181,7 +233,6 @@ class MarketplaceWebFeedDetailScreen extends HookConsumerWidget {
), ),
), ),
), ),
Gap(MediaQuery.of(context).padding.bottom),
], ],
), ),
); );

View File

@@ -6,8 +6,8 @@ part of 'feed_detail.dart';
// RiverpodGenerator // RiverpodGenerator
// ************************************************************************** // **************************************************************************
String _$marketplaceWebFeedContentHash() => String _$marketplaceWebFeedHash() =>
r'4e65350bff4055302e15ec14266cdebb1cd89bbe'; r'8383f94f1bc272b903c341b8d95000313b69d14c';
/// Copied from Dart SDK /// Copied from Dart SDK
class _SystemHash { class _SystemHash {
@@ -30,34 +30,25 @@ class _SystemHash {
} }
} }
/// Provider for web feed articles content /// See also [marketplaceWebFeed].
/// @ProviderFor(marketplaceWebFeed)
/// Copied from [marketplaceWebFeedContent]. const marketplaceWebFeedProvider = MarketplaceWebFeedFamily();
@ProviderFor(marketplaceWebFeedContent)
const marketplaceWebFeedContentProvider = MarketplaceWebFeedContentFamily();
/// Provider for web feed articles content /// See also [marketplaceWebFeed].
/// class MarketplaceWebFeedFamily extends Family<AsyncValue<SnWebFeed>> {
/// Copied from [marketplaceWebFeedContent]. /// See also [marketplaceWebFeed].
class MarketplaceWebFeedContentFamily const MarketplaceWebFeedFamily();
extends Family<AsyncValue<List<SnWebArticle>>> {
/// Provider for web feed articles content
///
/// Copied from [marketplaceWebFeedContent].
const MarketplaceWebFeedContentFamily();
/// Provider for web feed articles content /// See also [marketplaceWebFeed].
/// MarketplaceWebFeedProvider call(String feedId) {
/// Copied from [marketplaceWebFeedContent]. return MarketplaceWebFeedProvider(feedId);
MarketplaceWebFeedContentProvider call({required String feedId}) {
return MarketplaceWebFeedContentProvider(feedId: feedId);
} }
@override @override
MarketplaceWebFeedContentProvider getProviderOverride( MarketplaceWebFeedProvider getProviderOverride(
covariant MarketplaceWebFeedContentProvider provider, covariant MarketplaceWebFeedProvider provider,
) { ) {
return call(feedId: provider.feedId); return call(provider.feedId);
} }
static const Iterable<ProviderOrFamily>? _dependencies = null; static const Iterable<ProviderOrFamily>? _dependencies = null;
@@ -72,36 +63,28 @@ class MarketplaceWebFeedContentFamily
_allTransitiveDependencies; _allTransitiveDependencies;
@override @override
String? get name => r'marketplaceWebFeedContentProvider'; String? get name => r'marketplaceWebFeedProvider';
} }
/// Provider for web feed articles content /// See also [marketplaceWebFeed].
/// class MarketplaceWebFeedProvider extends AutoDisposeFutureProvider<SnWebFeed> {
/// Copied from [marketplaceWebFeedContent]. /// See also [marketplaceWebFeed].
class MarketplaceWebFeedContentProvider MarketplaceWebFeedProvider(String feedId)
extends AutoDisposeFutureProvider<List<SnWebArticle>> {
/// Provider for web feed articles content
///
/// Copied from [marketplaceWebFeedContent].
MarketplaceWebFeedContentProvider({required String feedId})
: this._internal( : this._internal(
(ref) => marketplaceWebFeedContent( (ref) => marketplaceWebFeed(ref as MarketplaceWebFeedRef, feedId),
ref as MarketplaceWebFeedContentRef, from: marketplaceWebFeedProvider,
feedId: feedId, name: r'marketplaceWebFeedProvider',
),
from: marketplaceWebFeedContentProvider,
name: r'marketplaceWebFeedContentProvider',
debugGetCreateSourceHash: debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product') const bool.fromEnvironment('dart.vm.product')
? null ? null
: _$marketplaceWebFeedContentHash, : _$marketplaceWebFeedHash,
dependencies: MarketplaceWebFeedContentFamily._dependencies, dependencies: MarketplaceWebFeedFamily._dependencies,
allTransitiveDependencies: allTransitiveDependencies:
MarketplaceWebFeedContentFamily._allTransitiveDependencies, MarketplaceWebFeedFamily._allTransitiveDependencies,
feedId: feedId, feedId: feedId,
); );
MarketplaceWebFeedContentProvider._internal( MarketplaceWebFeedProvider._internal(
super._createNotifier, { super._createNotifier, {
required super.name, required super.name,
required super.dependencies, required super.dependencies,
@@ -115,13 +98,12 @@ class MarketplaceWebFeedContentProvider
@override @override
Override overrideWith( Override overrideWith(
FutureOr<List<SnWebArticle>> Function(MarketplaceWebFeedContentRef provider) FutureOr<SnWebFeed> Function(MarketplaceWebFeedRef provider) create,
create,
) { ) {
return ProviderOverride( return ProviderOverride(
origin: this, origin: this,
override: MarketplaceWebFeedContentProvider._internal( override: MarketplaceWebFeedProvider._internal(
(ref) => create(ref as MarketplaceWebFeedContentRef), (ref) => create(ref as MarketplaceWebFeedRef),
from: from, from: from,
name: null, name: null,
dependencies: null, dependencies: null,
@@ -133,13 +115,13 @@ class MarketplaceWebFeedContentProvider
} }
@override @override
AutoDisposeFutureProviderElement<List<SnWebArticle>> createElement() { AutoDisposeFutureProviderElement<SnWebFeed> createElement() {
return _MarketplaceWebFeedContentProviderElement(this); return _MarketplaceWebFeedProviderElement(this);
} }
@override @override
bool operator ==(Object other) { bool operator ==(Object other) {
return other is MarketplaceWebFeedContentProvider && other.feedId == feedId; return other is MarketplaceWebFeedProvider && other.feedId == feedId;
} }
@override @override
@@ -153,19 +135,18 @@ class MarketplaceWebFeedContentProvider
@Deprecated('Will be removed in 3.0. Use Ref instead') @Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element // ignore: unused_element
mixin MarketplaceWebFeedContentRef mixin MarketplaceWebFeedRef on AutoDisposeFutureProviderRef<SnWebFeed> {
on AutoDisposeFutureProviderRef<List<SnWebArticle>> {
/// The parameter `feedId` of this provider. /// The parameter `feedId` of this provider.
String get feedId; String get feedId;
} }
class _MarketplaceWebFeedContentProviderElement class _MarketplaceWebFeedProviderElement
extends AutoDisposeFutureProviderElement<List<SnWebArticle>> extends AutoDisposeFutureProviderElement<SnWebFeed>
with MarketplaceWebFeedContentRef { with MarketplaceWebFeedRef {
_MarketplaceWebFeedContentProviderElement(super.provider); _MarketplaceWebFeedProviderElement(super.provider);
@override @override
String get feedId => (origin as MarketplaceWebFeedContentProvider).feedId; String get feedId => (origin as MarketplaceWebFeedProvider).feedId;
} }
String _$marketplaceWebFeedSubscriptionHash() => String _$marketplaceWebFeedSubscriptionHash() =>
@@ -309,5 +290,169 @@ class _MarketplaceWebFeedSubscriptionProviderElement
(origin as MarketplaceWebFeedSubscriptionProvider).feedId; (origin as MarketplaceWebFeedSubscriptionProvider).feedId;
} }
String _$marketplaceWebFeedContentNotifierHash() =>
r'eff0eee14a244a2597756a61ad5957ae397c9bf5';
abstract class _$MarketplaceWebFeedContentNotifier
extends BuildlessAutoDisposeAsyncNotifier<CursorPagingData<SnWebArticle>> {
late final String feedId;
FutureOr<CursorPagingData<SnWebArticle>> build(String feedId);
}
/// Provider for web feed articles content
///
/// Copied from [MarketplaceWebFeedContentNotifier].
@ProviderFor(MarketplaceWebFeedContentNotifier)
const marketplaceWebFeedContentNotifierProvider =
MarketplaceWebFeedContentNotifierFamily();
/// Provider for web feed articles content
///
/// Copied from [MarketplaceWebFeedContentNotifier].
class MarketplaceWebFeedContentNotifierFamily
extends Family<AsyncValue<CursorPagingData<SnWebArticle>>> {
/// Provider for web feed articles content
///
/// Copied from [MarketplaceWebFeedContentNotifier].
const MarketplaceWebFeedContentNotifierFamily();
/// Provider for web feed articles content
///
/// Copied from [MarketplaceWebFeedContentNotifier].
MarketplaceWebFeedContentNotifierProvider call(String feedId) {
return MarketplaceWebFeedContentNotifierProvider(feedId);
}
@override
MarketplaceWebFeedContentNotifierProvider getProviderOverride(
covariant MarketplaceWebFeedContentNotifierProvider provider,
) {
return call(provider.feedId);
}
static const Iterable<ProviderOrFamily>? _dependencies = null;
@override
Iterable<ProviderOrFamily>? get dependencies => _dependencies;
static const Iterable<ProviderOrFamily>? _allTransitiveDependencies = null;
@override
Iterable<ProviderOrFamily>? get allTransitiveDependencies =>
_allTransitiveDependencies;
@override
String? get name => r'marketplaceWebFeedContentNotifierProvider';
}
/// Provider for web feed articles content
///
/// Copied from [MarketplaceWebFeedContentNotifier].
class MarketplaceWebFeedContentNotifierProvider
extends
AutoDisposeAsyncNotifierProviderImpl<
MarketplaceWebFeedContentNotifier,
CursorPagingData<SnWebArticle>
> {
/// Provider for web feed articles content
///
/// Copied from [MarketplaceWebFeedContentNotifier].
MarketplaceWebFeedContentNotifierProvider(String feedId)
: this._internal(
() => MarketplaceWebFeedContentNotifier()..feedId = feedId,
from: marketplaceWebFeedContentNotifierProvider,
name: r'marketplaceWebFeedContentNotifierProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product')
? null
: _$marketplaceWebFeedContentNotifierHash,
dependencies: MarketplaceWebFeedContentNotifierFamily._dependencies,
allTransitiveDependencies:
MarketplaceWebFeedContentNotifierFamily._allTransitiveDependencies,
feedId: feedId,
);
MarketplaceWebFeedContentNotifierProvider._internal(
super._createNotifier, {
required super.name,
required super.dependencies,
required super.allTransitiveDependencies,
required super.debugGetCreateSourceHash,
required super.from,
required this.feedId,
}) : super.internal();
final String feedId;
@override
FutureOr<CursorPagingData<SnWebArticle>> runNotifierBuild(
covariant MarketplaceWebFeedContentNotifier notifier,
) {
return notifier.build(feedId);
}
@override
Override overrideWith(MarketplaceWebFeedContentNotifier Function() create) {
return ProviderOverride(
origin: this,
override: MarketplaceWebFeedContentNotifierProvider._internal(
() => create()..feedId = feedId,
from: from,
name: null,
dependencies: null,
allTransitiveDependencies: null,
debugGetCreateSourceHash: null,
feedId: feedId,
),
);
}
@override
AutoDisposeAsyncNotifierProviderElement<
MarketplaceWebFeedContentNotifier,
CursorPagingData<SnWebArticle>
>
createElement() {
return _MarketplaceWebFeedContentNotifierProviderElement(this);
}
@override
bool operator ==(Object other) {
return other is MarketplaceWebFeedContentNotifierProvider &&
other.feedId == feedId;
}
@override
int get hashCode {
var hash = _SystemHash.combine(0, runtimeType.hashCode);
hash = _SystemHash.combine(hash, feedId.hashCode);
return _SystemHash.finish(hash);
}
}
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
mixin MarketplaceWebFeedContentNotifierRef
on AutoDisposeAsyncNotifierProviderRef<CursorPagingData<SnWebArticle>> {
/// The parameter `feedId` of this provider.
String get feedId;
}
class _MarketplaceWebFeedContentNotifierProviderElement
extends
AutoDisposeAsyncNotifierProviderElement<
MarketplaceWebFeedContentNotifier,
CursorPagingData<SnWebArticle>
>
with MarketplaceWebFeedContentNotifierRef {
_MarketplaceWebFeedContentNotifierProviderElement(super.provider);
@override
String get feedId =>
(origin as MarketplaceWebFeedContentNotifierProvider).feedId;
}
// ignore_for_file: type=lint // ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package // ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package

View File

@@ -7,7 +7,7 @@ part of 'feed_marketplace.dart';
// ************************************************************************** // **************************************************************************
String _$marketplaceWebFeedsNotifierHash() => String _$marketplaceWebFeedsNotifierHash() =>
r'dbf885d95570ca9c2259a58998975db813b18cbb'; r'774b2985f2f7d61fe958f534f84e39f814327c4e';
/// Copied from Dart SDK /// Copied from Dart SDK
class _SystemHash { class _SystemHash {

View File

@@ -1,5 +1,6 @@
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/foundation.dart';
import 'package:island/screens/chat/chat.dart'; import 'package:island/screens/chat/chat.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:island/models/chat.dart'; import 'package:island/models/chat.dart';
@@ -520,9 +521,11 @@ class _RealmActionMenu extends HookConsumerWidget {
class RealmMemberListNotifier extends _$RealmMemberListNotifier class RealmMemberListNotifier extends _$RealmMemberListNotifier
with CursorPagingNotifierMixin<SnRealmMember> { with CursorPagingNotifierMixin<SnRealmMember> {
static const int _pageSize = 20; static const int _pageSize = 20;
ValueNotifier<int> totalCount = ValueNotifier(0);
@override @override
Future<CursorPagingData<SnRealmMember>> build(String realmSlug) async { Future<CursorPagingData<SnRealmMember>> build(String realmSlug) async {
totalCount.value = 0;
return fetch(); return fetch();
} }
@@ -541,6 +544,7 @@ class RealmMemberListNotifier extends _$RealmMemberListNotifier
); );
final total = int.parse(response.headers.value('X-Total') ?? '0'); final total = int.parse(response.headers.value('X-Total') ?? '0');
totalCount.value = total;
final List<dynamic> data = response.data; final List<dynamic> data = response.data;
final members = data.map((e) => SnRealmMember.fromJson(e)).toList(); final members = data.map((e) => SnRealmMember.fromJson(e)).toList();
@@ -553,52 +557,9 @@ class RealmMemberListNotifier extends _$RealmMemberListNotifier
nextCursor: nextCursor, nextCursor: nextCursor,
); );
} }
}
// Keep the old provider for backward compatibility void dispose() {
final realmMemberStateProvider = totalCount.dispose();
StateNotifierProvider.family<RealmMemberNotifier, RealmMemberState, String>(
(ref, realmSlug) {
final apiClient = ref.watch(apiClientProvider);
return RealmMemberNotifier(apiClient, realmSlug);
},
);
class RealmMemberNotifier extends StateNotifier<RealmMemberState> {
final String realmSlug;
final Dio _apiClient;
RealmMemberNotifier(this._apiClient, this.realmSlug)
: super(const RealmMemberState(members: [], isLoading: false, total: 0));
Future<void> loadMore({int offset = 0, int take = 20}) async {
if (state.isLoading) return;
if (state.total > 0 && state.members.length >= state.total) return;
state = state.copyWith(isLoading: true, error: null);
try {
final response = await _apiClient.get(
'/sphere/realms/$realmSlug/members',
queryParameters: {'offset': offset, 'take': take, 'withStatus': true},
);
final total = int.parse(response.headers.value('X-Total') ?? '0');
final List<dynamic> data = response.data;
final members = data.map((e) => SnRealmMember.fromJson(e)).toList();
state = state.copyWith(
members: [...state.members, ...members],
total: total,
isLoading: false,
);
} catch (e) {
state = state.copyWith(error: e.toString(), isLoading: false);
}
}
void reset() {
state = const RealmMemberState(members: [], isLoading: false, total: 0);
} }
} }
@@ -610,18 +571,10 @@ class _RealmMemberListSheet extends HookConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final realmIdentity = ref.watch(realmIdentityProvider(realmSlug)); final realmIdentity = ref.watch(realmIdentityProvider(realmSlug));
final memberListProvider = realmMemberListNotifierProvider(realmSlug); final memberListProvider = realmMemberListNotifierProvider(realmSlug);
final memberListNotifier = ref.watch(memberListProvider.notifier);
// For backward compatibility and to show total count in the header
final memberState = ref.watch(realmMemberStateProvider(realmSlug));
final memberNotifier = ref.read(
realmMemberStateProvider(realmSlug).notifier,
);
useEffect(() { useEffect(() {
Future(() { return memberListNotifier.dispose;
memberNotifier.loadMore();
});
return null;
}, []); }, []);
Future<void> invitePerson() async { Future<void> invitePerson() async {
@@ -638,9 +591,7 @@ class _RealmMemberListSheet extends HookConsumerWidget {
'/sphere/realms/invites/$realmSlug', '/sphere/realms/invites/$realmSlug',
data: {'related_user_id': result.id, 'role': 0}, data: {'related_user_id': result.id, 'role': 0},
); );
// Refresh both providers // Refresh the provider
memberNotifier.reset();
await memberNotifier.loadMore();
ref.invalidate(memberListProvider); ref.invalidate(memberListProvider);
} catch (err) { } catch (err) {
showErrorAlert(err); showErrorAlert(err);
@@ -652,13 +603,18 @@ class _RealmMemberListSheet extends HookConsumerWidget {
padding: EdgeInsets.only(top: 16, left: 20, right: 16, bottom: 12), padding: EdgeInsets.only(top: 16, left: 20, right: 16, bottom: 12),
child: Row( child: Row(
children: [ children: [
Text( ListenableBuilder(
'members'.plural(memberState.total), listenable: memberListNotifier.totalCount,
builder:
(context, _) => Text(
'members'.plural(memberListNotifier.totalCount.value),
key: ValueKey(memberListNotifier),
style: Theme.of(context).textTheme.headlineSmall?.copyWith( style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
letterSpacing: -0.5, letterSpacing: -0.5,
), ),
), ),
),
const Spacer(), const Spacer(),
IconButton( IconButton(
icon: const Icon(Symbols.person_add), icon: const Icon(Symbols.person_add),
@@ -668,9 +624,7 @@ class _RealmMemberListSheet extends HookConsumerWidget {
IconButton( IconButton(
icon: const Icon(Symbols.refresh), icon: const Icon(Symbols.refresh),
onPressed: () { onPressed: () {
// Refresh both providers // Refresh the provider
memberNotifier.reset();
memberNotifier.loadMore();
ref.invalidate(memberListProvider); ref.invalidate(memberListProvider);
}, },
), ),
@@ -744,9 +698,7 @@ class _RealmMemberListSheet extends HookConsumerWidget {
), ),
).then((value) { ).then((value) {
if (value != null) { if (value != null) {
// Refresh both providers // Refresh the provider
memberNotifier.reset();
memberNotifier.loadMore();
ref.invalidate(memberListProvider); ref.invalidate(memberListProvider);
} }
}); });
@@ -766,9 +718,7 @@ class _RealmMemberListSheet extends HookConsumerWidget {
await apiClient.delete( await apiClient.delete(
'/sphere/realms/$realmSlug/members/${member.accountId}', '/sphere/realms/$realmSlug/members/${member.accountId}',
); );
// Refresh both providers // Refresh the provider
memberNotifier.reset();
memberNotifier.loadMore();
ref.invalidate(memberListProvider); ref.invalidate(memberListProvider);
} catch (err) { } catch (err) {
showErrorAlert(err); showErrorAlert(err);
@@ -801,34 +751,6 @@ class _RealmMemberListSheet extends HookConsumerWidget {
} }
} }
class RealmMemberState {
final List<SnRealmMember> members;
final bool isLoading;
final int total;
final String? error;
const RealmMemberState({
required this.members,
required this.isLoading,
required this.total,
this.error,
});
RealmMemberState copyWith({
List<SnRealmMember>? members,
bool? isLoading,
int? total,
String? error,
}) {
return RealmMemberState(
members: members ?? this.members,
isLoading: isLoading ?? this.isLoading,
total: total ?? this.total,
error: error ?? this.error,
);
}
}
class _RealmMemberRoleSheet extends HookConsumerWidget { class _RealmMemberRoleSheet extends HookConsumerWidget {
final String realmSlug; final String realmSlug;
final SnRealmMember member; final SnRealmMember member;