Add jump button to lists, split up stores (again)

This commit is contained in:
Tommy Chow 2022-01-06 07:29:30 -05:00
parent f3688f3696
commit c9234f7908
13 changed files with 393 additions and 154 deletions

View file

@ -6,6 +6,7 @@ import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:frosty/core/auth/auth_store.dart';
import 'package:frosty/screens/home/home.dart';
import 'package:frosty/screens/home/search/stores/search_store.dart';
import 'package:frosty/screens/home/stores/categories_store.dart';
import 'package:frosty/screens/home/stores/list_store.dart';
import 'package:frosty/screens/settings/stores/settings_store.dart';
import 'package:mobx/mobx.dart';
@ -24,8 +25,15 @@ Future<void> main() async {
final preferences = await SharedPreferences.getInstance();
final userSettings = preferences.getString('settings');
// Initialize a settings store from existing settings. If existing settings don't exist create a new one.
final settingsStore = userSettings != null ? SettingsStore.fromJson(jsonDecode(userSettings)) : SettingsStore();
SettingsStore settingsStore;
try {
// Initialize a settings store from existing settings.
settingsStore = SettingsStore.fromJson(jsonDecode(userSettings!));
} catch (e) {
// If existing settings don't exist or if the settings could not be decoded properly, create a new one.
// This will also occur when new settings are added to the app.
settingsStore = SettingsStore();
}
// Create a MobX reaction that will save the settings on disk everytime they are changed.
autorun((_) => preferences.setString('settings', jsonEncode(settingsStore)));
@ -96,6 +104,7 @@ class MyApp extends StatelessWidget {
authStore: authStore,
listType: ListType.top,
),
categoriesSectionStore: CategoriesStore(authStore: authStore),
followedStreamsStore: authStore.isLoggedIn
? ListStore(
authStore: authStore,

View file

@ -63,7 +63,7 @@ abstract class _ChatStoreBase with Store {
@readonly
var _userState = const USERSTATE();
late ReactionDisposer disposeEmoteMenuReaction;
late final ReactionDisposer disposeEmoteMenuReaction;
_ChatStoreBase({
required this.auth,

View file

@ -1,6 +1,7 @@
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:frosty/screens/channel/chat/details/chat_details_store.dart';
import 'package:frosty/widgets/scroll_to_top_button.dart';
import 'package:intl/intl.dart';
class ChattersList extends StatefulWidget {
@ -137,14 +138,7 @@ class _ChattersListState extends State<ChattersList> {
)
],
),
if (_showJumpButton)
ElevatedButton.icon(
label: const Text('Scroll to Top'),
onPressed: () => _scrollController.jumpTo(0.0),
icon: const Icon(
Icons.arrow_circle_up,
),
),
if (_showJumpButton) ScrollToTopButton(scrollController: _scrollController)
],
),
),

View file

@ -3,6 +3,7 @@ import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:frosty/core/auth/auth_store.dart';
import 'package:frosty/screens/home/search/search.dart';
import 'package:frosty/screens/home/search/stores/search_store.dart';
import 'package:frosty/screens/home/stores/categories_store.dart';
import 'package:frosty/screens/home/stores/list_store.dart';
import 'package:frosty/screens/home/streams_list.dart';
import 'package:frosty/screens/home/top/top_section.dart';
@ -12,12 +13,14 @@ import 'package:provider/provider.dart';
class Home extends StatefulWidget {
final ListStore topSectionStore;
final CategoriesStore categoriesSectionStore;
final SearchStore searchStore;
final ListStore? followedStreamsStore;
const Home({
Key? key,
required this.topSectionStore,
required this.categoriesSectionStore,
required this.searchStore,
required this.followedStreamsStore,
}) : super(key: key);
@ -76,7 +79,10 @@ class _HomeState extends State<Home> {
index: _selectedIndex,
children: [
if (authStore.isLoggedIn) StreamsList(store: widget.followedStreamsStore!),
TopSection(topSectionStore: widget.topSectionStore),
TopSection(
topSectionStore: widget.topSectionStore,
categoriesSectionStore: widget.categoriesSectionStore,
),
Search(searchStore: widget.searchStore),
],
);

View file

@ -0,0 +1,72 @@
import 'package:flutter/material.dart';
import 'package:frosty/api/twitch_api.dart';
import 'package:frosty/core/auth/auth_store.dart';
import 'package:frosty/models/category.dart';
import 'package:mobx/mobx.dart';
part 'categories_store.g.dart';
class CategoriesStore = _CategoriesStoreBase with _$CategoriesStore;
abstract class _CategoriesStoreBase with Store {
/// The authentication store.
final AuthStore authStore;
final scrollController = ScrollController();
/// The pagination cursor for the categories.
String? _categoriesCursor;
@observable
var showJumpButton = false;
@readonly
var _categories = ObservableList<CategoryTwitch>();
/// The loading status for pagination.
@readonly
bool _isLoading = false;
/// Returns whether or not there are more streams and loading status for pagination.
bool get hasMore => _isLoading == false && _categoriesCursor != null;
_CategoriesStoreBase({required this.authStore}) {
scrollController.addListener(() {
if (scrollController.position.atEdge || scrollController.position.outOfRange) {
showJumpButton = false;
} else {
showJumpButton = true;
}
});
getCategories();
}
// Fetches the top categories based on the current cursor.
@action
Future<void> getCategories() async {
_isLoading = true;
final result = await Twitch.getTopGames(headers: authStore.headersTwitch, cursor: _categoriesCursor);
if (result != null) {
if (_categoriesCursor == null) {
_categories = result.data.asObservable();
} else {
_categories.addAll(result.data);
}
_categoriesCursor = result.pagination['cursor'];
}
_isLoading = false;
}
/// Resets the cursor and then fetches the categories.
@action
Future<void> refreshCategories() {
_categoriesCursor = null;
return getCategories();
}
void dispose() {
scrollController.dispose();
}
}

View file

@ -0,0 +1,90 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'categories_store.dart';
// **************************************************************************
// StoreGenerator
// **************************************************************************
// ignore_for_file: non_constant_identifier_names, unnecessary_brace_in_string_interps, unnecessary_lambdas, prefer_expression_function_bodies, lines_longer_than_80_chars, avoid_as, avoid_annotating_with_dynamic
mixin _$CategoriesStore on _CategoriesStoreBase, Store {
final _$showJumpButtonAtom =
Atom(name: '_CategoriesStoreBase.showJumpButton');
@override
bool get showJumpButton {
_$showJumpButtonAtom.reportRead();
return super.showJumpButton;
}
@override
set showJumpButton(bool value) {
_$showJumpButtonAtom.reportWrite(value, super.showJumpButton, () {
super.showJumpButton = value;
});
}
final _$_categoriesAtom = Atom(name: '_CategoriesStoreBase._categories');
ObservableList<CategoryTwitch> get categories {
_$_categoriesAtom.reportRead();
return super._categories;
}
@override
ObservableList<CategoryTwitch> get _categories => categories;
@override
set _categories(ObservableList<CategoryTwitch> value) {
_$_categoriesAtom.reportWrite(value, super._categories, () {
super._categories = value;
});
}
final _$_isLoadingAtom = Atom(name: '_CategoriesStoreBase._isLoading');
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;
});
}
final _$getCategoriesAsyncAction =
AsyncAction('_CategoriesStoreBase.getCategories');
@override
Future<void> getCategories() {
return _$getCategoriesAsyncAction.run(() => super.getCategories());
}
final _$_CategoriesStoreBaseActionController =
ActionController(name: '_CategoriesStoreBase');
@override
Future<void> refreshCategories() {
final _$actionInfo = _$_CategoriesStoreBaseActionController.startAction(
name: '_CategoriesStoreBase.refreshCategories');
try {
return super.refreshCategories();
} finally {
_$_CategoriesStoreBaseActionController.endAction(_$actionInfo);
}
}
@override
String toString() {
return '''
showJumpButton: ${showJumpButton}
''';
}
}

View file

@ -1,3 +1,4 @@
import 'package:flutter/widgets.dart';
import 'package:frosty/api/twitch_api.dart';
import 'package:frosty/core/auth/auth_store.dart';
import 'package:frosty/models/category.dart';
@ -9,29 +10,28 @@ part 'list_store.g.dart';
class ListStore = _ListStoreBase with _$ListStore;
abstract class _ListStoreBase with Store {
/// The list of the fetched streams.
@readonly
var _streams = ObservableList<StreamTwitch>();
/// The authentication store.
final AuthStore authStore;
@readonly
var _categories = ObservableList<CategoryTwitch>();
final ListType listType;
/// The loading status for pagination.
@readonly
bool _isLoading = false;
final CategoryTwitch? categoryInfo;
final scrollController = ScrollController();
/// The pagination cursor for the streams.
String? _streamsCursor;
/// The pagination cursor for the categories.
String? _categoriesCursor;
@observable
var showJumpButton = false;
final CategoryTwitch? categoryInfo;
/// The list of the fetched streams.
@readonly
var _streams = ObservableList<StreamTwitch>();
final ListType listType;
/// The authentication store.
final AuthStore authStore;
/// The loading status for pagination.
@readonly
bool _isLoading = false;
/// Returns whether or not there are more streams and loading status for pagination.
bool get hasMore => _isLoading == false && _streamsCursor != null;
@ -41,14 +41,19 @@ abstract class _ListStoreBase with Store {
required this.listType,
this.categoryInfo,
}) {
scrollController.addListener(() {
if (scrollController.position.atEdge || scrollController.position.outOfRange) {
showJumpButton = false;
} else {
showJumpButton = true;
}
});
switch (listType) {
case ListType.followed:
if (listType == ListType.followed && authStore.isLoggedIn) getStreams();
break;
case ListType.top:
getStreams();
getCategories();
break;
case ListType.category:
getStreams();
break;
@ -96,24 +101,6 @@ abstract class _ListStoreBase with Store {
_isLoading = false;
}
// Fetches the top categories based on the current cursor.
@action
Future<void> getCategories() async {
_isLoading = true;
final result = await Twitch.getTopGames(headers: authStore.headersTwitch, cursor: _categoriesCursor);
if (result != null) {
if (_categoriesCursor == null) {
_categories = result.data.asObservable();
} else {
_categories.addAll(result.data);
}
_categoriesCursor = result.pagination['cursor'];
}
_isLoading = false;
}
/// Resets the cursor and then fetches the streams.
@action
Future<void> refreshStreams() {
@ -122,12 +109,8 @@ abstract class _ListStoreBase with Store {
return getStreams();
}
/// Resets the cursor and then fetches the categories.
@action
Future<void> refreshCategories() {
_categoriesCursor = null;
return getCategories();
void dispose() {
scrollController.dispose();
}
}

View file

@ -9,6 +9,21 @@ part of 'list_store.dart';
// ignore_for_file: non_constant_identifier_names, unnecessary_brace_in_string_interps, unnecessary_lambdas, prefer_expression_function_bodies, lines_longer_than_80_chars, avoid_as, avoid_annotating_with_dynamic
mixin _$ListStore on _ListStoreBase, Store {
final _$showJumpButtonAtom = Atom(name: '_ListStoreBase.showJumpButton');
@override
bool get showJumpButton {
_$showJumpButtonAtom.reportRead();
return super.showJumpButton;
}
@override
set showJumpButton(bool value) {
_$showJumpButtonAtom.reportWrite(value, super.showJumpButton, () {
super.showJumpButton = value;
});
}
final _$_streamsAtom = Atom(name: '_ListStoreBase._streams');
ObservableList<StreamTwitch> get streams {
@ -26,23 +41,6 @@ mixin _$ListStore on _ListStoreBase, Store {
});
}
final _$_categoriesAtom = Atom(name: '_ListStoreBase._categories');
ObservableList<CategoryTwitch> get categories {
_$_categoriesAtom.reportRead();
return super._categories;
}
@override
ObservableList<CategoryTwitch> get _categories => categories;
@override
set _categories(ObservableList<CategoryTwitch> value) {
_$_categoriesAtom.reportWrite(value, super._categories, () {
super._categories = value;
});
}
final _$_isLoadingAtom = Atom(name: '_ListStoreBase._isLoading');
bool get isLoading {
@ -67,14 +65,6 @@ mixin _$ListStore on _ListStoreBase, Store {
return _$getStreamsAsyncAction.run(() => super.getStreams());
}
final _$getCategoriesAsyncAction =
AsyncAction('_ListStoreBase.getCategories');
@override
Future<void> getCategories() {
return _$getCategoriesAsyncAction.run(() => super.getCategories());
}
final _$_ListStoreBaseActionController =
ActionController(name: '_ListStoreBase');
@ -89,21 +79,10 @@ mixin _$ListStore on _ListStoreBase, Store {
}
}
@override
Future<void> refreshCategories() {
final _$actionInfo = _$_ListStoreBaseActionController.startAction(
name: '_ListStoreBase.refreshCategories');
try {
return super.refreshCategories();
} finally {
_$_ListStoreBaseActionController.endAction(_$actionInfo);
}
}
@override
String toString() {
return '''
showJumpButton: ${showJumpButton}
''';
}
}

View file

@ -3,16 +3,26 @@ import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:frosty/screens/home/stores/list_store.dart';
import 'package:frosty/screens/settings/stores/settings_store.dart';
import 'package:frosty/widgets/loading_indicator.dart';
import 'package:frosty/widgets/scroll_to_top_button.dart';
import 'package:frosty/widgets/stream_card.dart';
import 'package:provider/provider.dart';
class StreamsList extends StatelessWidget {
class StreamsList extends StatefulWidget {
final ListStore store;
const StreamsList({Key? key, required this.store}) : super(key: key);
@override
_StreamsListState createState() => _StreamsListState();
}
class _StreamsListState extends State<StreamsList> with AutomaticKeepAliveClientMixin {
@override
Widget build(BuildContext context) {
super.build(context);
final store = widget.store;
store.refreshStreams();
return RefreshIndicator(
onRefresh: store.refreshStreams,
child: Observer(
@ -20,22 +30,44 @@ class StreamsList extends StatelessWidget {
if (store.streams.isEmpty && store.isLoading) {
return const LoadingIndicator(subtitle: Text('Loading streams...'));
}
return ListView.builder(
itemCount: store.streams.length,
itemBuilder: (context, index) {
if (index > store.streams.length / 2 && store.hasMore) {
store.getStreams();
}
return Observer(
builder: (context) => StreamCard(
streamInfo: store.streams[index],
showUptime: context.read<SettingsStore>().showThumbnailUptime,
return Stack(
alignment: AlignmentDirectional.bottomCenter,
children: [
ListView.builder(
physics: const AlwaysScrollableScrollPhysics(),
controller: store.scrollController,
itemCount: store.streams.length,
itemBuilder: (context, index) {
if (index > store.streams.length / 2 && store.hasMore) {
store.getStreams();
}
return Observer(
builder: (context) => StreamCard(
streamInfo: store.streams[index],
showUptime: context.read<SettingsStore>().showThumbnailUptime,
),
);
},
),
Observer(
builder: (context) => AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
child: store.showJumpButton ? ScrollToTopButton(scrollController: store.scrollController) : const SizedBox(),
),
);
},
),
],
);
},
),
);
}
@override
bool get wantKeepAlive => true;
@override
void dispose() {
widget.store.dispose();
super.dispose();
}
}

View file

@ -1,36 +1,67 @@
import 'package:flutter/material.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:frosty/screens/home/stores/list_store.dart';
import 'package:frosty/screens/home/stores/categories_store.dart';
import 'package:frosty/screens/home/top/categories/category_card.dart';
import 'package:frosty/widgets/scroll_to_top_button.dart';
class Categories extends StatelessWidget {
final ListStore store;
class Categories extends StatefulWidget {
final CategoriesStore store;
const Categories({
Key? key,
required this.store,
}) : super(key: key);
@override
_CategoriesState createState() => _CategoriesState();
}
class _CategoriesState extends State<Categories> with AutomaticKeepAliveClientMixin {
@override
Widget build(BuildContext context) {
super.build(context);
final store = widget.store;
store.refreshCategories();
return RefreshIndicator(
onRefresh: store.refreshCategories,
child: Observer(
builder: (_) {
return GridView.builder(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
),
itemCount: store.categories.length,
itemBuilder: (context, index) {
if (index > store.categories.length / 2 && store.hasMore) {
store.getCategories();
}
return CategoryCard(category: store.categories[index]);
},
return Stack(
alignment: AlignmentDirectional.bottomCenter,
children: [
GridView.builder(
controller: store.scrollController,
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
),
itemCount: store.categories.length,
itemBuilder: (context, index) {
if (index > store.categories.length / 2 && store.hasMore) {
store.getCategories();
}
return CategoryCard(category: store.categories[index]);
},
),
Observer(
builder: (context) => AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
child: store.showJumpButton ? ScrollToTopButton(scrollController: store.scrollController) : const SizedBox(),
),
),
],
);
},
),
);
}
@override
bool get wantKeepAlive => true;
@override
void dispose() {
widget.store.dispose();
super.dispose();
}
}

View file

@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:frosty/screens/home/stores/list_store.dart';
import 'package:frosty/screens/settings/stores/settings_store.dart';
import 'package:frosty/widgets/scroll_to_top_button.dart';
import 'package:frosty/widgets/stream_card.dart';
import 'package:provider/provider.dart';
@ -18,42 +19,59 @@ class CategoryStreams extends StatelessWidget {
onRefresh: store.refreshStreams,
child: Observer(
builder: (context) {
return CustomScrollView(
physics: const BouncingScrollPhysics(parent: AlwaysScrollableScrollPhysics()),
slivers: [
SliverAppBar(
stretch: true,
pinned: true,
expandedHeight: MediaQuery.of(context).size.height / 3,
flexibleSpace: FlexibleSpaceBar(
title: Text(
store.categoryInfo!.name,
style: Theme.of(context).textTheme.headline6?.copyWith(fontWeight: FontWeight.bold),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
background: CachedNetworkImage(
imageUrl: store.categoryInfo!.boxArtUrl.replaceRange(store.categoryInfo!.boxArtUrl.lastIndexOf('-') + 1, null, '300x400.jpg'),
color: const Color.fromRGBO(255, 255, 255, 0.5),
colorBlendMode: BlendMode.modulate,
fit: BoxFit.cover,
),
),
),
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
if (index > store.streams.length / 2 && store.hasMore) {
store.getStreams();
}
return Observer(
builder: (context) => StreamCard(
streamInfo: store.streams[index],
showUptime: context.read<SettingsStore>().showThumbnailUptime,
return Stack(
alignment: AlignmentDirectional.bottomCenter,
children: [
CustomScrollView(
physics: const BouncingScrollPhysics(parent: AlwaysScrollableScrollPhysics()),
controller: store.scrollController,
slivers: [
SliverAppBar(
stretch: true,
pinned: true,
expandedHeight: MediaQuery.of(context).size.height / 3,
flexibleSpace: FlexibleSpaceBar(
title: Text(
store.categoryInfo!.name,
style: Theme.of(context).textTheme.headline6?.copyWith(fontWeight: FontWeight.bold),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
);
},
childCount: store.streams.length,
background: CachedNetworkImage(
imageUrl: store.categoryInfo!.boxArtUrl.replaceRange(store.categoryInfo!.boxArtUrl.lastIndexOf('-') + 1, null, '300x400.jpg'),
color: const Color.fromRGBO(255, 255, 255, 0.5),
colorBlendMode: BlendMode.modulate,
fit: BoxFit.cover,
),
),
),
SliverSafeArea(
top: false,
sliver: SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
if (index > store.streams.length / 2 && store.hasMore) {
store.getStreams();
}
return Observer(
builder: (context) => StreamCard(
streamInfo: store.streams[index],
showUptime: context.read<SettingsStore>().showThumbnailUptime,
),
);
},
childCount: store.streams.length,
),
),
),
],
),
SafeArea(
child: Observer(
builder: (context) => AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
child: store.showJumpButton ? ScrollToTopButton(scrollController: store.scrollController) : const SizedBox(),
),
),
),
],

View file

@ -1,14 +1,17 @@
import 'package:flutter/material.dart';
import 'package:frosty/screens/home/stores/categories_store.dart';
import 'package:frosty/screens/home/stores/list_store.dart';
import 'package:frosty/screens/home/streams_list.dart';
import 'package:frosty/screens/home/top/categories/categories.dart';
class TopSection extends StatelessWidget {
final ListStore topSectionStore;
final CategoriesStore categoriesSectionStore;
const TopSection({
Key? key,
required this.topSectionStore,
required this.categoriesSectionStore,
}) : super(key: key);
@override
@ -27,7 +30,7 @@ class TopSection extends StatelessWidget {
child: TabBarView(
children: [
StreamsList(store: topSectionStore),
Categories(store: topSectionStore),
Categories(store: categoriesSectionStore),
],
),
),

View file

@ -0,0 +1,22 @@
import 'package:flutter/material.dart';
class ScrollToTopButton extends StatelessWidget {
final ScrollController scrollController;
const ScrollToTopButton({Key? key, required this.scrollController}) : super(key: key);
@override
Widget build(BuildContext context) {
return ElevatedButton.icon(
label: const Text('Scroll to Top'),
onPressed: () => scrollController.animateTo(
0.0,
curve: Curves.fastOutSlowIn,
duration: const Duration(milliseconds: 500),
),
icon: const Icon(
Icons.arrow_circle_up,
),
);
}
}