257 lines
8.0 KiB
Dart
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')}';
|
||
|
|
}
|
||
|
|
}
|