Add ability to pin channels in "Following" tab (#395)

* add pinned channels list global config setting

* add ability to pin streams

* ensure that all pinned streams are fetched

* rename

* tweak padding

* fix typo
This commit is contained in:
Tommy Chow 2024-09-09 22:09:20 -07:00 • committed by GitHub
parent 0d503935df
commit 3a4cd449f7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 380 additions and 81 deletions

View file

@ -291,6 +291,23 @@ class TwitchApi {
}
}
Future<StreamsTwitch> getStreamsByIds({
required List<String> userIds,
required Map<String, String> headers,
}) async {
final uri = Uri.parse(
'https://api.twitch.tv/helix/streams?${userIds.map((e) => 'user_id=$e').join('&')}&first=100',
);
final response = await _client.get(uri, headers: headers);
if (response.statusCode == 200) {
final decoded = jsonDecode(response.body);
return StreamsTwitch.fromJson(decoded);
} else {
return Future.error('Failed to get stream info');
}
}
/// Returns a [UserTwitch] object containing the user info associated with the given [userLogin].
Future<UserTwitch> getUser({
String? userLogin,

View file

@ -4,8 +4,8 @@ import 'package:frosty/screens/channel/chat/stores/chat_store.dart';
import 'package:frosty/screens/channel/chat/widgets/chat_message.dart';
import 'package:frosty/utils.dart';
import 'package:frosty/widgets/alert_message.dart';
import 'package:frosty/widgets/block_report_modal.dart';
import 'package:frosty/widgets/profile_picture.dart';
import 'package:frosty/widgets/user_actions_modal.dart';
class ChatUserModal extends StatefulWidget {
final ChatStore chatStore;
@ -72,7 +72,7 @@ class _ChatUserModalState extends State<ChatUserModal> {
tooltip: 'More',
onPressed: () => showModalBottomSheet(
context: context,
builder: (context) => BlockReportModal(
builder: (context) => UserActionsModal(
authStore: widget.chatStore.auth,
name: name,
userLogin: widget.username,

View file

@ -6,10 +6,10 @@ import 'package:frosty/screens/channel/channel.dart';
import 'package:frosty/screens/home/search/search_store.dart';
import 'package:frosty/utils.dart';
import 'package:frosty/widgets/alert_message.dart';
import 'package:frosty/widgets/block_report_modal.dart';
import 'package:frosty/widgets/loading_indicator.dart';
import 'package:frosty/widgets/profile_picture.dart';
import 'package:frosty/widgets/uptime.dart';
import 'package:frosty/widgets/user_actions_modal.dart';
import 'package:mobx/mobx.dart';
class SearchResultsChannels extends StatefulWidget {
@ -107,7 +107,7 @@ class _SearchResultsChannelsState extends State<SearchResultsChannels> {
showModalBottomSheet(
context: context,
builder: (context) => BlockReportModal(
builder: (context) => UserActionsModal(
authStore: widget.searchStore.authStore,
name: displayName,
userLogin: channel.broadcasterLogin,

View file

@ -8,10 +8,10 @@ import 'package:frosty/screens/channel/video/video_bar.dart';
import 'package:frosty/screens/settings/stores/auth_store.dart';
import 'package:frosty/theme.dart';
import 'package:frosty/utils.dart';
import 'package:frosty/widgets/block_report_modal.dart';
import 'package:frosty/widgets/cached_image.dart';
import 'package:frosty/widgets/loading_indicator.dart';
import 'package:frosty/widgets/uptime.dart';
import 'package:frosty/widgets/user_actions_modal.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
@ -19,12 +19,16 @@ class LargeStreamCard extends StatelessWidget {
final StreamTwitch streamInfo;
final bool showThumbnail;
final bool showCategory;
final bool showPinOption;
final bool? isPinned;
const LargeStreamCard({
super.key,
required this.streamInfo,
required this.showThumbnail,
this.showCategory = true,
this.showPinOption = false,
this.isPinned,
});
@override
@ -151,11 +155,13 @@ class LargeStreamCard extends StatelessWidget {
showModalBottomSheet(
context: context,
builder: (context) => BlockReportModal(
builder: (context) => UserActionsModal(
authStore: context.read<AuthStore>(),
name: streamerName,
userLogin: streamInfo.userLogin,
userId: streamInfo.userId,
showPinOption: showPinOption,
isPinned: isPinned,
),
);
},

View file

@ -8,12 +8,12 @@ import 'package:frosty/screens/home/top/categories/category_streams.dart';
import 'package:frosty/screens/settings/stores/auth_store.dart';
import 'package:frosty/theme.dart';
import 'package:frosty/utils.dart';
import 'package:frosty/widgets/block_report_modal.dart';
import 'package:frosty/widgets/cached_image.dart';
import 'package:frosty/widgets/loading_indicator.dart';
import 'package:frosty/widgets/photo_view.dart';
import 'package:frosty/widgets/profile_picture.dart';
import 'package:frosty/widgets/uptime.dart';
import 'package:frosty/widgets/user_actions_modal.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
@ -22,12 +22,16 @@ class StreamCard extends StatelessWidget {
final StreamTwitch streamInfo;
final bool showThumbnail;
final bool showCategory;
final bool showPinOption;
final bool? isPinned;
const StreamCard({
super.key,
required this.streamInfo,
required this.showThumbnail,
this.showCategory = true,
this.showPinOption = false,
this.isPinned,
});
@override
@ -210,11 +214,13 @@ class StreamCard extends StatelessWidget {
showModalBottomSheet(
context: context,
builder: (context) => BlockReportModal(
builder: (context) => UserActionsModal(
authStore: context.read<AuthStore>(),
name: streamerName,
userLogin: streamInfo.userLogin,
userId: streamInfo.userId,
showPinOption: showPinOption,
isPinned: isPinned,
),
);
},

View file

@ -5,6 +5,7 @@ import 'package:frosty/apis/twitch_api.dart';
import 'package:frosty/models/category.dart';
import 'package:frosty/models/stream.dart';
import 'package:frosty/screens/settings/stores/auth_store.dart';
import 'package:frosty/screens/settings/stores/settings_store.dart';
import 'package:mobx/mobx.dart';
part 'stream_list_store.g.dart';
@ -15,6 +16,8 @@ abstract class ListStoreBase with Store {
/// The authentication store.
final AuthStore authStore;
final SettingsStore settingsStore;
/// Twitch API service class for making requests.
final TwitchApi twitchApi;
@ -36,19 +39,35 @@ abstract class ListStoreBase with Store {
/// Returns whether or not there are more streams and loading status for pagination.
@computed
bool get hasMore => _isLoading == false && _streamsCursor != null;
bool get hasMore => isLoading == false && _streamsCursor != null;
@computed
bool get isLoading =>
_isAllStreamsLoading ||
_isPinnedStreamsLoading ||
_isCategoryDetailsLoading;
/// The loading status for pagination.
@readonly
bool _isLoading = false;
/// The list of the fetched streams.
@readonly
var _allStreams = ObservableList<StreamTwitch>();
@readonly
bool _isAllStreamsLoading = false;
@readonly
var _pinnedStreams = ObservableList<StreamTwitch>();
@readonly
var _isPinnedStreamsLoading = false;
@readonly
CategoryTwitch? _categoryDetails;
@readonly
var _isCategoryDetailsLoading = false;
/// Whether or not the scroll to top button is visible.
@observable
var showJumpButton = false;
@ -68,8 +87,11 @@ abstract class ListStoreBase with Store {
@readonly
String? _error;
ReactionDisposer? _pinnedStreamsReactioniDisposer;
ListStoreBase({
required this.authStore,
required this.settingsStore,
required this.twitchApi,
required this.listType,
this.categoryId,
@ -86,17 +108,26 @@ abstract class ListStoreBase with Store {
});
}
getStreams();
if (listType == ListType.followed) {
_pinnedStreamsReactioniDisposer = reaction(
(_) => settingsStore.pinnedChannelIds,
(_) => getPinnedStreams(),
);
if (listType == ListType.category && categoryId != null) {
getPinnedStreams();
}
if (listType == ListType.category) {
_getCategoryDetails();
}
getStreams();
}
/// Fetches the streams based on the type and current cursor.
@action
Future<void> getStreams() async {
_isLoading = true;
_isAllStreamsLoading = true;
try {
final StreamsTwitch newStreams;
@ -137,20 +168,50 @@ abstract class ListStoreBase with Store {
_error = e.toString();
}
_isLoading = false;
_isAllStreamsLoading = false;
}
@action
Future<void> getPinnedStreams() async {
if (settingsStore.pinnedChannelIds.isEmpty) {
_pinnedStreams.clear();
return;
}
_isPinnedStreamsLoading = true;
try {
_pinnedStreams = (await twitchApi.getStreamsByIds(
userIds: settingsStore.pinnedChannelIds,
headers: authStore.headersTwitch,
))
.data
.asObservable();
_error = null;
} on SocketException {
_error = 'Failed to connect';
} catch (e) {
_error = e.toString();
}
_isPinnedStreamsLoading = false;
}
/// Resets the cursor and then fetches the streams.
@action
Future<void> refreshStreams() {
_streamsCursor = null;
Future<void> refreshStreams() async {
if (listType == ListType.followed) await getPinnedStreams();
return getStreams();
_streamsCursor = null;
await getStreams();
}
@action
Future<void> _getCategoryDetails() async {
_isLoading = true;
if (categoryId == null) return;
_isCategoryDetailsLoading = true;
final categoryDetails = await twitchApi.getCategory(
headers: authStore.headersTwitch,
@ -159,7 +220,7 @@ abstract class ListStoreBase with Store {
_categoryDetails = categoryDetails.data.first;
_isLoading = false;
_isCategoryDetailsLoading = false;
}
/// Checks the last time the streams were refreshed and updates them if it has been more than 5 minutes.
@ -172,7 +233,11 @@ abstract class ListStoreBase with Store {
lastTimeRefreshed = now;
}
void dispose() => scrollController?.dispose();
void dispose() {
_pinnedStreamsReactioniDisposer?.call();
scrollController?.dispose();
}
}
/// The possible types of lists that can be displayed.

View file

@ -15,6 +15,13 @@ mixin _$ListStore on ListStoreBase, Store {
bool get hasMore => (_$hasMoreComputed ??=
Computed<bool>(() => super.hasMore, name: 'ListStoreBase.hasMore'))
.value;
Computed<bool>? _$isLoadingComputed;
@override
bool get isLoading =>
(_$isLoadingComputed ??= Computed<bool>(() => super.isLoading,
name: 'ListStoreBase.isLoading'))
.value;
Computed<ObservableList<StreamTwitch>>? _$streamsComputed;
@override
@ -23,24 +30,6 @@ mixin _$ListStore on ListStoreBase, Store {
name: 'ListStoreBase.streams'))
.value;
late final _$_isLoadingAtom =
Atom(name: 'ListStoreBase._isLoading', context: context);
bool get isLoading {
_$_isLoadingAtom.reportRead();
return super._isLoading;
}
@override
bool get _isLoading => isLoading;
@override
set _isLoading(bool value) {
_$_isLoadingAtom.reportWrite(value, super._isLoading, () {
super._isLoading = value;
});
}
late final _$_allStreamsAtom =
Atom(name: 'ListStoreBase._allStreams', context: context);
@ -59,6 +48,62 @@ mixin _$ListStore on ListStoreBase, Store {
});
}
late final _$_isAllStreamsLoadingAtom =
Atom(name: 'ListStoreBase._isAllStreamsLoading', context: context);
bool get isAllStreamsLoading {
_$_isAllStreamsLoadingAtom.reportRead();
return super._isAllStreamsLoading;
}
@override
bool get _isAllStreamsLoading => isAllStreamsLoading;
@override
set _isAllStreamsLoading(bool value) {
_$_isAllStreamsLoadingAtom.reportWrite(value, super._isAllStreamsLoading,
() {
super._isAllStreamsLoading = value;
});
}
late final _$_pinnedStreamsAtom =
Atom(name: 'ListStoreBase._pinnedStreams', context: context);
ObservableList<StreamTwitch> get pinnedStreams {
_$_pinnedStreamsAtom.reportRead();
return super._pinnedStreams;
}
@override
ObservableList<StreamTwitch> get _pinnedStreams => pinnedStreams;
@override
set _pinnedStreams(ObservableList<StreamTwitch> value) {
_$_pinnedStreamsAtom.reportWrite(value, super._pinnedStreams, () {
super._pinnedStreams = value;
});
}
late final _$_isPinnedStreamsLoadingAtom =
Atom(name: 'ListStoreBase._isPinnedStreamsLoading', context: context);
bool get isPinnedStreamsLoading {
_$_isPinnedStreamsLoadingAtom.reportRead();
return super._isPinnedStreamsLoading;
}
@override
bool get _isPinnedStreamsLoading => isPinnedStreamsLoading;
@override
set _isPinnedStreamsLoading(bool value) {
_$_isPinnedStreamsLoadingAtom
.reportWrite(value, super._isPinnedStreamsLoading, () {
super._isPinnedStreamsLoading = value;
});
}
late final _$_categoryDetailsAtom =
Atom(name: 'ListStoreBase._categoryDetails', context: context);
@ -77,6 +122,25 @@ mixin _$ListStore on ListStoreBase, Store {
});
}
late final _$_isCategoryDetailsLoadingAtom =
Atom(name: 'ListStoreBase._isCategoryDetailsLoading', context: context);
bool get isCategoryDetailsLoading {
_$_isCategoryDetailsLoadingAtom.reportRead();
return super._isCategoryDetailsLoading;
}
@override
bool get _isCategoryDetailsLoading => isCategoryDetailsLoading;
@override
set _isCategoryDetailsLoading(bool value) {
_$_isCategoryDetailsLoadingAtom
.reportWrite(value, super._isCategoryDetailsLoading, () {
super._isCategoryDetailsLoading = value;
});
}
late final _$showJumpButtonAtom =
Atom(name: 'ListStoreBase.showJumpButton', context: context);
@ -119,6 +183,22 @@ mixin _$ListStore on ListStoreBase, Store {
return _$getStreamsAsyncAction.run(() => super.getStreams());
}
late final _$getPinnedStreamsAsyncAction =
AsyncAction('ListStoreBase.getPinnedStreams', context: context);
@override
Future<void> getPinnedStreams() {
return _$getPinnedStreamsAsyncAction.run(() => super.getPinnedStreams());
}
late final _$refreshStreamsAsyncAction =
AsyncAction('ListStoreBase.refreshStreams', context: context);
@override
Future<void> refreshStreams() {
return _$refreshStreamsAsyncAction.run(() => super.refreshStreams());
}
late final _$_getCategoryDetailsAsyncAction =
AsyncAction('ListStoreBase._getCategoryDetails', context: context);
@ -128,25 +208,12 @@ mixin _$ListStore on ListStoreBase, Store {
.run(() => super._getCategoryDetails());
}
late final _$ListStoreBaseActionController =
ActionController(name: 'ListStoreBase', context: context);
@override
Future<void> refreshStreams() {
final _$actionInfo = _$ListStoreBaseActionController.startAction(
name: 'ListStoreBase.refreshStreams');
try {
return super.refreshStreams();
} finally {
_$ListStoreBaseActionController.endAction(_$actionInfo);
}
}
@override
String toString() {
return '''
showJumpButton: ${showJumpButton},
hasMore: ${hasMore},
isLoading: ${isLoading},
streams: ${streams}
''';
}

View file

@ -12,6 +12,7 @@ import 'package:frosty/widgets/alert_message.dart';
import 'package:frosty/widgets/animated_scroll_border.dart';
import 'package:frosty/widgets/loading_indicator.dart';
import 'package:frosty/widgets/scroll_to_top_button.dart';
import 'package:frosty/widgets/section_header.dart';
import 'package:provider/provider.dart';
/// A widget that displays a list of followed or top streams based on the provided [listType].
@ -43,6 +44,7 @@ class _StreamsListState extends State<StreamsList>
with AutomaticKeepAliveClientMixin, WidgetsBindingObserver {
late final _listStore = ListStore(
authStore: context.read<AuthStore>(),
settingsStore: context.read<SettingsStore>(),
twitchApi: context.read<TwitchApi>(),
listType: widget.listType,
categoryId: widget.categoryId,
@ -126,6 +128,17 @@ class _StreamsListState extends State<StreamsList>
);
}
final settingsStore = context.watch<SettingsStore>();
final unpinnedStreams = _listStore.streams
.where(
(stream) =>
!settingsStore.pinnedChannelIds.contains(stream.userId),
)
.toList();
final isFollowingTab = widget.listType == ListType.followed;
return Stack(
alignment: AlignmentDirectional.bottomCenter,
children: [
@ -144,42 +157,113 @@ class _StreamsListState extends State<StreamsList>
Expanded(
child: Scrollbar(
controller: _listStore.scrollController,
child: ListView.builder(
child: CustomScrollView(
physics: const AlwaysScrollableScrollPhysics(),
controller: _listStore.scrollController,
itemCount: _listStore.streams.length,
itemBuilder: (context, index) {
if (index > _listStore.streams.length - 10 &&
_listStore.hasMore) {
debugPrint('$index ${_listStore.streams.length}');
_listStore.getStreams();
}
return Observer(
builder: (context) =>
context.read<SettingsStore>().largeStreamCard
slivers: [
if (isFollowingTab &&
_listStore.pinnedStreams.isNotEmpty) ...[
const SliverToBoxAdapter(
child: SectionHeader(
'Pinnned',
isFirst: true,
padding: EdgeInsets.fromLTRB(
16,
8,
16,
8,
),
),
),
SliverList.builder(
itemCount: _listStore.pinnedStreams.length,
itemBuilder: (context, index) {
return Observer(
builder: (context) {
return settingsStore.largeStreamCard
? LargeStreamCard(
key: ValueKey(
_listStore
.pinnedStreams[index].userId,
),
streamInfo:
_listStore.pinnedStreams[index],
showThumbnail: context
.read<SettingsStore>()
.showThumbnails,
showCategory:
widget.categoryId == null,
showPinOption: true,
isPinned: true,
)
: StreamCard(
key: ValueKey(
_listStore
.pinnedStreams[index].userId,
),
streamInfo:
_listStore.pinnedStreams[index],
showThumbnail: context
.read<SettingsStore>()
.showThumbnails,
showCategory:
widget.categoryId == null,
showPinOption: true,
isPinned: true,
);
},
);
},
),
const SliverToBoxAdapter(
child: SectionHeader(
'All',
isFirst: true,
padding: EdgeInsets.fromLTRB(
16,
8,
16,
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(
_listStore.streams[index].userId,
unpinnedStreams[index].userId,
),
streamInfo: _listStore.streams[index],
showThumbnail: context
.read<SettingsStore>()
.showThumbnails,
streamInfo: unpinnedStreams[index],
showThumbnail:
settingsStore.showThumbnails,
showCategory: widget.categoryId == null,
showPinOption: isFollowingTab,
isPinned: false,
)
: StreamCard(
key: ValueKey(
_listStore.streams[index].userId,
unpinnedStreams[index].userId,
),
streamInfo: _listStore.streams[index],
showThumbnail: context
.read<SettingsStore>()
.showThumbnails,
streamInfo: unpinnedStreams[index],
showThumbnail:
settingsStore.showThumbnails,
showCategory: widget.categoryId == null,
showPinOption: isFollowingTab,
isPinned: false,
),
);
},
);
},
),
],
),
),
),

View file

@ -360,6 +360,7 @@ abstract class _SettingsStoreBase with Store {
// * Global configs
static const defaultFullScreen = false;
static const defaultFullScreenChatOverlay = false;
static const defaultPinnedChannelIds = <String>[];
@JsonKey(defaultValue: defaultFullScreen)
@observable
@ -369,10 +370,15 @@ abstract class _SettingsStoreBase with Store {
@observable
var fullScreenChatOverlay = defaultFullScreenChatOverlay;
@JsonKey(defaultValue: defaultPinnedChannelIds)
@observable
var pinnedChannelIds = defaultPinnedChannelIds;
@action
void resetGlobalConfigs() {
fullScreen = defaultFullScreen;
fullScreenChatOverlay = defaultFullScreenChatOverlay;
pinnedChannelIds = defaultPinnedChannelIds;
}
@action

View file

@ -67,7 +67,11 @@ SettingsStore _$SettingsStoreFromJson(Map<String, dynamic> json) =>
..shareCrashLogsAndAnalytics =
json['shareCrashLogsAndAnalytics'] as bool? ?? true
..fullScreen = json['fullScreen'] as bool? ?? false
..fullScreenChatOverlay = json['fullScreenChatOverlay'] as bool? ?? false;
..fullScreenChatOverlay = json['fullScreenChatOverlay'] as bool? ?? false
..pinnedChannelIds = (json['pinnedChannelIds'] as List<dynamic>?)
?.map((e) => e as String)
.toList() ??
[];
Map<String, dynamic> _$SettingsStoreToJson(SettingsStore instance) =>
<String, dynamic>{
@ -118,6 +122,7 @@ Map<String, dynamic> _$SettingsStoreToJson(SettingsStore instance) =>
'shareCrashLogsAndAnalytics': instance.shareCrashLogsAndAnalytics,
'fullScreen': instance.fullScreen,
'fullScreenChatOverlay': instance.fullScreenChatOverlay,
'pinnedChannelIds': instance.pinnedChannelIds,
};
const _$ThemeTypeEnumMap = {
@ -895,6 +900,22 @@ mixin _$SettingsStore on _SettingsStoreBase, Store {
});
}
late final _$pinnedChannelIdsAtom =
Atom(name: '_SettingsStoreBase.pinnedChannelIds', context: context);
@override
List<String> get pinnedChannelIds {
_$pinnedChannelIdsAtom.reportRead();
return super.pinnedChannelIds;
}
@override
set pinnedChannelIds(List<String> value) {
_$pinnedChannelIdsAtom.reportWrite(value, super.pinnedChannelIds, () {
super.pinnedChannelIds = value;
});
}
late final _$_SettingsStoreBaseActionController =
ActionController(name: '_SettingsStoreBase', context: context);
@ -1012,7 +1033,8 @@ showRecentMessages: ${showRecentMessages},
darkenRecentMessages: ${darkenRecentMessages},
shareCrashLogsAndAnalytics: ${shareCrashLogsAndAnalytics},
fullScreen: ${fullScreen},
fullScreenChatOverlay: ${fullScreenChatOverlay}
fullScreenChatOverlay: ${fullScreenChatOverlay},
pinnedChannelIds: ${pinnedChannelIds}
''';
}
}

View file

@ -1,20 +1,26 @@
import 'package:flutter/material.dart';
import 'package:frosty/screens/settings/stores/auth_store.dart';
import 'package:frosty/screens/settings/stores/settings_store.dart';
import 'package:frosty/widgets/app_bar.dart';
import 'package:provider/provider.dart';
import 'package:webview_flutter/webview_flutter.dart';
class BlockReportModal extends StatelessWidget {
class UserActionsModal extends StatelessWidget {
final AuthStore authStore;
final String name;
final String userLogin;
final String userId;
final bool showPinOption;
final bool? isPinned;
const BlockReportModal({
const UserActionsModal({
super.key,
required this.authStore,
required this.name,
required this.userLogin,
required this.userId,
this.showPinOption = false,
this.isPinned,
});
@override
@ -23,6 +29,26 @@ class BlockReportModal extends StatelessWidget {
primary: false,
shrinkWrap: true,
children: [
if (showPinOption)
ListTile(
leading: const Icon(Icons.push_pin_outlined),
title: Text('${isPinned == true ? 'Unpin' : 'Pin'} $name'),
onTap: () {
if (isPinned == true) {
context.read<SettingsStore>().pinnedChannelIds = [
...context.read<SettingsStore>().pinnedChannelIds
..remove(userId),
];
} else {
context.read<SettingsStore>().pinnedChannelIds = [
...context.read<SettingsStore>().pinnedChannelIds,
userId,
];
}
Navigator.pop(context);
},
),
if (authStore.isLoggedIn)
ListTile(
leading: const Icon(Icons.block_rounded),