178 lines
5.5 KiB
Dart
178 lines
5.5 KiB
Dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
|
|
import '../data/models/briefing_feed.dart';
|
|
import '../data/models/news_item.dart';
|
|
import 'api_client_provider.dart';
|
|
|
|
// ─── NewsState ────────────────────────────────────────────────────────────────
|
|
|
|
class NewsState {
|
|
final List<NewsItem> items;
|
|
final int offset;
|
|
final bool hasMore;
|
|
final bool loadingMore;
|
|
final int? selectedFeedId;
|
|
final Map<int, String?> reactions;
|
|
|
|
const NewsState({
|
|
required this.items,
|
|
required this.offset,
|
|
required this.hasMore,
|
|
required this.loadingMore,
|
|
required this.selectedFeedId,
|
|
required this.reactions,
|
|
});
|
|
|
|
NewsState copyWith({
|
|
List<NewsItem>? items,
|
|
int? offset,
|
|
bool? hasMore,
|
|
bool? loadingMore,
|
|
Object? selectedFeedId = _sentinel,
|
|
Map<int, String?>? reactions,
|
|
}) {
|
|
return NewsState(
|
|
items: items ?? this.items,
|
|
offset: offset ?? this.offset,
|
|
hasMore: hasMore ?? this.hasMore,
|
|
loadingMore: loadingMore ?? this.loadingMore,
|
|
selectedFeedId: selectedFeedId == _sentinel
|
|
? this.selectedFeedId
|
|
: selectedFeedId as int?,
|
|
reactions: reactions ?? this.reactions,
|
|
);
|
|
}
|
|
}
|
|
|
|
const _sentinel = Object();
|
|
|
|
// ─── NewsNotifier ─────────────────────────────────────────────────────────────
|
|
|
|
final newsProvider =
|
|
AsyncNotifierProvider<NewsNotifier, NewsState>(NewsNotifier.new);
|
|
|
|
class NewsNotifier extends AsyncNotifier<NewsState> {
|
|
static const _limit = 40;
|
|
|
|
@override
|
|
Future<NewsState> build() async {
|
|
final items = await ref.watch(newsApiProvider).getNewsItems(
|
|
days: 90,
|
|
limit: _limit,
|
|
offset: 0,
|
|
);
|
|
return NewsState(
|
|
items: items,
|
|
offset: items.length,
|
|
hasMore: items.length == _limit,
|
|
loadingMore: false,
|
|
selectedFeedId: null,
|
|
reactions: {for (final item in items) item.id: item.reaction},
|
|
);
|
|
}
|
|
|
|
/// Re-fetch the first page without clearing state (no flicker).
|
|
Future<void> refresh() async {
|
|
final current = state.value;
|
|
final items = await ref.read(newsApiProvider).getNewsItems(
|
|
days: 90,
|
|
limit: _limit,
|
|
offset: 0,
|
|
feedId: current?.selectedFeedId,
|
|
);
|
|
state = AsyncData(NewsState(
|
|
items: items,
|
|
offset: items.length,
|
|
hasMore: items.length == _limit,
|
|
loadingMore: false,
|
|
selectedFeedId: current?.selectedFeedId,
|
|
reactions: {for (final item in items) item.id: item.reaction},
|
|
));
|
|
}
|
|
|
|
Future<void> loadMore() async {
|
|
final current = state.value;
|
|
if (current == null || current.loadingMore || !current.hasMore) return;
|
|
state = AsyncData(current.copyWith(loadingMore: true));
|
|
try {
|
|
final items = await ref.read(newsApiProvider).getNewsItems(
|
|
days: 90,
|
|
limit: _limit,
|
|
offset: current.offset,
|
|
feedId: current.selectedFeedId,
|
|
);
|
|
final updatedReactions = Map<int, String?>.from(current.reactions);
|
|
for (final item in items) {
|
|
updatedReactions.putIfAbsent(item.id, () => item.reaction);
|
|
}
|
|
state = AsyncData(current.copyWith(
|
|
items: [...current.items, ...items],
|
|
offset: current.offset + items.length,
|
|
hasMore: items.length == _limit,
|
|
loadingMore: false,
|
|
reactions: updatedReactions,
|
|
));
|
|
} catch (e) {
|
|
final recovered = state.value ?? current;
|
|
state = AsyncData(recovered.copyWith(loadingMore: false));
|
|
rethrow;
|
|
}
|
|
}
|
|
|
|
Future<void> setFeed(int? feedId) async {
|
|
state = const AsyncLoading();
|
|
try {
|
|
final items = await ref.read(newsApiProvider).getNewsItems(
|
|
days: 90,
|
|
limit: _limit,
|
|
offset: 0,
|
|
feedId: feedId,
|
|
);
|
|
state = AsyncData(NewsState(
|
|
items: items,
|
|
offset: items.length,
|
|
hasMore: items.length == _limit,
|
|
loadingMore: false,
|
|
selectedFeedId: feedId,
|
|
reactions: {for (final item in items) item.id: item.reaction},
|
|
));
|
|
} catch (e, st) {
|
|
state = AsyncError(e, st);
|
|
}
|
|
}
|
|
|
|
void toggleReaction(int itemId, String reaction) {
|
|
final current = state.value;
|
|
if (current == null) return;
|
|
final prev = current.reactions[itemId];
|
|
final next = prev == reaction ? null : reaction;
|
|
state = AsyncData(current.copyWith(
|
|
reactions: {...current.reactions, itemId: next},
|
|
));
|
|
final briefingApi = ref.read(briefingApiProvider);
|
|
final future = next == null
|
|
? briefingApi.deleteRssReaction(itemId)
|
|
: briefingApi.postRssReaction(itemId, next);
|
|
future.catchError((_) {
|
|
final s = state.value;
|
|
if (s != null) {
|
|
state = AsyncData(s.copyWith(
|
|
reactions: {...s.reactions, itemId: prev},
|
|
));
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
// ─── FeedsNotifier ────────────────────────────────────────────────────────────
|
|
|
|
final feedsProvider =
|
|
AsyncNotifierProvider<FeedsNotifier, List<BriefingFeed>>(FeedsNotifier.new);
|
|
|
|
class FeedsNotifier extends AsyncNotifier<List<BriefingFeed>> {
|
|
@override
|
|
Future<List<BriefingFeed>> build() async {
|
|
return ref.watch(newsApiProvider).getFeeds();
|
|
}
|
|
}
|