Files
xianyan/lib/features/home/presentation/home_page.dart
2026-06-07 18:20:26 +08:00

594 lines
22 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-05-30
// 作用: 句子阅读主页面,展示每日推荐 + 分类筛选 + 句子流
// 上次更新: SheetAnimationNotifier改为Riverpod provider使用ref.watch监听
// ============================================================
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:heroine/heroine.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/constants/character_name.dart';
import '../../../core/theme/app_theme.dart';
import '../../../core/theme/app_spacing.dart';
import '../../../core/theme/app_typography.dart';
import '../../../core/theme/app_radius.dart';
import '../../../core/router/app_routes.dart';
import '../../../core/router/app_nav_extension.dart';
import '../../../core/services/audio/sfx_service.dart';
import '../../../core/services/device/shake_detector.dart';
import '../../../core/services/device/battery_info_service.dart';
import '../../../shared/widgets/containers/bottom_sheet.dart';
import '../../../core/constants/character_expression.dart';
import '../../../shared/widgets/animation/appbar_character_sprite.dart';
import '../../../shared/widgets/display/appbar_date_display.dart';
import '../../../shared/widgets/animation/character_tip_bubble.dart';
import '../../../core/services/audio/tts_service.dart';
import '../../../core/utils/ui/interaction_animations.dart';
import '../../../core/utils/ui/sheet_animation_notifier.dart';
import '../../../core/utils/platform/platform_feature_guard.dart';
import '../../../shared/widgets/display/skeleton.dart';
import '../providers/character_tips_provider.dart';
import '../providers/character_mood_provider.dart';
import '../providers/home_provider.dart';
import '../../../features/source/providers/source_provider.dart';
import '../../../features/mine/settings/providers/theme_settings_provider.dart';
import '../../../features/mine/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 'providers/sentence_detail_sheet.dart';
import '../../../../core/providers/split_view_provider.dart';
import '../../../../core/layout/adaptive_split_view.dart';
import 'panels/sentence_detail_panel.dart';
import '../../../../core/layout/right_panel_registry.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;
StreamSubscription<BatteryInfo>? _batterySubscription;
StreamSubscription<TtsState>? _ttsSubscription;
final List<ProviderSubscription> _providerSubscriptions = [];
@override
void initState() {
super.initState();
_gestureController = HomeGestureController(
onSwipeToNext: _swipeToNextCategory,
onSwipeToPrev: _swipeToPrevCategory,
);
_readingController = ReadingExperienceController();
RightPanelRegistry.register('sentence_detail', (ctx, args) {
return SentenceDetailPanel.fromArgs(args);
});
ShakeDetector.instance.pushHandler('/home', _onShake);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (ref.read(generalSettingsProvider).shakeToSwitch) {
ShakeDetector.instance.start();
}
_setupStateListeners();
});
BatteryInfoService.instance.init();
_batterySubscription = BatteryInfoService.instance.onBatteryChanged.listen((
info,
) {
if (!mounted) return;
if (!info.isLow) return;
_characterKey.currentState?.triggerExpression(
CharacterExpression.worried,
);
final t = ref.read(translationsProvider);
final message = info.isCritical
? t.home.base.batteryCritical
: t.home.base.batteryLow;
ref
.read(characterTipsProvider.notifier)
.showTip(TipsCategory.easterEgg, message);
});
_ttsSubscription = TtsService.instance.onStateChanged.listen((ttsState) {
if (!mounted) return;
if (ttsState == TtsState.speaking) {
_characterKey.currentState?.triggerExpression(
CharacterExpression.speaking,
);
} else if (ttsState == TtsState.paused || ttsState == TtsState.idle) {
_characterKey.currentState?.triggerExpression(
CharacterExpression.smile,
);
}
});
_scrollController.addListener(_onScrollForReading);
}
@override
void dispose() {
for (final sub in _providerSubscriptions) {
sub.close();
}
_batterySubscription?.cancel();
_ttsSubscription?.cancel();
_readingController.dispose();
ShakeDetector.instance.popHandler('/home');
ShakeDetector.instance.stop();
_scrollController.removeListener(_onScrollForReading);
_scrollController.dispose();
super.dispose();
}
void _onShake() {
ref.read(homeProvider.notifier).refreshDailySentences();
_characterKey.currentState?.triggerExpression(CharacterExpression.dizzy);
SfxService.instance.play(SfxType.shake);
final characterId = ref.read(
themeSettingsProvider.select((s) => s.tabCharacterStyleId),
);
ref.read(characterTipsProvider.notifier).generateTip(characterId);
}
void _onScrollForReading() {
final mode = ref.read(generalSettingsProvider).screenAlwaysOn;
_readingController.onScroll(mode);
}
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 isWidescreen =
screenWidth >= kSplitViewBreakpoint && splitState.splitViewEnabled;
if (isWidescreen) {
ref
.read(splitViewProvider.notifier)
.setHomeRightPanel(
'sentence_detail',
args: {'sentenceId': sentence.id, 'sentenceText': sentence.text},
);
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),
);
final Widget scaffold = CupertinoPageScaffold(
backgroundColor: ext.bgPrimary,
child: SafeArea(
bottom: false,
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(),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.md,
vertical: 4,
),
child: Row(
children: [
Row(
mainAxisSize: MainAxisSize.min,
children: [
Stack(
clipBehavior: Clip.none,
children: [
AppBarCharacterSprite(
key: _characterKey,
characterId: characterId,
animationIntensity: ref.watch(
themeSettingsProvider.select(
(s) => s
.animationIntensity
.durationMultiplier,
),
),
mood: ref.watch(
characterMoodProvider.select((s) => s.mood),
),
onTap: () {
ref
.read(characterTipsProvider.notifier)
.generateTip(characterId);
if (TtsService.instance.isAvailable &&
state.dailySentence != null) {
TtsService.instance.speak(
state.dailySentence!.text,
);
}
},
onDoubleTap: () {
ref
.read(characterTipsProvider.notifier)
.generateTip(characterId);
if (TtsService.instance.isSpeaking) {
TtsService.instance.stop();
}
},
),
Positioned(
left: 0,
top: 52,
child: CharacterTipBubble(
characterId: characterId,
),
),
],
),
const SizedBox(width: AppSpacing.sm),
GestureDetector(
onTap: () =>
_characterKey.currentState?.lookAtTitle(),
child: Text(
CharacterName.appBarTitle,
style: AppTypography.title1.copyWith(
color: ext.textPrimary,
),
),
),
],
),
const Spacer(),
AppBarDateDisplay(
onTap: () => _showDateConfigSheet(context),
),
const Spacer(),
BounceButton(
onTap: () => context.appPush(AppRoutes.search),
child: Heroine(
tag: 'search-icon',
child: Container(
padding: const EdgeInsets.all(AppSpacing.sm),
decoration: BoxDecoration(
color: ext.bgSecondary,
borderRadius: AppRadius.fullBorder,
),
child: Icon(
CupertinoIcons.search,
size: 20,
color: ext.iconSecondary,
),
),
),
),
],
),
).safeFadeIn(duration: 300.ms),
),
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)),
],
),
),
),
),
);
return AnimatedBuilder(
animation: ref.watch(sheetAnimationProvider),
builder: (context, child) {
if (!mounted) return child!;
final value = ref.read(sheetAnimationProvider).progress;
if (value < 0.001) return child!;
final scale = 1.0 - value * 0.06;
final radius = value.clamp(0.0, 1.0) * 20;
return RepaintBoundary(
child: Transform.scale(
scale: scale,
alignment: Alignment.topCenter,
filterQuality: FilterQuality.low,
child: ClipRRect(
borderRadius: BorderRadius.vertical(top: Radius.circular(radius)),
child: child,
),
),
);
},
child: scaffold,
);
}
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<bool>(
generalSettingsProvider.select((s) => s.shakeToSwitch),
(prev, next) {
if (prev != next) {
if (next) {
ShakeDetector.instance.start();
} else {
ShakeDetector.instance.stop();
}
}
},
),
);
_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)),
),
);
}