353 lines
10 KiB
Dart
353 lines
10 KiB
Dart
// ============================================================
|
||
// 闲言APP — 句子来源状态管理
|
||
// 创建时间: 2026-04-29
|
||
/// 更新时间: 2026-06-07
|
||
/// 作用: 频道数据+开关状态+统计信息+混合规则+偏好设置管理
|
||
/// 上次更新: 移除硬编码禁用列表,分类启用状态由服务端推荐权重管理控制
|
||
// ============================================================
|
||
|
||
import 'dart:convert';
|
||
|
||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||
|
||
import '../../../core/storage/kv_storage.dart';
|
||
import '../../../core/utils/logger.dart';
|
||
import '../../home/models/feed_model.dart';
|
||
import '../../home/services/feed_service.dart';
|
||
|
||
const _kDisabledChannels = 'source_disabled_channels';
|
||
const _kMixConfig = 'source_mix_config';
|
||
const _kHomeCardMixConfig = 'home_card_mix_config';
|
||
const _kDeduplicateContent = 'source_deduplicate_content';
|
||
const _kSortPreference = 'source_sort_preference';
|
||
const _kPerPagePreference = 'source_per_page_preference';
|
||
|
||
/// 后台默认关闭的分类key(服务端已根据is_enabled过滤,此处仅用于重排序置底)
|
||
const _kBottomKeys = ['jieqi', 'article', 'lunyu', 'abbr', 'jiufang'];
|
||
|
||
class SourceState {
|
||
const SourceState({
|
||
this.channels = const [],
|
||
this.stats,
|
||
this.disabledKeys = const {},
|
||
this.isLoading = false,
|
||
this.searchQuery = '',
|
||
this.mixConfig = const FeedMixConfig(),
|
||
this.homeCardMixConfig = const FeedMixConfig(limit: 5),
|
||
this.deduplicateContent = true,
|
||
this.sortPreference = 'newest',
|
||
this.perPage = 20,
|
||
});
|
||
|
||
final List<FeedChannel> channels;
|
||
final FeedStatsResult? stats;
|
||
final Set<String> disabledKeys;
|
||
final bool isLoading;
|
||
final String searchQuery;
|
||
|
||
final FeedMixConfig mixConfig;
|
||
final FeedMixConfig homeCardMixConfig;
|
||
final bool deduplicateContent;
|
||
final String sortPreference;
|
||
final int perPage;
|
||
|
||
List<FeedChannel> get filteredChannels {
|
||
if (searchQuery.isEmpty) return channels;
|
||
final q = searchQuery.toLowerCase();
|
||
return channels
|
||
.where(
|
||
(c) =>
|
||
c.name.toLowerCase().contains(q) ||
|
||
c.key.toLowerCase().contains(q),
|
||
)
|
||
.toList();
|
||
}
|
||
|
||
int get enabledCount => channels.length - disabledKeys.length;
|
||
|
||
bool isEnabled(String key) => !disabledKeys.contains(key);
|
||
|
||
SourceState copyWith({
|
||
List<FeedChannel>? channels,
|
||
FeedStatsResult? stats,
|
||
Set<String>? disabledKeys,
|
||
bool? isLoading,
|
||
String? searchQuery,
|
||
FeedMixConfig? mixConfig,
|
||
FeedMixConfig? homeCardMixConfig,
|
||
bool? deduplicateContent,
|
||
String? sortPreference,
|
||
int? perPage,
|
||
bool clearStats = false,
|
||
}) {
|
||
return SourceState(
|
||
channels: channels ?? this.channels,
|
||
stats: clearStats ? null : (stats ?? this.stats),
|
||
disabledKeys: disabledKeys ?? this.disabledKeys,
|
||
isLoading: isLoading ?? this.isLoading,
|
||
searchQuery: searchQuery ?? this.searchQuery,
|
||
mixConfig: mixConfig ?? this.mixConfig,
|
||
homeCardMixConfig: homeCardMixConfig ?? this.homeCardMixConfig,
|
||
deduplicateContent: deduplicateContent ?? this.deduplicateContent,
|
||
sortPreference: sortPreference ?? this.sortPreference,
|
||
perPage: perPage ?? this.perPage,
|
||
);
|
||
}
|
||
}
|
||
|
||
class SourceNotifier extends Notifier<SourceState> {
|
||
@override
|
||
SourceState build() {
|
||
Future.microtask(init).catchError((_) {});
|
||
return const SourceState();
|
||
}
|
||
|
||
SourceNotifier();
|
||
|
||
Future<void> init() async {
|
||
state = state.copyWith(isLoading: true);
|
||
_loadDisabledKeys();
|
||
_loadMixConfig();
|
||
_loadHomeCardMixConfig();
|
||
_loadDeduplicateContent();
|
||
_loadSortPreference();
|
||
_loadPerPage();
|
||
|
||
final results = await Future.wait([
|
||
FeedService.fetchChannels(),
|
||
FeedService.fetchStats(),
|
||
]);
|
||
|
||
final rawChannels = results[0] as List<FeedChannel>;
|
||
final stats = results[1] as FeedStatsResult;
|
||
|
||
final reordered = _reorderChannels(rawChannels);
|
||
|
||
// 服务端已根据is_enabled过滤,前端无需默认禁用
|
||
var disabledKeys = state.disabledKeys;
|
||
if (disabledKeys.isEmpty) {
|
||
// 首次使用时,不设置默认禁用,所有服务端返回的分类默认启用
|
||
disabledKeys = {};
|
||
await _saveDisabledKeys({});
|
||
}
|
||
|
||
state = state.copyWith(
|
||
channels: reordered,
|
||
stats: stats,
|
||
disabledKeys: disabledKeys,
|
||
isLoading: false,
|
||
);
|
||
}
|
||
|
||
List<FeedChannel> _reorderChannels(List<FeedChannel> channels) {
|
||
final bottomSet = _kBottomKeys.toSet();
|
||
final topChannels = channels
|
||
.where((c) => !bottomSet.contains(c.key))
|
||
.toList();
|
||
final bottomChannels = channels
|
||
.where((c) => bottomSet.contains(c.key))
|
||
.toList();
|
||
final orderedBottom = <FeedChannel>[];
|
||
for (final key in _kBottomKeys) {
|
||
final ch = bottomChannels.where((c) => c.key == key);
|
||
orderedBottom.addAll(ch);
|
||
}
|
||
return [...topChannels, ...orderedBottom];
|
||
}
|
||
|
||
Future<void> toggleChannel(String key) async {
|
||
final newDisabled = Set<String>.from(state.disabledKeys);
|
||
if (newDisabled.contains(key)) {
|
||
newDisabled.remove(key);
|
||
} else {
|
||
newDisabled.add(key);
|
||
}
|
||
state = state.copyWith(disabledKeys: newDisabled);
|
||
await _saveDisabledKeys(newDisabled);
|
||
_syncPreferencesToServer();
|
||
}
|
||
|
||
Future<void> enableAll() async {
|
||
state = state.copyWith(disabledKeys: {});
|
||
await _saveDisabledKeys({});
|
||
_syncPreferencesToServer();
|
||
}
|
||
|
||
Future<void> disableAll() async {
|
||
final allKeys = state.channels.map((c) => c.key).toSet();
|
||
state = state.copyWith(disabledKeys: allKeys);
|
||
await _saveDisabledKeys(allKeys);
|
||
_syncPreferencesToServer();
|
||
}
|
||
|
||
void updateSearch(String query) {
|
||
state = state.copyWith(searchQuery: query);
|
||
}
|
||
|
||
void updateMixConfig(FeedMixConfig config) {
|
||
state = state.copyWith(mixConfig: config);
|
||
_saveMixConfig(config);
|
||
_syncPreferencesToServer();
|
||
}
|
||
|
||
void updateHomeCardMixConfig(FeedMixConfig config) {
|
||
state = state.copyWith(homeCardMixConfig: config);
|
||
_saveHomeCardMixConfig(config);
|
||
_syncPreferencesToServer();
|
||
}
|
||
|
||
void setDeduplicateContent(bool enabled) {
|
||
state = state.copyWith(deduplicateContent: enabled);
|
||
try {
|
||
KvStorage.setString(_kDeduplicateContent, enabled.toString());
|
||
} catch (e) {
|
||
Log.e('内容去重开关保存失败', e);
|
||
}
|
||
_syncPreferencesToServer();
|
||
}
|
||
|
||
void setSortPreference(String sort) {
|
||
state = state.copyWith(sortPreference: sort);
|
||
try {
|
||
KvStorage.setString(_kSortPreference, sort);
|
||
} catch (e) {
|
||
Log.e('排序偏好保存失败', e);
|
||
}
|
||
_syncPreferencesToServer();
|
||
}
|
||
|
||
void setPerPage(int count) {
|
||
state = state.copyWith(perPage: count);
|
||
try {
|
||
KvStorage.setString(_kPerPagePreference, count.toString());
|
||
} catch (e) {
|
||
Log.e('每页数量保存失败', e);
|
||
}
|
||
_syncPreferencesToServer();
|
||
}
|
||
|
||
void _loadDisabledKeys() {
|
||
try {
|
||
final raw = KvStorage.getString(_kDisabledChannels);
|
||
if (raw != null && raw.isNotEmpty) {
|
||
final list = (jsonDecode(raw) as List<dynamic>)
|
||
.map((e) => e.toString())
|
||
.toList();
|
||
state = state.copyWith(disabledKeys: list.toSet());
|
||
}
|
||
} catch (e) {
|
||
Log.e('来源页禁用频道读取失败', e);
|
||
}
|
||
}
|
||
|
||
Future<void> _saveDisabledKeys(Set<String> keys) async {
|
||
try {
|
||
await KvStorage.setString(_kDisabledChannels, jsonEncode(keys.toList()));
|
||
} catch (e) {
|
||
Log.e('来源页禁用频道保存失败', e);
|
||
}
|
||
}
|
||
|
||
void _loadMixConfig() {
|
||
try {
|
||
final raw = KvStorage.getString(_kMixConfig);
|
||
if (raw != null && raw.isNotEmpty) {
|
||
final decoded = jsonDecode(raw) as Map<String, dynamic>;
|
||
state = state.copyWith(mixConfig: FeedMixConfig.fromJson(decoded));
|
||
}
|
||
} catch (e) {
|
||
Log.e('混合规则读取失败', e);
|
||
}
|
||
}
|
||
|
||
void _saveMixConfig(FeedMixConfig config) {
|
||
try {
|
||
KvStorage.setString(_kMixConfig, jsonEncode(config.toJson()));
|
||
} catch (e) {
|
||
Log.e('混合规则保存失败', e);
|
||
}
|
||
}
|
||
|
||
void _loadHomeCardMixConfig() {
|
||
try {
|
||
final raw = KvStorage.getString(_kHomeCardMixConfig);
|
||
if (raw != null && raw.isNotEmpty) {
|
||
final decoded = jsonDecode(raw) as Map<String, dynamic>;
|
||
state = state.copyWith(
|
||
homeCardMixConfig: FeedMixConfig.fromJson(decoded),
|
||
);
|
||
}
|
||
} catch (e) {
|
||
Log.e('首页卡片混合规则读取失败', e);
|
||
}
|
||
}
|
||
|
||
void _saveHomeCardMixConfig(FeedMixConfig config) {
|
||
try {
|
||
KvStorage.setString(_kHomeCardMixConfig, jsonEncode(config.toJson()));
|
||
} catch (e) {
|
||
Log.e('首页卡片混合规则保存失败', e);
|
||
}
|
||
}
|
||
|
||
void _loadDeduplicateContent() {
|
||
try {
|
||
final raw = KvStorage.getString(_kDeduplicateContent);
|
||
if (raw != null && raw.isNotEmpty) {
|
||
state = state.copyWith(deduplicateContent: raw == 'true');
|
||
}
|
||
} catch (e) {
|
||
Log.e('内容去重开关读取失败', e);
|
||
}
|
||
}
|
||
|
||
void _loadSortPreference() {
|
||
try {
|
||
final raw = KvStorage.getString(_kSortPreference);
|
||
if (raw != null && raw.isNotEmpty) {
|
||
state = state.copyWith(sortPreference: raw);
|
||
}
|
||
} catch (e) {
|
||
Log.e('排序偏好读取失败', e);
|
||
}
|
||
}
|
||
|
||
void _loadPerPage() {
|
||
try {
|
||
final raw = KvStorage.getString(_kPerPagePreference);
|
||
if (raw != null && raw.isNotEmpty) {
|
||
final val = int.tryParse(raw);
|
||
if (val != null && val >= 10 && val <= 50) {
|
||
state = state.copyWith(perPage: val);
|
||
}
|
||
}
|
||
} catch (e) {
|
||
Log.e('每页数量读取失败', e);
|
||
}
|
||
}
|
||
|
||
void _syncPreferencesToServer() {
|
||
try {
|
||
final prefs = FeedPreferences(
|
||
disabledChannels: state.disabledKeys.toList(),
|
||
mixMode: state.mixConfig.mode,
|
||
mixChannels: state.mixConfig.channels,
|
||
mixRatios: state.mixConfig.ratios,
|
||
groupSize: state.mixConfig.groupSize,
|
||
deduplicate: state.deduplicateContent,
|
||
sort: state.sortPreference,
|
||
homeCardMode: state.homeCardMixConfig.mode,
|
||
homeCardChannels: state.homeCardMixConfig.channels,
|
||
perPage: state.perPage,
|
||
);
|
||
FeedService.savePreferences(prefs);
|
||
} catch (e) {
|
||
Log.e('偏好同步到服务端失败', e);
|
||
}
|
||
}
|
||
}
|
||
|
||
final sourceProvider = NotifierProvider<SourceNotifier, SourceState>(
|
||
SourceNotifier.new,
|
||
);
|