frosty/lib/screens/home/stream_list/streams_list.dart
copilot d8247843c4
Some checks failed
Actions probe / probe (push) Successful in 0s
Release APK / Build & publish release (push) Failing after 24s
Add freeform tag search and viewer sort for live lists.
Helix cannot filter by tags or sort ascending; use Twitch GQL for Search→Tags→streams and Most/Fewest viewers on Top, category, and tag screens.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-30 23:22:56 +02:00

490 lines
20 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:frosty/apis/twitch_api.dart';
import 'package:frosty/apis/twitch_gql_api.dart';
import 'package:frosty/models/stream.dart';
import 'package:frosty/screens/home/stream_list/large_stream_card.dart';
import 'package:frosty/screens/home/stream_list/offline_channel_card.dart';
import 'package:frosty/screens/home/stream_list/stream_card.dart';
import 'package:frosty/screens/home/stream_list/stream_list_store.dart';
import 'package:frosty/screens/settings/stores/auth_store.dart';
import 'package:frosty/screens/settings/stores/settings_store.dart';
import 'package:frosty/widgets/alert_message.dart';
import 'package:frosty/widgets/expandable_section_header.dart';
import 'package:frosty/widgets/frosty_scrollbar.dart';
import 'package:frosty/widgets/scroll_to_top_button.dart';
import 'package:frosty/widgets/section_header.dart';
import 'package:frosty/widgets/skeleton_loader.dart';
import 'package:provider/provider.dart';
/// A widget that displays a list of followed or top streams based on the provided [listType].
/// For a widget that displays the top streams under a category, refer to [CategoryStreams].
class StreamsList extends StatefulWidget {
/// The type of list to display.
final ListType listType;
final String? categoryId;
/// Freeform tag when [listType] is [ListType.tag].
final String? freeformTag;
/// Viewer sort for top / category / tag lists.
final StreamViewerSort sortOrder;
/// The scroll controller to use for scroll to top functionality.
final ScrollController? scrollController;
final bool showJumpButton;
const StreamsList({
super.key,
required this.listType,
this.categoryId,
this.freeformTag,
this.sortOrder = StreamViewerSort.viewersDesc,
this.scrollController,
this.showJumpButton = false,
});
@override
State<StreamsList> createState() => _StreamsListState();
}
class _StreamsListState extends State<StreamsList>
with AutomaticKeepAliveClientMixin, WidgetsBindingObserver {
late final ListStore _listStore;
ScrollController? _scrollController;
@override
bool get wantKeepAlive => true;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
if (widget.scrollController == null && widget.showJumpButton) {
_scrollController = ScrollController();
}
_listStore = ListStore(
authStore: context.read<AuthStore>(),
settingsStore: context.read<SettingsStore>(),
twitchApi: context.read<TwitchApi>(),
twitchGqlApi: context.read<TwitchGqlApi>(),
listType: widget.listType,
categoryId: widget.categoryId,
freeformTag: widget.freeformTag,
sortOrder: widget.sortOrder,
scrollController: widget.scrollController ?? _scrollController,
);
}
@override
void dispose() {
_scrollController?.dispose();
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
super.didChangeAppLifecycleState(state);
if (state == AppLifecycleState.resumed) {
_listStore.checkLastTimeRefreshedAndUpdate();
}
}
@override
Widget build(BuildContext context) {
super.build(context);
_listStore.checkLastTimeRefreshedAndUpdate();
final extraTopPadding = widget.listType == ListType.top
? kToolbarHeight
: widget.listType == ListType.category
? kToolbarHeight + 122
: widget.listType == ListType.tag
? kToolbarHeight
: 0.0;
final topPadding = MediaQuery.of(context).padding.top + extraTopPadding;
return RefreshIndicator.adaptive(
edgeOffset: topPadding,
onRefresh: () async {
HapticFeedback.lightImpact();
await _listStore.refreshStreams();
if (_listStore.error != null) {
final snackBar = SnackBar(
content: AlertMessage(message: _listStore.error!, centered: false),
);
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(snackBar);
}
},
child: Observer(
builder: (_) {
Widget? statusWidget;
if (_listStore.error != null) {
statusWidget = AlertMessage(
message: _listStore.error!,
vertical: true,
);
}
if (_listStore.streams.isEmpty) {
if (_listStore.isLoading && _listStore.error == null) {
// Show skeleton loaders while loading
final settingsStore = context.watch<SettingsStore>();
final isLargeCard = settingsStore.largeStreamCard;
final isFollowingTab = widget.listType == ListType.followed;
final pinnedChannelCount = settingsStore.pinnedChannelIds.length;
return CustomScrollView(
slivers: [
// Add padding for app bar
SliverTopPadding(extraTopPadding: extraTopPadding),
// Show pinned section if following tab and has pinned channels
if (isFollowingTab && pinnedChannelCount > 0) ...[
SliverToBoxAdapter(
child: Builder(
builder: (context) => SectionHeader(
'Pinned',
isFirst: true,
padding: EdgeInsets.fromLTRB(
16 + MediaQuery.of(context).padding.left,
8,
16 + MediaQuery.of(context).padding.right,
8,
),
),
),
),
SliverList.builder(
itemCount: pinnedChannelCount,
itemBuilder: (context, index) {
if (isLargeCard) {
return LargeStreamCardSkeletonLoader(
showThumbnail: settingsStore.showThumbnails,
showCategory: widget.listType != ListType.category,
);
} else {
return StreamCardSkeletonLoader(
showThumbnail: settingsStore.showThumbnails,
showCategory: widget.listType != ListType.category,
);
}
},
),
SliverToBoxAdapter(
child: Builder(
builder: (context) => SectionHeader(
'All',
isFirst: true,
padding: EdgeInsets.fromLTRB(
16 + MediaQuery.of(context).padding.left,
8,
16 + MediaQuery.of(context).padding.right,
8,
),
),
),
),
],
SliverList.builder(
itemCount: isFollowingTab && pinnedChannelCount > 0 ? 5 : 8,
itemBuilder: (context, index) {
if (isLargeCard) {
return LargeStreamCardSkeletonLoader(
showThumbnail: settingsStore.showThumbnails,
showCategory: widget.listType != ListType.category,
);
} else {
return StreamCardSkeletonLoader(
showThumbnail: settingsStore.showThumbnails,
showCategory: widget.listType != ListType.category,
);
}
},
),
SliverBottomPadding(),
],
);
} else {
statusWidget = AlertMessage(
message: switch (widget.listType) {
ListType.followed => 'No followed streams',
ListType.tag => 'No streams with this tag',
ListType.category => 'No streams in this category',
ListType.top => 'No top streams',
},
vertical: true,
);
}
}
if (statusWidget != null) {
return CustomScrollView(
slivers: [
SliverFillRemaining(child: Center(child: statusWidget)),
],
);
}
final settingsStore = context.watch<SettingsStore>();
final unpinnedStreams = widget.listType == ListType.followed
? _listStore.streams
.where(
(stream) => !settingsStore.pinnedChannelIds.contains(
stream.userId,
),
)
.toList()
: _listStore.streams;
final isFollowingTab = widget.listType == ListType.followed;
return Stack(
alignment: AlignmentDirectional.bottomCenter,
children: [
Column(
children: [
Expanded(
child: FrostyScrollbar(
controller: _listStore.scrollController,
padding: EdgeInsets.only(
top:
MediaQuery.of(context).padding.top +
extraTopPadding,
bottom: MediaQuery.of(context).padding.bottom,
),
child: CustomScrollView(
physics: const AlwaysScrollableScrollPhysics(),
controller: _listStore.scrollController,
slivers: [
SliverTopPadding(extraTopPadding: extraTopPadding),
if (isFollowingTab &&
_listStore.allPinnedChannels.isNotEmpty) ...[
SliverToBoxAdapter(
child: Builder(
builder: (context) => SectionHeader(
'Pinned',
isFirst: true,
padding: EdgeInsets.fromLTRB(
16 + MediaQuery.of(context).padding.left,
8,
16 + MediaQuery.of(context).padding.right,
8,
),
),
),
),
Observer(
builder: (context) {
// Show actual pinned channels (live streams only)
return SliverList.builder(
itemCount:
_listStore.allPinnedChannels.length,
itemBuilder: (context, index) {
final stream =
_listStore.allPinnedChannels[index]
as StreamTwitch;
return Observer(
builder: (context) {
// Only live stream cards since we removed offline pinned channels
return settingsStore.largeStreamCard
? LargeStreamCard(
key: ValueKey(stream.userId),
streamInfo: stream,
showThumbnail: context
.read<SettingsStore>()
.showThumbnails,
showCategory:
widget.categoryId == null,
showPinOption: true,
isPinned: true,
)
: StreamCard(
key: ValueKey(stream.userId),
streamInfo: stream,
showThumbnail: context
.read<SettingsStore>()
.showThumbnails,
showCategory:
widget.categoryId == null,
showPinOption: true,
isPinned: true,
);
},
);
},
);
},
),
SliverToBoxAdapter(
child: Builder(
builder: (context) => SectionHeader(
'All',
isFirst: true,
padding: EdgeInsets.fromLTRB(
16 + MediaQuery.of(context).padding.left,
8,
16 + MediaQuery.of(context).padding.right,
8,
),
),
),
),
],
SliverList.builder(
itemCount: unpinnedStreams.length,
itemBuilder: (context, index) {
if (index > unpinnedStreams.length - 10 &&
_listStore.hasMore) {
_listStore.getStreams();
}
return Observer(
builder: (context) =>
settingsStore.largeStreamCard
? LargeStreamCard(
key: ValueKey(
unpinnedStreams[index].userId,
),
streamInfo: unpinnedStreams[index],
showThumbnail:
settingsStore.showThumbnails,
showCategory: widget.categoryId == null,
showPinOption: isFollowingTab,
isPinned: false,
)
: StreamCard(
key: ValueKey(
unpinnedStreams[index].userId,
),
streamInfo: unpinnedStreams[index],
showThumbnail:
settingsStore.showThumbnails,
showCategory: widget.categoryId == null,
showPinOption: isFollowingTab,
isPinned: false,
),
);
},
),
// Add offline followed channels section for following tab
if (isFollowingTab) ...[
SliverToBoxAdapter(
child: Observer(
builder: (context) => ExpandableSectionHeader(
'Offline',
isFirst: true,
padding: EdgeInsets.fromLTRB(
16 + MediaQuery.of(context).padding.left,
16,
16 + MediaQuery.of(context).padding.right,
8,
),
isExpanded:
_listStore.isOfflineChannelsExpanded,
onToggle: () =>
_listStore.isOfflineChannelsExpanded =
!_listStore.isOfflineChannelsExpanded,
),
),
),
if (_listStore.isOfflineChannelsExpanded)
SliverList.builder(
itemCount: _listStore.offlineChannels.length,
itemBuilder: (context, index) {
// Load more offline channels when nearing the end
if (index >
_listStore.offlineChannels.length -
5 &&
_listStore.hasMoreOfflineChannels) {
_listStore.getOfflineChannels();
}
return Observer(
builder: (context) => OfflineChannelCard(
key: ValueKey(
_listStore
.offlineChannels[index]
.broadcasterId,
),
channelInfo:
_listStore.offlineChannels[index],
showPinOption: false,
isPinned: false,
),
);
},
),
],
// Add padding for bottom navigation bar
const SliverBottomPadding(),
],
),
),
),
],
),
if (widget.showJumpButton)
Observer(
builder: (context) => AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
switchInCurve: Curves.easeOut,
switchOutCurve: Curves.easeIn,
child: _listStore.showJumpButton
? ScrollToTopButton(
scrollController: _listStore.scrollController!,
)
: null,
),
),
],
);
},
),
);
}
}
/// Helper widget for consistent top padding in sliver lists
class SliverTopPadding extends StatelessWidget {
final double extraTopPadding;
const SliverTopPadding({super.key, this.extraTopPadding = 0.0});
@override
Widget build(BuildContext context) {
return SliverToBoxAdapter(
child: SizedBox(
height: MediaQuery.of(context).padding.top + extraTopPadding,
),
);
}
}
/// Helper widget for consistent bottom padding in sliver lists
class SliverBottomPadding extends StatelessWidget {
const SliverBottomPadding({super.key});
@override
Widget build(BuildContext context) {
return SliverToBoxAdapter(
child: SizedBox(height: MediaQuery.of(context).padding.bottom),
);
}
}