594 lines
22 KiB
Dart
594 lines
22 KiB
Dart
// ============================================================
|
||
// 闲言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)),
|
||
),
|
||
);
|
||
}
|