Refactor & rename relevant channels to streams
This commit is contained in:
parent
bbd810b0c5
commit
49b3385c12
14 changed files with 296 additions and 348 deletions
|
|
@ -4,7 +4,7 @@ A [Twitch](https://www.twitch.tv/) client for iOS and Android, built with [Flutt
|
|||
|
||||
## Features
|
||||
|
||||
- Browse top and followed channels on Twitch
|
||||
- Browse top and followed streams on Twitch
|
||||
- Watch live streams with live chat
|
||||
- Support for BTTV, FFZ, and 7TV emotes, including GIFs
|
||||
|
||||
|
|
|
|||
|
|
@ -141,14 +141,14 @@ class Twitch {
|
|||
return false;
|
||||
}
|
||||
|
||||
/// Returns a map with top 10 streamers and a cursor for further requests.
|
||||
static Future<Map<String, dynamic>?> getTopChannels({required Map<String, String>? headers, required String? cursor}) async {
|
||||
/// Returns a map containing top 20 streams and a cursor for further requests.
|
||||
static Future<Map<String, dynamic>?> getTopStreams({required Map<String, String>? headers, required String? cursor}) async {
|
||||
final Uri uri;
|
||||
|
||||
if (cursor == null) {
|
||||
uri = Uri.parse('https://api.twitch.tv/helix/streams?first=10');
|
||||
uri = Uri.parse('https://api.twitch.tv/helix/streams');
|
||||
} else {
|
||||
uri = Uri.parse('https://api.twitch.tv/helix/streams?first=10&after=$cursor');
|
||||
uri = Uri.parse('https://api.twitch.tv/helix/streams?after=$cursor');
|
||||
}
|
||||
|
||||
final response = await http.get(uri, headers: headers);
|
||||
|
|
@ -157,20 +157,20 @@ class Twitch {
|
|||
final decoded = jsonDecode(response.body);
|
||||
final data = decoded['data'] as List;
|
||||
|
||||
return {'channels': data.map((channel) => Stream.fromJson(channel)).toList(), 'cursor': decoded['pagination']['cursor']};
|
||||
return {'streams': data.map((stream) => Stream.fromJson(stream)).toList(), 'cursor': decoded['pagination']['cursor']};
|
||||
} else {
|
||||
debugPrint('Failed to update top channels');
|
||||
debugPrint('Failed to update top streams');
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a map with the given user's top 10 followed streamers and a cursor for further requests.
|
||||
static Future<Map<String, dynamic>?> getFollowedChannels({required String id, required Map<String, String>? headers, required String? cursor}) async {
|
||||
/// Returns a map with the given user's top 20 followed streams and a cursor for further requests.
|
||||
static Future<Map<String, dynamic>?> getFollowedStreams({required String id, required Map<String, String>? headers, required String? cursor}) async {
|
||||
final Uri uri;
|
||||
|
||||
if (cursor == null) {
|
||||
uri = Uri.parse('https://api.twitch.tv/helix/streams/followed?first=10&user_id=$id');
|
||||
uri = Uri.parse('https://api.twitch.tv/helix/streams/followed?user_id=$id');
|
||||
} else {
|
||||
uri = Uri.parse('https://api.twitch.tv/helix/streams/followed?user_id=$id&first=10&after=$cursor');
|
||||
uri = Uri.parse('https://api.twitch.tv/helix/streams/followed?user_id=$id&after=$cursor');
|
||||
}
|
||||
|
||||
final response = await http.get(uri, headers: headers);
|
||||
|
|
@ -179,9 +179,9 @@ class Twitch {
|
|||
final decoded = jsonDecode(response.body);
|
||||
final data = decoded['data'] as List;
|
||||
|
||||
return {'channels': data.map((channel) => Stream.fromJson(channel)).toList(), 'cursor': decoded['pagination']['cursor']};
|
||||
return {'streams': data.map((stream) => Stream.fromJson(stream)).toList(), 'cursor': decoded['pagination']['cursor']};
|
||||
} else {
|
||||
debugPrint('Failed to update followed channels');
|
||||
debugPrint('Failed to update followed streams');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,54 +1,5 @@
|
|||
import 'package:frosty/models/stream.dart';
|
||||
|
||||
/// Twitch API client ID.
|
||||
const clientId = String.fromEnvironment('CLIENT_ID');
|
||||
|
||||
/// Twitch API client secret.
|
||||
const secret = String.fromEnvironment('SECRET');
|
||||
|
||||
/// Sample channel objects for testing/preview purposes.
|
||||
const sampleChannels = [
|
||||
Stream(
|
||||
id: '',
|
||||
userId: '',
|
||||
userLogin: 'xqcow',
|
||||
userName: 'xQcOW',
|
||||
gameId: '',
|
||||
gameName: 'VALORANT',
|
||||
type: 'live',
|
||||
title: 'TITANIC MAN WITH UNREAL UNMATCHED UNBELIEVABLE STRENGHT AND APTITUDE ACCOMPLISHES ACHIEVEMENTS DEEMED IMPOSSIBLE BY THE PUBLIC (GONE WRONG)',
|
||||
viewerCount: 76422,
|
||||
startedAt: '',
|
||||
language: 'en',
|
||||
thumbnailUrl: 'https://static-cdn.jtvnw.net/previews-ttv/live_user_xqcow-{width}x{height}.jpg',
|
||||
tagIds: [],
|
||||
isMature: false),
|
||||
Stream(
|
||||
id: '',
|
||||
userId: '',
|
||||
userLogin: 'lirik',
|
||||
userName: 'Lirik',
|
||||
gameId: '',
|
||||
gameName: 'Just Chatting',
|
||||
type: 'live',
|
||||
title: 'LOL',
|
||||
viewerCount: 11933,
|
||||
startedAt: '',
|
||||
language: 'en',
|
||||
thumbnailUrl: 'https://static-cdn.jtvnw.net/previews-ttv/live_user_lirik-{width}x{height}.jpg',
|
||||
tagIds: [],
|
||||
isMature: false),
|
||||
Stream(
|
||||
id: '',
|
||||
userId: '',
|
||||
userLogin: 'mizkif',
|
||||
userName: 'Mizkif',
|
||||
gameId: '',
|
||||
gameName: 'Just Chatting',
|
||||
type: 'live',
|
||||
title: 'DRAMA DRAMA DRAMA',
|
||||
viewerCount: 4547,
|
||||
startedAt: '',
|
||||
language: 'en',
|
||||
thumbnailUrl: 'https://static-cdn.jtvnw.net/previews-ttv/live_user_mizkif-{width}x{height}.jpg',
|
||||
tagIds: [],
|
||||
isMature: false)
|
||||
];
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:frosty/screens/home.dart';
|
||||
import 'package:frosty/stores/auth_store.dart';
|
||||
import 'package:frosty/stores/channel_list_store.dart';
|
||||
import 'package:frosty/stores/stream_list_store.dart';
|
||||
import 'package:frosty/stores/home_store.dart';
|
||||
import 'package:frosty/stores/settings_store.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
|
@ -39,7 +39,7 @@ class MyApp extends StatelessWidget {
|
|||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState == ConnectionState.done) {
|
||||
return Home(
|
||||
channelListStore: ChannelListStore(authStore: context.read<AuthStore>()),
|
||||
streamListStore: StreamListStore(authStore: context.read<AuthStore>()),
|
||||
homeStore: HomeStore(),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,21 +3,21 @@ import 'package:flutter_mobx/flutter_mobx.dart';
|
|||
import 'package:frosty/screens/search.dart';
|
||||
import 'package:frosty/screens/settings.dart';
|
||||
import 'package:frosty/stores/auth_store.dart';
|
||||
import 'package:frosty/stores/channel_list_store.dart';
|
||||
import 'package:frosty/stores/stream_list_store.dart';
|
||||
import 'package:frosty/stores/home_store.dart';
|
||||
import 'package:frosty/stores/settings_store.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../widgets/channel_list.dart';
|
||||
import '../widgets/stream_list.dart';
|
||||
|
||||
class Home extends StatefulWidget {
|
||||
final HomeStore homeStore;
|
||||
final ChannelListStore channelListStore;
|
||||
final StreamListStore streamListStore;
|
||||
|
||||
const Home({
|
||||
Key? key,
|
||||
required this.homeStore,
|
||||
required this.channelListStore,
|
||||
required this.streamListStore,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
|
|
@ -30,8 +30,8 @@ class _HomeState extends State<Home> {
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final titles = [
|
||||
if (context.read<AuthStore>().isLoggedIn) 'Followed Channels',
|
||||
'Top Channels',
|
||||
if (context.read<AuthStore>().isLoggedIn) 'Followed Streams',
|
||||
'Top Streams',
|
||||
'Categories',
|
||||
'Search',
|
||||
];
|
||||
|
|
@ -78,13 +78,13 @@ class _HomeState extends State<Home> {
|
|||
index: widget.homeStore.selectedIndex,
|
||||
children: [
|
||||
if (context.read<AuthStore>().isLoggedIn)
|
||||
ChannelList(
|
||||
category: ChannelCategory.followed,
|
||||
channelListStore: widget.channelListStore,
|
||||
StreamList(
|
||||
category: StreamCategory.followed,
|
||||
streamListStore: widget.streamListStore,
|
||||
),
|
||||
ChannelList(
|
||||
category: ChannelCategory.top,
|
||||
channelListStore: widget.channelListStore,
|
||||
StreamList(
|
||||
category: StreamCategory.top,
|
||||
streamListStore: widget.streamListStore,
|
||||
),
|
||||
const Center(
|
||||
child: Text('Games'),
|
||||
|
|
|
|||
|
|
@ -1,113 +0,0 @@
|
|||
import 'package:frosty/api/twitch_api.dart';
|
||||
import 'package:mobx/mobx.dart';
|
||||
import 'package:frosty/stores/auth_store.dart';
|
||||
import 'package:frosty/models/stream.dart';
|
||||
|
||||
part 'channel_list_store.g.dart';
|
||||
|
||||
// TODO: Fix followed list refresh when log in.
|
||||
class ChannelListStore = _ChannelListBase with _$ChannelListStore;
|
||||
|
||||
abstract class _ChannelListBase with Store {
|
||||
/// The list of the fetched top channels
|
||||
@observable
|
||||
var _topChannels = ObservableList<Stream>();
|
||||
|
||||
/// The list of the fetched followed channels
|
||||
@observable
|
||||
var _followedChannels = ObservableList<Stream>();
|
||||
|
||||
/// The loading status for pagination.
|
||||
bool _isLoading = false;
|
||||
|
||||
/// The pagination cursor for top channels.
|
||||
String? _topChannelsCurrentCursor;
|
||||
|
||||
/// The pagination cursor for followed channels.
|
||||
String? _followedChannelsCurrentCursor;
|
||||
|
||||
/// The authentication store.
|
||||
final AuthStore authStore;
|
||||
|
||||
_ChannelListBase({required this.authStore}) {
|
||||
getChannels(category: ChannelCategory.top);
|
||||
|
||||
if (authStore.user?.id != null) {
|
||||
getChannels(category: ChannelCategory.followed);
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the appropriate channels the [category].
|
||||
ObservableList<Stream> channels({required ChannelCategory category}) {
|
||||
switch (category) {
|
||||
case ChannelCategory.top:
|
||||
return _topChannels;
|
||||
case ChannelCategory.followed:
|
||||
return _followedChannels;
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns whether or not there are more channels for the [category].
|
||||
bool hasMore({required ChannelCategory category}) {
|
||||
switch (category) {
|
||||
case ChannelCategory.top:
|
||||
return _isLoading == false && _topChannelsCurrentCursor != null;
|
||||
case ChannelCategory.followed:
|
||||
return _isLoading == false && _followedChannelsCurrentCursor != null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Resets the cursor and then fetches the channels for the [category].
|
||||
@action
|
||||
Future<void> refresh({required ChannelCategory category}) async {
|
||||
switch (category) {
|
||||
case ChannelCategory.top:
|
||||
_topChannelsCurrentCursor = null;
|
||||
break;
|
||||
case ChannelCategory.followed:
|
||||
_followedChannelsCurrentCursor = null;
|
||||
break;
|
||||
}
|
||||
|
||||
await getChannels(category: category);
|
||||
}
|
||||
|
||||
/// Fetches the channels for the [category] based on the current cursor.
|
||||
@action
|
||||
Future<void> getChannels({required ChannelCategory category}) async {
|
||||
_isLoading = true;
|
||||
|
||||
switch (category) {
|
||||
case ChannelCategory.top:
|
||||
final newTopChannels = await Twitch.getTopChannels(headers: authStore.headersTwitch, cursor: _topChannelsCurrentCursor);
|
||||
|
||||
if (newTopChannels != null) {
|
||||
if (_topChannelsCurrentCursor == null) {
|
||||
_topChannels = ObservableList.of(newTopChannels['channels']);
|
||||
} else {
|
||||
_topChannels.addAll(newTopChannels['channels']);
|
||||
}
|
||||
_topChannelsCurrentCursor = newTopChannels['cursor'];
|
||||
}
|
||||
break;
|
||||
case ChannelCategory.followed:
|
||||
final newFollowedChannels =
|
||||
await Twitch.getFollowedChannels(id: authStore.user!.id, headers: authStore.headersTwitch, cursor: _followedChannelsCurrentCursor);
|
||||
|
||||
if (newFollowedChannels != null) {
|
||||
if (_followedChannelsCurrentCursor == null) {
|
||||
_followedChannels = ObservableList.of(newFollowedChannels['channels']);
|
||||
} else {
|
||||
_followedChannels.addAll(newFollowedChannels['channels']);
|
||||
}
|
||||
_followedChannelsCurrentCursor = newFollowedChannels['cursor'];
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
_isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// The type of category for channels.
|
||||
enum ChannelCategory { top, followed }
|
||||
|
|
@ -1,64 +0,0 @@
|
|||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'channel_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 _$ChannelListStore on _ChannelListBase, Store {
|
||||
final _$_topChannelsAtom = Atom(name: '_ChannelListBase._topChannels');
|
||||
|
||||
@override
|
||||
ObservableList<Stream> get _topChannels {
|
||||
_$_topChannelsAtom.reportRead();
|
||||
return super._topChannels;
|
||||
}
|
||||
|
||||
@override
|
||||
set _topChannels(ObservableList<Stream> value) {
|
||||
_$_topChannelsAtom.reportWrite(value, super._topChannels, () {
|
||||
super._topChannels = value;
|
||||
});
|
||||
}
|
||||
|
||||
final _$_followedChannelsAtom =
|
||||
Atom(name: '_ChannelListBase._followedChannels');
|
||||
|
||||
@override
|
||||
ObservableList<Stream> get _followedChannels {
|
||||
_$_followedChannelsAtom.reportRead();
|
||||
return super._followedChannels;
|
||||
}
|
||||
|
||||
@override
|
||||
set _followedChannels(ObservableList<Stream> value) {
|
||||
_$_followedChannelsAtom.reportWrite(value, super._followedChannels, () {
|
||||
super._followedChannels = value;
|
||||
});
|
||||
}
|
||||
|
||||
final _$refreshAsyncAction = AsyncAction('_ChannelListBase.refresh');
|
||||
|
||||
@override
|
||||
Future<void> refresh({required ChannelCategory category}) {
|
||||
return _$refreshAsyncAction.run(() => super.refresh(category: category));
|
||||
}
|
||||
|
||||
final _$getChannelsAsyncAction = AsyncAction('_ChannelListBase.getChannels');
|
||||
|
||||
@override
|
||||
Future<void> getChannels({required ChannelCategory category}) {
|
||||
return _$getChannelsAsyncAction
|
||||
.run(() => super.getChannels(category: category));
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return '''
|
||||
|
||||
''';
|
||||
}
|
||||
}
|
||||
113
lib/stores/stream_list_store.dart
Normal file
113
lib/stores/stream_list_store.dart
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
import 'package:frosty/api/twitch_api.dart';
|
||||
import 'package:mobx/mobx.dart';
|
||||
import 'package:frosty/stores/auth_store.dart';
|
||||
import 'package:frosty/models/stream.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 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 }
|
||||
63
lib/stores/stream_list_store.g.dart
Normal file
63
lib/stores/stream_list_store.g.dart
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
// 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 '''
|
||||
|
||||
''';
|
||||
}
|
||||
}
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_mobx/flutter_mobx.dart';
|
||||
import 'package:frosty/stores/channel_list_store.dart';
|
||||
import 'package:frosty/widgets/channel_card.dart';
|
||||
|
||||
class ChannelList extends StatelessWidget {
|
||||
final ChannelCategory category;
|
||||
final ChannelListStore channelListStore;
|
||||
|
||||
const ChannelList({Key? key, required this.category, required this.channelListStore}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
debugPrint('Build channel list!');
|
||||
|
||||
return RefreshIndicator(
|
||||
child: Observer(
|
||||
builder: (_) {
|
||||
return ListView.builder(
|
||||
itemCount: channelListStore.channels(category: category).length,
|
||||
padding: const EdgeInsets.all(5.0),
|
||||
itemBuilder: (context, index) {
|
||||
if (index > channelListStore.channels(category: category).length / 2 && channelListStore.hasMore(category: category)) {
|
||||
channelListStore.getChannels(category: category);
|
||||
}
|
||||
return ChannelCard(channelInfo: channelListStore.channels(category: category).elementAt(index));
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
onRefresh: () => channelListStore.refresh(category: category),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -4,11 +4,11 @@ import 'package:frosty/models/stream.dart';
|
|||
import 'package:frosty/screens/video_chat.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
/// A card widget that displays a live channel's thumbnail and details.
|
||||
class ChannelCard extends StatelessWidget {
|
||||
final Stream channelInfo;
|
||||
/// A tappable card widget that displays a stream's thumbnail and details.
|
||||
class StreamCard extends StatelessWidget {
|
||||
final Stream streamInfo;
|
||||
|
||||
const ChannelCard({Key? key, required this.channelInfo}) : super(key: key);
|
||||
const StreamCard({Key? key, required this.streamInfo}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
|
@ -20,8 +20,8 @@ class ChannelCard extends StatelessWidget {
|
|||
MaterialPageRoute(
|
||||
builder: (context) {
|
||||
return VideoChat(
|
||||
userLogin: channelInfo.userLogin,
|
||||
userName: channelInfo.userName,
|
||||
userLogin: streamInfo.userLogin,
|
||||
userName: streamInfo.userName,
|
||||
);
|
||||
},
|
||||
),
|
||||
|
|
@ -36,7 +36,7 @@ class ChannelCard extends StatelessWidget {
|
|||
child: Padding(
|
||||
padding: const EdgeInsets.only(left: 10.0),
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: channelInfo.thumbnailUrl.replaceFirst('-{width}x{height}', '-440x248'),
|
||||
imageUrl: streamInfo.thumbnailUrl.replaceFirst('-{width}x{height}', '-440x248'),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
@ -49,22 +49,22 @@ class ChannelCard extends StatelessWidget {
|
|||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
channelInfo.userName,
|
||||
streamInfo.userName,
|
||||
style: const TextStyle(fontSize: 16),
|
||||
),
|
||||
const SizedBox(height: 5),
|
||||
SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Text(channelInfo.title.replaceAll('\n', '')),
|
||||
child: Text(streamInfo.title.replaceAll('\n', '')),
|
||||
),
|
||||
const SizedBox(height: 5),
|
||||
Text(
|
||||
channelInfo.gameName,
|
||||
streamInfo.gameName,
|
||||
style: const TextStyle(fontSize: 12),
|
||||
),
|
||||
const SizedBox(height: 5),
|
||||
Text(
|
||||
'${NumberFormat().format(channelInfo.viewerCount)} viewers',
|
||||
'${NumberFormat().format(streamInfo.viewerCount)} viewers',
|
||||
style: const TextStyle(fontSize: 12),
|
||||
),
|
||||
],
|
||||
32
lib/widgets/stream_list.dart
Normal file
32
lib/widgets/stream_list.dart
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_mobx/flutter_mobx.dart';
|
||||
import 'package:frosty/stores/stream_list_store.dart';
|
||||
import 'package:frosty/widgets/stream_card.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(
|
||||
child: Observer(
|
||||
builder: (_) {
|
||||
return ListView.builder(
|
||||
itemCount: streamListStore.streams(category: category).length,
|
||||
padding: const EdgeInsets.all(5.0),
|
||||
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).elementAt(index));
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
onRefresh: () => streamListStore.refresh(category: category),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,48 +0,0 @@
|
|||
import 'dart:convert';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:frosty/models/stream.dart';
|
||||
|
||||
void main() {
|
||||
test('Twitch channel should parse', () {
|
||||
const sampleChannel = '''
|
||||
{
|
||||
"id":"43809710301",
|
||||
"user_id":"71092938",
|
||||
"user_login":"xqcow",
|
||||
"user_name":"xQcOW",
|
||||
"game_id":"508292",
|
||||
"game_name":"Bloons TD 6",
|
||||
"type":"live",
|
||||
"title":"A shorter title...",
|
||||
"viewer_count":50051,
|
||||
"started_at":"2021-09-23T20:06:22Z",
|
||||
"language":"en",
|
||||
"thumbnail_url":"https://static-cdn.jtvnw.net/previews-ttv/live_user_xqcow-{width}x{height}.jpg",
|
||||
"tag_ids":[
|
||||
"e6bb8b34-4c28-4b5f-94ed-12c1ebf2d0e4",
|
||||
"6ea6bca4-4712-4ab9-a906-e3336a9d8039",
|
||||
"6606e54c-f92d-40f6-8257-74977889ccdd"
|
||||
],
|
||||
"is_mature":false
|
||||
}
|
||||
''';
|
||||
|
||||
final decoded = jsonDecode(sampleChannel);
|
||||
final channel = Stream.fromJson(decoded);
|
||||
|
||||
expect(channel.id, '43809710301');
|
||||
expect(channel.userId, '71092938');
|
||||
expect(channel.userLogin, 'xqcow');
|
||||
expect(channel.userName, 'xQcOW');
|
||||
expect(channel.gameId, '508292');
|
||||
expect(channel.gameName, 'Bloons TD 6');
|
||||
expect(channel.type, 'live');
|
||||
expect(channel.title, 'A shorter title...');
|
||||
expect(channel.viewerCount, 50051);
|
||||
expect(channel.startedAt, '2021-09-23T20:06:22Z');
|
||||
expect(channel.language, 'en');
|
||||
expect(channel.thumbnailUrl, 'https://static-cdn.jtvnw.net/previews-ttv/live_user_xqcow-{width}x{height}.jpg');
|
||||
expect(channel.tagIds, ["e6bb8b34-4c28-4b5f-94ed-12c1ebf2d0e4", "6ea6bca4-4712-4ab9-a906-e3336a9d8039", "6606e54c-f92d-40f6-8257-74977889ccdd"]);
|
||||
expect(channel.isMature, false);
|
||||
});
|
||||
}
|
||||
48
test/stream_test.dart
Normal file
48
test/stream_test.dart
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import 'dart:convert';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:frosty/models/stream.dart';
|
||||
|
||||
void main() {
|
||||
test('Twitch stream should parse correctly', () {
|
||||
const sampleStream = '''
|
||||
{
|
||||
"id":"43809710301",
|
||||
"user_id":"71092938",
|
||||
"user_login":"xqcow",
|
||||
"user_name":"xQcOW",
|
||||
"game_id":"508292",
|
||||
"game_name":"Bloons TD 6",
|
||||
"type":"live",
|
||||
"title":"A shorter title...",
|
||||
"viewer_count":50051,
|
||||
"started_at":"2021-09-23T20:06:22Z",
|
||||
"language":"en",
|
||||
"thumbnail_url":"https://static-cdn.jtvnw.net/previews-ttv/live_user_xqcow-{width}x{height}.jpg",
|
||||
"tag_ids":[
|
||||
"e6bb8b34-4c28-4b5f-94ed-12c1ebf2d0e4",
|
||||
"6ea6bca4-4712-4ab9-a906-e3336a9d8039",
|
||||
"6606e54c-f92d-40f6-8257-74977889ccdd"
|
||||
],
|
||||
"is_mature":false
|
||||
}
|
||||
''';
|
||||
|
||||
final decoded = jsonDecode(sampleStream);
|
||||
final stream = Stream.fromJson(decoded);
|
||||
|
||||
expect(stream.id, '43809710301');
|
||||
expect(stream.userId, '71092938');
|
||||
expect(stream.userLogin, 'xqcow');
|
||||
expect(stream.userName, 'xQcOW');
|
||||
expect(stream.gameId, '508292');
|
||||
expect(stream.gameName, 'Bloons TD 6');
|
||||
expect(stream.type, 'live');
|
||||
expect(stream.title, 'A shorter title...');
|
||||
expect(stream.viewerCount, 50051);
|
||||
expect(stream.startedAt, '2021-09-23T20:06:22Z');
|
||||
expect(stream.language, 'en');
|
||||
expect(stream.thumbnailUrl, 'https://static-cdn.jtvnw.net/previews-ttv/live_user_xqcow-{width}x{height}.jpg');
|
||||
expect(stream.tagIds, ["e6bb8b34-4c28-4b5f-94ed-12c1ebf2d0e4", "6ea6bca4-4712-4ab9-a906-e3336a9d8039", "6606e54c-f92d-40f6-8257-74977889ccdd"]);
|
||||
expect(stream.isMature, false);
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue