1. 新增工作台三栏布局模式,适配宽屏设备 2. 添加跨平台系统托盘支持,新增托盘图标资源 3. 修复工作台模式下导航返回异常问题 4. 统一JSON类型安全解析,替换硬类型转换 5. 增加macOS深度链接支持,统一渠道分发信息 6. 优化部分页面生命周期和状态加载逻辑 7. 移除废弃的nearby_connections依赖
419 lines
15 KiB
Dart
419 lines
15 KiB
Dart
// ============================================================
|
||
// 闲言APP — 首页
|
||
// 创建时间: 2026-04-20
|
||
// 更新时间: 2026-06-15
|
||
// 作用: 句子阅读主页面,展示每日推荐 + 分类筛选 + 句子流
|
||
// 上次更新: 拆分AppBar和系统状态监听到独立组件,降低HomePage复杂度
|
||
// ============================================================
|
||
|
||
import 'dart:async';
|
||
|
||
import 'package:flutter/cupertino.dart';
|
||
import 'package:flutter/material.dart';
|
||
import 'package:flutter_animate/flutter_animate.dart';
|
||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||
import 'package:sliver_tools/sliver_tools.dart';
|
||
|
||
import 'package:stupid_simple_sheet/stupid_simple_sheet.dart';
|
||
|
||
import 'package:xianyan/l10n/translations.dart';
|
||
import '../../../core/theme/app_theme.dart';
|
||
import '../../../core/theme/app_spacing.dart';
|
||
import '../../../core/router/app_routes.dart';
|
||
import '../../../core/router/app_nav_extension.dart';
|
||
import '../../../shared/widgets/containers/bottom_sheet.dart';
|
||
import '../../../core/utils/platform/platform_feature_guard.dart';
|
||
import '../../../shared/widgets/animation/appbar_character_sprite.dart';
|
||
import '../../../shared/widgets/display/skeleton.dart';
|
||
import '../providers/home_provider.dart';
|
||
import '../../../features/source/providers/source_provider.dart';
|
||
import '../../../features/settings/providers/theme_settings_provider.dart';
|
||
import '../../../features/settings/providers/general_settings_provider.dart';
|
||
import 'home_daily_card.dart';
|
||
import 'home_refresh_indicator.dart';
|
||
import 'home_square_header.dart';
|
||
import 'home_tool_center.dart';
|
||
import 'controllers/home_gesture_controller.dart';
|
||
import 'controllers/reading_experience_controller.dart';
|
||
import 'widgets/home_offline_banner.dart';
|
||
import 'widgets/home_empty_daily_card.dart';
|
||
import 'widgets/home_action_buttons.dart';
|
||
import 'widgets/home_sentence_list_section.dart';
|
||
import 'widgets/quick_card_sheet.dart';
|
||
import 'widgets/home_app_bar_section.dart';
|
||
import 'widgets/home_system_state_monitor.dart';
|
||
import 'widgets/new_features_dialog.dart';
|
||
import 'providers/sentence_detail_sheet.dart';
|
||
import '../../../../core/providers/split_view_provider.dart';
|
||
import '../../../../core/layout/adaptive_split_view.dart';
|
||
import '../../../../core/layout/workbench/right_panel_navigator.dart';
|
||
import 'panels/sentence_detail_panel.dart';
|
||
import 'date_config_sheet.dart';
|
||
|
||
class HomePage extends ConsumerStatefulWidget {
|
||
const HomePage({super.key});
|
||
|
||
@override
|
||
ConsumerState<HomePage> createState() => _HomePageState();
|
||
}
|
||
|
||
class _HomePageState extends ConsumerState<HomePage> {
|
||
final ScrollController _scrollController = ScrollController();
|
||
final _characterKey = GlobalKey<AppBarCharacterSpriteState>();
|
||
|
||
late final HomeGestureController _gestureController;
|
||
late final ReadingExperienceController _readingController;
|
||
|
||
bool _scrollLocked = false;
|
||
bool _isSwitchingChannel = false;
|
||
final List<ProviderSubscription> _providerSubscriptions = [];
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_gestureController = HomeGestureController(
|
||
onSwipeToNext: _swipeToNextCategory,
|
||
onSwipeToPrev: _swipeToPrevCategory,
|
||
);
|
||
_readingController = ReadingExperienceController();
|
||
|
||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||
_setupStateListeners();
|
||
// 引导页开启"了解新功能"后,首次进入主页弹出当前版本更新日志
|
||
_maybeShowNewFeaturesDialog();
|
||
});
|
||
_scrollController.addListener(_onScrollForReading);
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
for (final sub in _providerSubscriptions) {
|
||
sub.close();
|
||
}
|
||
_readingController.dispose();
|
||
_scrollController.removeListener(_onScrollForReading);
|
||
_scrollController.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
void _onScrollForReading() {
|
||
final mode = ref.read(generalSettingsProvider).screenAlwaysOn;
|
||
_readingController.onScroll(mode);
|
||
}
|
||
|
||
/// 引导页开启"了解新功能"后,首次进入主页弹出当前版本更新日志
|
||
void _maybeShowNewFeaturesDialog() {
|
||
if (!mounted) return;
|
||
final t = ref.read(translationsProvider);
|
||
final ext = Theme.of(context).extension<AppThemeExtension>()!;
|
||
NewFeaturesDialog.maybeShow(context, t, ext);
|
||
}
|
||
|
||
void _onChannelSwitch(String? code) {
|
||
if (_isSwitchingChannel) return;
|
||
setState(() => _isSwitchingChannel = true);
|
||
ref.read(homeProvider.notifier).selectType(code);
|
||
Future.delayed(const Duration(milliseconds: 600), () {
|
||
if (mounted) setState(() => _isSwitchingChannel = false);
|
||
}).catchError((_) {});
|
||
}
|
||
|
||
void _handlePointerDown(PointerDownEvent event) {
|
||
_gestureController.handlePointerDown(event);
|
||
}
|
||
|
||
void _handlePointerMove(PointerMoveEvent event) {
|
||
_gestureController.handlePointerMove(event, _scrollLocked);
|
||
}
|
||
|
||
void _handlePointerUp(PointerUpEvent event) {
|
||
_gestureController.handlePointerUp();
|
||
}
|
||
|
||
void _swipeToNextCategory() {
|
||
if (_isSwitchingChannel) return;
|
||
final state = ref.read(homeProvider);
|
||
final allTypes = [null, ...state.channels.map((c) => c.key)];
|
||
var currentIdx = allTypes.indexOf(state.selectedType);
|
||
if (currentIdx < 0) currentIdx = 0;
|
||
if (currentIdx < allTypes.length - 1) {
|
||
_onChannelSwitch(allTypes[currentIdx + 1]);
|
||
}
|
||
}
|
||
|
||
void _swipeToPrevCategory() {
|
||
if (_isSwitchingChannel) return;
|
||
final state = ref.read(homeProvider);
|
||
final allTypes = [null, ...state.channels.map((c) => c.key)];
|
||
var currentIdx = allTypes.indexOf(state.selectedType);
|
||
if (currentIdx < 0) currentIdx = 0;
|
||
if (currentIdx > 0) {
|
||
_onChannelSwitch(allTypes[currentIdx - 1]);
|
||
}
|
||
}
|
||
|
||
void _showDailyDetailSheet(
|
||
HomeSentence sentence,
|
||
AppThemeExtension ext,
|
||
WidgetRef ref,
|
||
) {
|
||
final screenWidth = MediaQuery.sizeOf(context).width;
|
||
final splitState = ref.read(splitViewProvider);
|
||
final isWorkbench = screenWidth >= kCompactBreakpoint &&
|
||
splitState.workbenchEnabled;
|
||
|
||
if (isWorkbench) {
|
||
// 工作台模式:push 到右栏嵌套 Navigator,显示完整句子详情面板
|
||
ref.read(rightPanelStackProvider.notifier).push(
|
||
splitState.currentTab,
|
||
RightPanelEntry(
|
||
route: '/home/sentence/${sentence.id}',
|
||
title: '句子详情',
|
||
extra: {'sentenceId': sentence.id, 'sentenceText': sentence.text},
|
||
builder: (_) => SentenceDetailPanel(
|
||
sentence: sentence,
|
||
),
|
||
),
|
||
);
|
||
return;
|
||
}
|
||
|
||
SentenceDetailSheet.show(
|
||
context: context,
|
||
sentence: sentence,
|
||
ext: ext,
|
||
ref: ref,
|
||
onLike: () => ref.read(homeProvider.notifier).toggleLike(sentence.id),
|
||
onFavorite: () =>
|
||
ref.read(homeProvider.notifier).toggleFavorite(sentence.id),
|
||
onReadLater: () =>
|
||
ref.read(homeProvider.notifier).toggleReadLater(sentence.id),
|
||
);
|
||
}
|
||
|
||
void _showDateConfigSheet(BuildContext context) {
|
||
AppBottomSheet.showHalf<void>(
|
||
context: context,
|
||
builder: (_) => const DateConfigSheet(),
|
||
);
|
||
}
|
||
|
||
void _showToolCenter(BuildContext context) {
|
||
HomeToolCenter.show(context);
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final ext = AppTheme.ext(context);
|
||
final state = ref.watch(homeProvider);
|
||
final t = ref.watch(translationsProvider);
|
||
final characterId = ref.watch(
|
||
themeSettingsProvider.select((s) => s.tabCharacterStyleId),
|
||
);
|
||
|
||
return CupertinoPageScaffold(
|
||
backgroundColor: ext.bgPrimary,
|
||
child: SafeArea(
|
||
bottom: false,
|
||
child: HomeSystemStateMonitor(
|
||
characterKey: _characterKey,
|
||
child: Listener(
|
||
onPointerDown: _handlePointerDown,
|
||
onPointerMove: _handlePointerMove,
|
||
onPointerUp: _handlePointerUp,
|
||
child: HomeRefreshIndicator(
|
||
onOpenPanel: () => _showToolCenter(context),
|
||
characterId: characterId,
|
||
child: CustomScrollView(
|
||
controller: _scrollController,
|
||
physics: _scrollLocked
|
||
? const NeverScrollableScrollPhysics()
|
||
: const BouncingScrollPhysics(),
|
||
slivers: [
|
||
if (state.isOffline) const HomeOfflineBanner(),
|
||
|
||
HomeAppBarSection(
|
||
characterKey: _characterKey,
|
||
characterId: characterId,
|
||
onDateTap: () => _showDateConfigSheet(context),
|
||
),
|
||
|
||
SliverToBoxAdapter(
|
||
child: (state.isForceLoading && state.dailySentence == null)
|
||
? const DailyCardSkeleton()
|
||
: Padding(
|
||
padding: const EdgeInsets.symmetric(
|
||
horizontal: AppSpacing.md,
|
||
vertical: AppSpacing.sm,
|
||
),
|
||
child:
|
||
state.dailySentence == null &&
|
||
state.dailySentences.isEmpty
|
||
? HomeEmptyDailyCard(
|
||
onRetry: () =>
|
||
ref.read(homeProvider.notifier).refresh(),
|
||
)
|
||
: DailyCard(
|
||
ext: ext,
|
||
state: state,
|
||
onScrollLockChanged: (locked) {
|
||
setState(() => _scrollLocked = locked);
|
||
},
|
||
onLike: (sentence) => ref
|
||
.read(homeProvider.notifier)
|
||
.toggleLike(sentence.id),
|
||
onFavorite: (sentence) => ref
|
||
.read(homeProvider.notifier)
|
||
.toggleFavorite(sentence.id),
|
||
onLoadMore: () => ref
|
||
.read(homeProvider.notifier)
|
||
.refreshDailySentences(),
|
||
onTap: (sentence) =>
|
||
_showDailyDetailSheet(sentence, ext, ref),
|
||
),
|
||
).safeFadeInSlideY(duration: 300.ms, delay: 100.ms),
|
||
),
|
||
|
||
HomeActionButtons(
|
||
onCreateCard: () {
|
||
final text = (state.dailySentence?.text.isNotEmpty == true)
|
||
? state.dailySentence!.text
|
||
: t.home.base.defaultSentence;
|
||
_showQuickCard(context, text);
|
||
},
|
||
onEditSentence: () {
|
||
final text = (state.dailySentence?.text.isNotEmpty == true)
|
||
? state.dailySentence!.text
|
||
: t.home.base.defaultSentence;
|
||
context.appPush(
|
||
'${AppRoutes.editor}?text=${Uri.encodeComponent(text)}',
|
||
);
|
||
},
|
||
),
|
||
|
||
SliverPinnedHeader(
|
||
child: SquareHeaderContent(
|
||
ext: ext,
|
||
selectedType: state.selectedType,
|
||
channels: state.channels,
|
||
currentSort: state.currentSort,
|
||
onRefresh: () => ref.read(homeProvider.notifier).refresh(),
|
||
onSelectType: (code) => _onChannelSwitch(code),
|
||
onSortChanged: (sort) =>
|
||
ref.read(homeProvider.notifier).changeSort(sort),
|
||
onSwipeLeft: () => _swipeToNextCategory(),
|
||
onSwipeRight: () => _swipeToPrevCategory(),
|
||
onScrollToTop: () {
|
||
if (_scrollController.hasClients) {
|
||
_scrollController.animateTo(
|
||
0,
|
||
duration: const Duration(milliseconds: 800),
|
||
curve: Curves.easeInOutCubic,
|
||
);
|
||
}
|
||
},
|
||
),
|
||
),
|
||
|
||
HomeSentenceListSection(
|
||
state: state,
|
||
isSwitchingChannel: _isSwitchingChannel,
|
||
onLoadMore: () => ref.read(homeProvider.notifier).loadMore(),
|
||
onCheckPreload: (index) =>
|
||
ref.read(homeProvider.notifier).checkPreload(index),
|
||
onToggleLike: (id) =>
|
||
ref.read(homeProvider.notifier).toggleLike(id),
|
||
onToggleFavorite: (id) =>
|
||
ref.read(homeProvider.notifier).toggleFavorite(id),
|
||
onToggleReadLater: (id) =>
|
||
ref.read(homeProvider.notifier).toggleReadLater(id),
|
||
onMarkRead: (id) =>
|
||
ref.read(homeProvider.notifier).markRead(id),
|
||
onRefresh: () => ref.read(homeProvider.notifier).refresh(),
|
||
onRefreshSentenceList: () =>
|
||
ref.read(homeProvider.notifier).refreshSentenceList(),
|
||
onSlidableOpened: _gestureController.onSlidableOpened,
|
||
onSlidableClosed: _gestureController.onSlidableClosed,
|
||
),
|
||
|
||
const SliverToBoxAdapter(child: SizedBox(height: 140)),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
void _setupStateListeners() {
|
||
_providerSubscriptions.add(
|
||
ref.listenManual<SourceState>(sourceProvider, (prev, next) {
|
||
if (prev?.disabledKeys != next.disabledKeys) {
|
||
ref
|
||
.read(homeProvider.notifier)
|
||
.refreshChannels(disabledKeys: next.disabledKeys);
|
||
}
|
||
}),
|
||
);
|
||
|
||
_providerSubscriptions.add(
|
||
ref.listenManual<String?>(homeProvider.select((s) => s.selectedType), (
|
||
prev,
|
||
next,
|
||
) {
|
||
if (prev != next) {
|
||
HomeSentenceListSection.clearAnimatedCardIds();
|
||
}
|
||
}),
|
||
);
|
||
|
||
_providerSubscriptions.add(
|
||
ref.listenManual<bool>(homeProvider.select((s) => s.isForceLoading), (
|
||
prev,
|
||
next,
|
||
) {
|
||
if (prev != next && next) {
|
||
HomeSentenceListSection.clearAnimatedCardIds();
|
||
}
|
||
}),
|
||
);
|
||
|
||
_providerSubscriptions.add(
|
||
ref.listenManual<String>(homeProvider.select((s) => s.currentSort), (
|
||
prev,
|
||
next,
|
||
) {
|
||
if (prev != next) {
|
||
HomeSentenceListSection.clearAnimatedCardIds();
|
||
}
|
||
}),
|
||
);
|
||
|
||
_providerSubscriptions.add(
|
||
ref.listenManual<int>(
|
||
generalSettingsProvider.select((s) => s.screenAlwaysOn),
|
||
(prev, next) {
|
||
if (prev != next) {
|
||
_readingController.onScreenAlwaysOnChanged(next);
|
||
}
|
||
},
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
void _showQuickCard(BuildContext context, String text) {
|
||
AppBottomSheet.showCustom<void>(
|
||
context: context,
|
||
builder: (_) => QuickCardSheet(initialText: text),
|
||
snappingConfig: const SheetSnappingConfig([0.95], initialSnap: 0.92),
|
||
blurBehindBarrier: false,
|
||
barrierColor: Colors.transparent,
|
||
backgroundColor: Colors.transparent,
|
||
shape: const RoundedSuperellipseBorder(
|
||
borderRadius: BorderRadius.vertical(top: Radius.circular(48)),
|
||
),
|
||
);
|
||
}
|