Files
2026-07-14 11:11:36 +08:00

257 lines
8.0 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/contracts/trakt/trakt_calendar_entry.dart';
import '../../providers/di_providers.dart';
import '../../providers/trakt_calendar_provider.dart';
import '../../shared/utils/media_nav.dart';
import '../../shared/widgets/app_loading_ring.dart';
import '../../shared/widgets/app_error_state.dart';
import '../../shared/widgets/app_sliver_header.dart';
import '../../shared/widgets/page_background.dart';
import '../../shared/widgets/pill_tab_bar.dart';
import '../../shared/widgets/smart_image.dart';
const _kTabs = ['全部', '剧集', '电影'];
class TraktCalendarPage extends ConsumerStatefulWidget {
const TraktCalendarPage({super.key});
@override
ConsumerState<TraktCalendarPage> createState() => _TraktCalendarPageState();
}
class _TraktCalendarPageState extends ConsumerState<TraktCalendarPage> {
int _tabIndex = 0;
void _onTabChanged(int index) {
if (index == _tabIndex) return;
setState(() => _tabIndex = index);
final filter = switch (index) {
1 => TraktCalendarFilter.shows,
2 => TraktCalendarFilter.movies,
_ => TraktCalendarFilter.all,
};
ref.read(traktCalendarFilterProvider.notifier).state = filter;
}
@override
Widget build(BuildContext context) {
final asyncEntries = ref.watch(traktCalendarProvider);
return Scaffold(
extendBodyBehindAppBar: true,
backgroundColor: Colors.transparent,
body: Stack(
children: [
const Positioned.fill(child: PageBackground()),
RefreshIndicator(
onRefresh: () async {
ref.invalidate(traktCalendarProvider);
await ref.read(traktCalendarProvider.future);
},
child: CustomScrollView(
slivers: [
AppSliverHeader(
title: '日历',
automaticallyImplyLeading: false,
bottom: PreferredSize(
preferredSize: const Size.fromHeight(48),
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
child: PillTabBar(
tabs: _kTabs,
selectedIndex: _tabIndex,
onSelect: _onTabChanged,
),
),
),
),
asyncEntries.when(
loading: () => const SliverFillRemaining(
child: Center(child: AppLoadingRing()),
),
error: (e, _) => SliverFillRemaining(
child: AppErrorState(
title: '加载失败',
message: '$e',
onRetry: () => ref.invalidate(traktCalendarProvider),
),
),
data: (entries) {
if (entries.isEmpty) {
return const SliverFillRemaining(
child: AppErrorState(
title: '暂无内容',
message: '未来 7 天没有新内容',
icon: Icons.calendar_today_outlined,
),
);
}
final groups = groupByDate(entries);
return SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) =>
_buildGroupItem(context, groups, index),
childCount: groups.fold<int>(
0,
(sum, g) => sum + 1 + g.entries.length,
),
),
);
},
),
],
),
),
],
),
);
}
Widget _buildGroupItem(
BuildContext context,
List<CalendarDateGroup> groups,
int flatIndex,
) {
var cursor = 0;
for (final group in groups) {
if (flatIndex == cursor) {
return _DateHeader(label: group.label, count: group.entries.length);
}
cursor++;
final entryIndex = flatIndex - cursor;
if (entryIndex < group.entries.length) {
return _CalendarEntryTile(entry: group.entries[entryIndex]);
}
cursor += group.entries.length;
}
return const SizedBox.shrink();
}
}
class _DateHeader extends StatelessWidget {
final String label;
final int count;
const _DateHeader({required this.label, required this.count});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.fromLTRB(24, 20, 24, 8),
child: Row(
children: [
Text(
label,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(width: 8),
Text(
'$count',
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurface.withValues(alpha: 0.45),
),
),
],
),
);
}
}
class _CalendarEntryTile extends ConsumerWidget {
final TraktCalendarEntry entry;
const _CalendarEntryTile({required this.entry});
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
final hasTmdb = entry.tmdbId != null && entry.tmdbId! > 0;
final airTime = _formatAirTime(entry.firstAired.toLocal());
String? networkName;
if (hasTmdb) {
final TmdbMediaKey key = (
tmdbId: entry.tmdbId!.toString(),
mediaType: entry.mediaType,
);
final enriched = ref.watch(tmdbEnrichedDetailProvider(key)).value;
networkName = enriched?.networks.firstOrNull?.name;
}
return InkWell(
onTap: hasTmdb
? () => goTmdbDetail(
context,
tmdbId: entry.tmdbId!,
mediaType: entry.mediaType,
)
: null,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8),
child: Row(
children: [
SizedBox(
width: 48,
height: 72,
child: SmartImage(
url: entry.posterUrl,
borderRadius: 6,
fallbackIcon: entry.type == 'movie'
? Icons.movie_outlined
: Icons.tv_outlined,
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
entry.displayTitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 3),
Text(
[
entry.displaySubtitle,
airTime,
if (networkName != null) networkName,
].where((s) => s.isNotEmpty).join(' · '),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurface.withValues(
alpha: 0.55,
),
),
),
],
),
),
if (!hasTmdb)
Icon(
Icons.link_off,
size: 16,
color: theme.colorScheme.onSurface.withValues(alpha: 0.25),
),
],
),
),
);
}
static String _formatAirTime(DateTime local) {
return '${local.hour.toString().padLeft(2, '0')}:'
'${local.minute.toString().padLeft(2, '0')}';
}
}