Initial commit
This commit is contained in:
@@ -0,0 +1,256 @@
|
||||
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')}';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user