Files
xianyan/lib/features/home/presentation/home_page.dart
Developer 83720002e6 feat: 新增工作台模式、系统托盘,修复多平台兼容性问题
1. 新增工作台三栏布局模式,适配宽屏设备
2. 添加跨平台系统托盘支持,新增托盘图标资源
3. 修复工作台模式下导航返回异常问题
4. 统一JSON类型安全解析,替换硬类型转换
5. 增加macOS深度链接支持,统一渠道分发信息
6. 优化部分页面生命周期和状态加载逻辑
7. 移除废弃的nearby_connections依赖
2026-06-19 06:43:55 +08:00

419 lines
15 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ============================================================
// 闲言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)),
),
);
}