Refactor & split stream list

This commit is contained in:
tommyxchow 2021-11-22 01:26:17 -05:00
parent a6654d9b89
commit d7633281d2
18 changed files with 341 additions and 280 deletions

View file

@ -159,7 +159,7 @@ class Twitch {
final decoded = jsonDecode(response.body);
final data = decoded['data'] as List;
return {'streams': data.map((stream) => Stream.fromJson(stream)).toList(), 'cursor': decoded['pagination']['cursor']};
return {'streams': data.map((stream) => StreamTwitch.fromJson(stream)).toList(), 'cursor': decoded['pagination']['cursor']};
} else {
debugPrint('Failed to update top streams');
}
@ -181,7 +181,7 @@ class Twitch {
final decoded = jsonDecode(response.body);
final data = decoded['data'] as List;
return {'streams': data.map((stream) => Stream.fromJson(stream)).toList(), 'cursor': decoded['pagination']['cursor']};
return {'streams': data.map((stream) => StreamTwitch.fromJson(stream)).toList(), 'cursor': decoded['pagination']['cursor']};
} else {
debugPrint('Failed to update followed streams');
}

View file

@ -3,9 +3,12 @@ import 'dart:io';
import 'package:flutter/material.dart';
import 'package:frosty/core/auth/auth_store.dart';
import 'package:frosty/core/settings/settings_store.dart';
import 'package:frosty/screens/categories/categories_store.dart';
import 'package:frosty/screens/home/home.dart';
import 'package:frosty/screens/home/home_store.dart';
import 'package:frosty/screens/stream_list/stream_list_store.dart';
import 'package:frosty/screens/search/search_store.dart';
import 'package:frosty/screens/stream_list/streams_followed/followed_streams_store.dart';
import 'package:frosty/screens/stream_list/streams_top/top_streams_store.dart';
import 'package:provider/provider.dart';
void main() {
@ -58,9 +61,14 @@ class MyApp extends StatelessWidget {
]),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.done) {
final authStore = context.read<AuthStore>();
return Home(
streamListStore: StreamListStore(authStore: context.read<AuthStore>()),
homeStore: HomeStore(),
topStreamsStore: TopStreamsStore(authStore: authStore),
followedStreamsStore: FollowedStreamsStore(authStore: authStore),
categoriesStore: CategoriesStore(authStore: authStore),
searchStore: SearchStore(authStore: authStore),
);
}
return const Center(child: CircularProgressIndicator());

View file

@ -4,7 +4,7 @@ part 'stream.g.dart';
// Object for live Twitch streams.
@JsonSerializable(createToJson: false, fieldRename: FieldRename.snake)
class Stream {
class StreamTwitch {
final String id;
final String userId;
final String userLogin;
@ -20,7 +20,7 @@ class Stream {
final List<String>? tagIds;
final bool isMature;
const Stream(
const StreamTwitch(
this.id,
this.userId,
this.userLogin,
@ -37,5 +37,5 @@ class Stream {
this.isMature,
);
factory Stream.fromJson(Map<String, dynamic> json) => _$StreamFromJson(json);
factory StreamTwitch.fromJson(Map<String, dynamic> json) => _$StreamTwitchFromJson(json);
}

View file

@ -6,7 +6,7 @@ part of 'stream.dart';
// JsonSerializableGenerator
// **************************************************************************
Stream _$StreamFromJson(Map<String, dynamic> json) => Stream(
StreamTwitch _$StreamTwitchFromJson(Map<String, dynamic> json) => StreamTwitch(
json['id'] as String,
json['user_id'] as String,
json['user_login'] as String,

View file

@ -1,25 +1,27 @@
import 'package:flutter/material.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:frosty/core/auth/auth_store.dart';
import 'package:frosty/screens/categories/categories_store.dart';
import 'package:frosty/screens/categories/category_card.dart';
import 'package:provider/provider.dart';
class Categories extends StatelessWidget {
const Categories({Key? key}) : super(key: key);
final CategoriesStore store;
const Categories({
Key? key,
required this.store,
}) : super(key: key);
@override
Widget build(BuildContext context) {
final categoriesStore = CategoriesStore(authStore: context.read<AuthStore>());
return Observer(
builder: (_) {
return ListView.builder(
itemCount: categoriesStore.categories.length,
itemCount: store.categories.length,
itemBuilder: (context, index) {
if (index > categoriesStore.categories.length / 2 && categoriesStore.hasMore) {
categoriesStore.getGames();
if (index > store.categories.length / 2 && store.hasMore) {
store.getGames();
}
return CategoryCard(category: categoriesStore.categories[index]);
return CategoryCard(category: store.categories[index]);
},
);
},

View file

@ -4,30 +4,32 @@ import 'package:frosty/core/auth/auth_store.dart';
import 'package:frosty/core/settings/settings.dart';
import 'package:frosty/core/settings/settings_store.dart';
import 'package:frosty/screens/categories/categories.dart';
import 'package:frosty/screens/categories/categories_store.dart';
import 'package:frosty/screens/home/home_store.dart';
import 'package:frosty/screens/search/search.dart';
import 'package:frosty/screens/stream_list/stream_list_store.dart';
import 'package:frosty/screens/search/search_store.dart';
import 'package:frosty/screens/stream_list/streams_followed/followed_streams.dart';
import 'package:frosty/screens/stream_list/streams_followed/followed_streams_store.dart';
import 'package:frosty/screens/stream_list/streams_top/top_streams.dart';
import 'package:frosty/screens/stream_list/streams_top/top_streams_store.dart';
import 'package:provider/provider.dart';
import '../stream_list/stream_list.dart';
class Home extends StatefulWidget {
class Home extends StatelessWidget {
final HomeStore homeStore;
final StreamListStore streamListStore;
final TopStreamsStore topStreamsStore;
final FollowedStreamsStore followedStreamsStore;
final CategoriesStore categoriesStore;
final SearchStore searchStore;
const Home({
Key? key,
required this.homeStore,
required this.streamListStore,
required this.topStreamsStore,
required this.followedStreamsStore,
required this.categoriesStore,
required this.searchStore,
}) : super(key: key);
@override
_HomeState createState() => _HomeState();
}
class _HomeState extends State<Home> {
final _textController = TextEditingController();
@override
Widget build(BuildContext context) {
debugPrint('build home');
@ -43,7 +45,7 @@ class _HomeState extends State<Home> {
'Search',
];
return Text(
titles[widget.homeStore.selectedIndex],
titles[homeStore.selectedIndex],
);
},
),
@ -66,19 +68,12 @@ class _HomeState extends State<Home> {
body: Observer(
builder: (_) {
return IndexedStack(
index: widget.homeStore.selectedIndex,
index: homeStore.selectedIndex,
children: [
if (context.read<AuthStore>().isLoggedIn)
StreamList(
category: StreamCategory.followed,
streamListStore: widget.streamListStore,
),
StreamList(
category: StreamCategory.top,
streamListStore: widget.streamListStore,
),
const Categories(),
const Search(),
if (context.read<AuthStore>().isLoggedIn) FollowedStreams(store: followedStreamsStore),
TopStreams(store: topStreamsStore),
Categories(store: categoriesStore),
Search(store: searchStore),
],
);
},
@ -106,17 +101,11 @@ class _HomeState extends State<Home> {
label: 'Search',
),
],
currentIndex: widget.homeStore.selectedIndex,
onTap: widget.homeStore.handleTap,
currentIndex: homeStore.selectedIndex,
onTap: homeStore.handleTap,
);
},
),
);
}
@override
void dispose() {
_textController.dispose();
super.dispose();
}
}

View file

@ -1,26 +1,21 @@
import 'package:flutter/material.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:frosty/core/auth/auth_store.dart';
import 'package:frosty/screens/channel/video_chat.dart';
import 'package:frosty/screens/search/search_store.dart';
import 'package:provider/provider.dart';
class Search extends StatefulWidget {
const Search({Key? key}) : super(key: key);
final SearchStore store;
const Search({
Key? key,
required this.store,
}) : super(key: key);
@override
_SearchState createState() => _SearchState();
}
class _SearchState extends State<Search> {
late final SearchStore searchStore;
@override
void initState() {
super.initState();
searchStore = SearchStore(authStore: context.read<AuthStore>());
}
@override
Widget build(BuildContext context) {
return Column(
@ -28,7 +23,7 @@ class _SearchState extends State<Search> {
Padding(
padding: const EdgeInsets.symmetric(vertical: 5.0, horizontal: 10.0),
child: TextField(
controller: searchStore.textController,
controller: widget.store.textController,
autocorrect: false,
decoration: const InputDecoration(
isDense: true,
@ -38,28 +33,28 @@ class _SearchState extends State<Search> {
),
hintText: 'Search for a channel',
),
onSubmitted: searchStore.handleQuery,
onSubmitted: widget.store.handleQuery,
),
),
Expanded(
child: RefreshIndicator(
onRefresh: () => searchStore.handleQuery(searchStore.textController.text),
onRefresh: () => widget.store.handleQuery(widget.store.textController.text),
child: Observer(
builder: (_) {
return ListView.builder(
itemCount: searchStore.searchResults.length + 1,
itemCount: widget.store.searchResults.length + 1,
itemBuilder: (context, index) {
if (index == searchStore.searchResults.length) {
if (searchStore.textController.text.isEmpty) {
if (index == widget.store.searchResults.length) {
if (widget.store.textController.text.isEmpty) {
return const SizedBox();
}
return ListTile(
title: Text('Go to ${searchStore.textController.text}'),
onTap: () => searchStore.handleSearch(searchStore.textController.text, context),
title: Text('Go to ${widget.store.textController.text}'),
onTap: () => widget.store.handleSearch(widget.store.textController.text, context),
);
}
final channel = searchStore.searchResults[index];
final channel = widget.store.searchResults[index];
return ListTile(
title: Text(channel.displayName),
trailing: channel.isLive
@ -104,7 +99,7 @@ class _SearchState extends State<Search> {
@override
void dispose() {
searchStore.dispose();
widget.store.dispose();
super.dispose();
}
}

View file

@ -6,7 +6,7 @@ import 'package:intl/intl.dart';
/// A tappable card widget that displays a stream's thumbnail and details.
class StreamCard extends StatelessWidget {
final Stream streamInfo;
final StreamTwitch streamInfo;
const StreamCard({Key? key, required this.streamInfo}) : super(key: key);

View file

@ -1,31 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:frosty/screens/stream_list/stream_card.dart';
import 'package:frosty/screens/stream_list/stream_list_store.dart';
class StreamList extends StatelessWidget {
final StreamCategory category;
final StreamListStore streamListStore;
const StreamList({Key? key, required this.category, required this.streamListStore}) : super(key: key);
@override
Widget build(BuildContext context) {
return RefreshIndicator(
onRefresh: () => streamListStore.refresh(category: category),
child: Observer(
builder: (_) {
return ListView.builder(
itemCount: streamListStore.streams(category: category).length,
itemBuilder: (context, index) {
if (index > streamListStore.streams(category: category).length / 2 && streamListStore.hasMore(category: category)) {
streamListStore.getStreams(category: category);
}
return StreamCard(streamInfo: streamListStore.streams(category: category)[index]);
},
);
},
),
);
}
}

View file

@ -1,113 +0,0 @@
import 'package:frosty/api/twitch_api.dart';
import 'package:frosty/core/auth/auth_store.dart';
import 'package:frosty/models/stream.dart';
import 'package:mobx/mobx.dart';
part 'stream_list_store.g.dart';
// TODO: Fix followed list refresh when log in.
class StreamListStore = _StreamListBase with _$StreamListStore;
abstract class _StreamListBase with Store {
/// The list of the fetched top streams.
@observable
var _topStreams = ObservableList<Stream>();
/// The list of the fetched followed streams.
@observable
var _followedStreams = ObservableList<Stream>();
/// The loading status for pagination.
bool _isLoading = false;
/// The pagination cursor for top streams.
String? _topStreamsCurrentCursor;
/// The pagination cursor for followed streams.
String? _followedStreamsCurrentCursor;
/// The authentication store.
final AuthStore authStore;
_StreamListBase({required this.authStore}) {
getStreams(category: StreamCategory.top);
if (authStore.user?.id != null) {
getStreams(category: StreamCategory.followed);
}
}
/// Returns the appropriate streams given the [category].
ObservableList<Stream> streams({required StreamCategory category}) {
switch (category) {
case StreamCategory.top:
return _topStreams;
case StreamCategory.followed:
return _followedStreams;
}
}
/// Returns whether or not there are more streams for the [category].
bool hasMore({required StreamCategory category}) {
switch (category) {
case StreamCategory.top:
return _isLoading == false && _topStreamsCurrentCursor != null;
case StreamCategory.followed:
return _isLoading == false && _followedStreamsCurrentCursor != null;
}
}
/// Resets the cursor and then fetches the streams for the [category].
@action
Future<void> refresh({required StreamCategory category}) async {
switch (category) {
case StreamCategory.top:
_topStreamsCurrentCursor = null;
break;
case StreamCategory.followed:
_followedStreamsCurrentCursor = null;
break;
}
await getStreams(category: category);
}
/// Fetches the streams for the [category] based on the current cursor.
@action
Future<void> getStreams({required StreamCategory category}) async {
_isLoading = true;
switch (category) {
case StreamCategory.top:
final newTopStreams = await Twitch.getTopStreams(headers: authStore.headersTwitch, cursor: _topStreamsCurrentCursor);
if (newTopStreams != null) {
if (_topStreamsCurrentCursor == null) {
_topStreams = ObservableList.of(newTopStreams['streams']);
} else {
_topStreams.addAll(newTopStreams['streams']);
}
_topStreamsCurrentCursor = newTopStreams['cursor'];
}
break;
case StreamCategory.followed:
final newFollowedStreams =
await Twitch.getFollowedStreams(id: authStore.user!.id, headers: authStore.headersTwitch, cursor: _followedStreamsCurrentCursor);
if (newFollowedStreams != null) {
if (_followedStreamsCurrentCursor == null) {
_followedStreams = ObservableList.of(newFollowedStreams['streams']);
} else {
_followedStreams.addAll(newFollowedStreams['streams']);
}
_followedStreamsCurrentCursor = newFollowedStreams['cursor'];
}
break;
}
_isLoading = false;
}
}
/// The type of category for streams.
enum StreamCategory { top, followed }

View file

@ -1,63 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'stream_list_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 _$StreamListStore on _StreamListBase, Store {
final _$_topStreamsAtom = Atom(name: '_StreamListBase._topStreams');
@override
ObservableList<Stream> get _topStreams {
_$_topStreamsAtom.reportRead();
return super._topStreams;
}
@override
set _topStreams(ObservableList<Stream> value) {
_$_topStreamsAtom.reportWrite(value, super._topStreams, () {
super._topStreams = value;
});
}
final _$_followedStreamsAtom = Atom(name: '_StreamListBase._followedStreams');
@override
ObservableList<Stream> get _followedStreams {
_$_followedStreamsAtom.reportRead();
return super._followedStreams;
}
@override
set _followedStreams(ObservableList<Stream> value) {
_$_followedStreamsAtom.reportWrite(value, super._followedStreams, () {
super._followedStreams = value;
});
}
final _$refreshAsyncAction = AsyncAction('_StreamListBase.refresh');
@override
Future<void> refresh({required StreamCategory category}) {
return _$refreshAsyncAction.run(() => super.refresh(category: category));
}
final _$getStreamsAsyncAction = AsyncAction('_StreamListBase.getStreams');
@override
Future<void> getStreams({required StreamCategory category}) {
return _$getStreamsAsyncAction
.run(() => super.getStreams(category: category));
}
@override
String toString() {
return '''
''';
}
}

View file

@ -0,0 +1,30 @@
import 'package:flutter/material.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:frosty/screens/stream_list/stream_card.dart';
import 'package:frosty/screens/stream_list/streams_followed/followed_streams_store.dart';
class FollowedStreams extends StatelessWidget {
final FollowedStreamsStore store;
const FollowedStreams({Key? key, required this.store}) : super(key: key);
@override
Widget build(BuildContext context) {
return RefreshIndicator(
onRefresh: store.refresh,
child: Observer(
builder: (_) {
return ListView.builder(
itemCount: store.followedStreams.length,
itemBuilder: (context, index) {
if (index > store.followedStreams.length / 2 && store.hasMore) {
store.getFollowedStreams();
}
return StreamCard(streamInfo: store.followedStreams[index]);
},
);
},
),
);
}
}

View file

@ -0,0 +1,58 @@
import 'package:frosty/api/twitch_api.dart';
import 'package:frosty/core/auth/auth_store.dart';
import 'package:frosty/models/stream.dart';
import 'package:mobx/mobx.dart';
part 'followed_streams_store.g.dart';
class FollowedStreamsStore = _FollowedStreamsStoreBase with _$FollowedStreamsStore;
abstract class _FollowedStreamsStoreBase with Store {
/// The list of the fetched followed streams.
@observable
var _followedStreams = ObservableList<StreamTwitch>();
ObservableList<StreamTwitch> get followedStreams => _followedStreams;
/// The loading status for pagination.
bool _isLoading = false;
/// The pagination cursor for followed streams.
String? _followedStreamsCurrentCursor;
/// The authentication store.
final AuthStore authStore;
/// Returns whether or not there are more streams and loading status for pagination.
bool get hasMore => _isLoading == false && _followedStreamsCurrentCursor != null;
_FollowedStreamsStoreBase({required this.authStore}) {
getFollowedStreams();
}
/// Fetches the followed streams based on the current cursor.
@action
Future<void> getFollowedStreams() async {
_isLoading = true;
final newFollowedStreams = await Twitch.getFollowedStreams(id: authStore.user!.id, headers: authStore.headersTwitch, cursor: _followedStreamsCurrentCursor);
if (newFollowedStreams != null) {
if (_followedStreamsCurrentCursor == null) {
_followedStreams = ObservableList.of(newFollowedStreams['streams']);
} else {
_followedStreams.addAll(newFollowedStreams['streams']);
}
_followedStreamsCurrentCursor = newFollowedStreams['cursor'];
}
_isLoading = false;
}
/// Resets the cursor and then fetches the followed streams.
@action
Future<void> refresh() async {
_followedStreamsCurrentCursor = null;
await getFollowedStreams();
}
}

View file

@ -0,0 +1,50 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'followed_streams_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 _$FollowedStreamsStore on _FollowedStreamsStoreBase, Store {
final _$_followedStreamsAtom =
Atom(name: '_FollowedStreamsStoreBase._followedStreams');
@override
ObservableList<StreamTwitch> get _followedStreams {
_$_followedStreamsAtom.reportRead();
return super._followedStreams;
}
@override
set _followedStreams(ObservableList<StreamTwitch> value) {
_$_followedStreamsAtom.reportWrite(value, super._followedStreams, () {
super._followedStreams = value;
});
}
final _$getFollowedStreamsAsyncAction =
AsyncAction('_FollowedStreamsStoreBase.getFollowedStreams');
@override
Future<void> getFollowedStreams() {
return _$getFollowedStreamsAsyncAction
.run(() => super.getFollowedStreams());
}
final _$refreshAsyncAction = AsyncAction('_FollowedStreamsStoreBase.refresh');
@override
Future<void> refresh() {
return _$refreshAsyncAction.run(() => super.refresh());
}
@override
String toString() {
return '''
''';
}
}

View file

@ -0,0 +1,30 @@
import 'package:flutter/material.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:frosty/screens/stream_list/stream_card.dart';
import 'package:frosty/screens/stream_list/streams_top/top_streams_store.dart';
class TopStreams extends StatelessWidget {
final TopStreamsStore store;
const TopStreams({Key? key, required this.store}) : super(key: key);
@override
Widget build(BuildContext context) {
return RefreshIndicator(
onRefresh: store.refresh,
child: Observer(
builder: (_) {
return ListView.builder(
itemCount: store.topStreams.length,
itemBuilder: (context, index) {
if (index > store.topStreams.length / 2 && store.hasMore) {
store.getTopStreams();
}
return StreamCard(streamInfo: store.topStreams[index]);
},
);
},
),
);
}
}

View file

@ -0,0 +1,58 @@
import 'package:frosty/api/twitch_api.dart';
import 'package:frosty/core/auth/auth_store.dart';
import 'package:frosty/models/stream.dart';
import 'package:mobx/mobx.dart';
part 'top_streams_store.g.dart';
class TopStreamsStore = _TopStreamsStoreBase with _$TopStreamsStore;
abstract class _TopStreamsStoreBase with Store {
/// The list of the fetched top streams.
@observable
var _topStreams = ObservableList<StreamTwitch>();
ObservableList<StreamTwitch> get topStreams => _topStreams;
/// The loading status for pagination.
bool _isLoading = false;
/// The pagination cursor for top streams.
String? _topStreamsCurrentCursor;
/// The authentication store.
final AuthStore authStore;
/// Returns whether or not there are more streams and loading status for pagination.
bool get hasMore => _isLoading == false && _topStreamsCurrentCursor != null;
_TopStreamsStoreBase({required this.authStore}) {
getTopStreams();
}
/// Fetches the top streams based on the current cursor.
@action
Future<void> getTopStreams() async {
_isLoading = true;
final newTopStreams = await Twitch.getTopStreams(headers: authStore.headersTwitch, cursor: _topStreamsCurrentCursor);
if (newTopStreams != null) {
if (_topStreamsCurrentCursor == null) {
_topStreams = ObservableList.of(newTopStreams['streams']);
} else {
_topStreams.addAll(newTopStreams['streams']);
}
_topStreamsCurrentCursor = newTopStreams['cursor'];
}
_isLoading = false;
}
/// Resets the cursor and then fetches the top streams.
@action
Future<void> refresh() async {
_topStreamsCurrentCursor = null;
await getTopStreams();
}
}

View file

@ -0,0 +1,48 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'top_streams_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 _$TopStreamsStore on _TopStreamsStoreBase, Store {
final _$_topStreamsAtom = Atom(name: '_TopStreamsStoreBase._topStreams');
@override
ObservableList<StreamTwitch> get _topStreams {
_$_topStreamsAtom.reportRead();
return super._topStreams;
}
@override
set _topStreams(ObservableList<StreamTwitch> value) {
_$_topStreamsAtom.reportWrite(value, super._topStreams, () {
super._topStreams = value;
});
}
final _$getTopStreamsAsyncAction =
AsyncAction('_TopStreamsStoreBase.getTopStreams');
@override
Future<void> getTopStreams() {
return _$getTopStreamsAsyncAction.run(() => super.getTopStreams());
}
final _$refreshAsyncAction = AsyncAction('_TopStreamsStoreBase.refresh');
@override
Future<void> refresh() {
return _$refreshAsyncAction.run(() => super.refresh());
}
@override
String toString() {
return '''
''';
}
}

View file

@ -28,7 +28,7 @@ void main() {
''';
final decoded = jsonDecode(sampleStream);
final stream = Stream.fromJson(decoded);
final stream = StreamTwitch.fromJson(decoded);
expect(stream.id, '43809710301');
expect(stream.userId, '71092938');