Merge pull request #150 from tommyxchow/90-redesign

Redesign and tweak various widgets and styles
This commit is contained in:
Tommy Chow 2022-07-03 02:00:07 -04:00 • committed by GitHub
commit c911767008
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
59 changed files with 2113 additions and 1214 deletions

View file

@ -21,7 +21,7 @@ class BTTVApi {
return emotes.map((emote) => Emote.fromBTTV(emote, EmoteType.bttvGlobal)).toList();
} else {
return Future.error('Failed to get BTTV global emotes.');
return Future.error('Failed to get BTTV global emotes');
}
}
@ -40,7 +40,7 @@ class BTTVApi {
return emoteToUrl;
} else {
return Future.error('Failed to get BTTV channel emotes.');
return Future.error('Failed to get BTTV channel emotes');
}
}

View file

@ -28,7 +28,7 @@ class FFZApi {
return emotes.map((emote) => Emote.fromFFZ(emote, EmoteType.ffzGlobal)).toList();
} else {
return Future.error('Failed to get FFZ global emotes.');
return Future.error('Failed to get FFZ global emotes');
}
}
@ -46,7 +46,7 @@ class FFZApi {
return Tuple2(roomInfo, emotes.map((emote) => Emote.fromFFZ(emote, EmoteType.ffzChannel)).toList());
} else {
return Future.error('Failed to get FFZ room info.');
return Future.error('Failed to get FFZ room info');
}
}
@ -75,7 +75,7 @@ class FFZApi {
return result;
} else {
return Future.error('Failed to get FFZ badges.');
return Future.error('Failed to get FFZ badges');
}
}
}

View file

@ -64,7 +64,7 @@ class SevenTVApi {
return result;
} else {
return Future.error('Failed to get 7TV badges.');
return Future.error('Failed to get 7TV badges');
}
}
}

View file

@ -28,7 +28,7 @@ class TwitchApi {
return emotes.map((emote) => Emote.fromTwitch(emote, EmoteType.twitchGlobal)).toList();
} else {
return Future.error('Failed to get Twitch global emotes.');
return Future.error('Failed to get Twitch global emotes');
}
}
@ -57,7 +57,7 @@ class TwitchApi {
}
}).toList();
} else {
return Future.error('Failed to get Twitch channel emotes.');
return Future.error('Failed to get Twitch channel emotes');
}
}
@ -85,7 +85,7 @@ class TwitchApi {
}
}).toList();
} else {
return Future.error('Failed to get Twitch emotes set.');
return Future.error('Failed to get Twitch emotes set');
}
}
@ -97,12 +97,12 @@ class TwitchApi {
if (response.statusCode == 200) {
final result = <String, Badge>{};
final decoded = jsonDecode(response.body)['badge_sets'] as Map;
decoded.forEach((id, versions) => (versions['versions'] as Map)
.forEach((version, badgeInfo) => result['$id/$version'] = Badge.fromTwitch(BadgeInfoTwitch.fromJson(badgeInfo))));
decoded.forEach((id, versions) =>
(versions['versions'] as Map).forEach((version, badgeInfo) => result['$id/$version'] = Badge.fromTwitch(BadgeInfoTwitch.fromJson(badgeInfo))));
return result;
} else {
return Future.error('Failed to get Twitch global badges.');
return Future.error('Failed to get Twitch global badges');
}
}
@ -114,12 +114,12 @@ class TwitchApi {
if (response.statusCode == 200) {
final result = <String, Badge>{};
final decoded = jsonDecode(response.body)['badge_sets'] as Map;
decoded.forEach((id, versions) => (versions['versions'] as Map)
.forEach((version, badgeInfo) => result['$id/$version'] = Badge.fromTwitch(BadgeInfoTwitch.fromJson(badgeInfo))));
decoded.forEach((id, versions) =>
(versions['versions'] as Map).forEach((version, badgeInfo) => result['$id/$version'] = Badge.fromTwitch(BadgeInfoTwitch.fromJson(badgeInfo))));
return result;
} else {
return Future.error('Failed to get Twitch channel badges.');
return Future.error('Failed to get Twitch channel badges');
}
}
@ -154,7 +154,7 @@ class TwitchApi {
if (response.statusCode == 200) {
return jsonDecode(response.body)['access_token'];
} else {
return Future.error('Failed to get default token.');
return Future.error('Failed to get default token');
}
}
@ -164,10 +164,8 @@ class TwitchApi {
final response = await _client.get(url, headers: {'Authorization': 'Bearer $token'});
if (response.statusCode == 200) {
debugPrint('Token validated!');
return true;
} else {
debugPrint('Token invalidated :(');
return false;
}
}
@ -194,9 +192,8 @@ class TwitchApi {
required Map<String, String> headers,
String? cursor,
}) async {
final url = Uri.parse(cursor == null
? 'https://api.twitch.tv/helix/streams/followed?user_id=$id'
: 'https://api.twitch.tv/helix/streams/followed?user_id=$id&after=$cursor');
final url = Uri.parse(
cursor == null ? 'https://api.twitch.tv/helix/streams/followed?user_id=$id' : 'https://api.twitch.tv/helix/streams/followed?user_id=$id&after=$cursor');
final response = await _client.get(url, headers: headers);
final decoded = jsonDecode(response.body);
@ -213,8 +210,8 @@ class TwitchApi {
required Map<String, String> headers,
String? cursor,
}) async {
final url = Uri.parse(
cursor == null ? 'https://api.twitch.tv/helix/streams?game_id=$gameId' : 'https://api.twitch.tv/helix/streams?game_id=$gameId&after=$cursor');
final url =
Uri.parse(cursor == null ? 'https://api.twitch.tv/helix/streams?game_id=$gameId' : 'https://api.twitch.tv/helix/streams?game_id=$gameId&after=$cursor');
final response = await _client.get(url, headers: headers);
final decoded = jsonDecode(response.body);
@ -243,7 +240,7 @@ class TwitchApi {
return Future.error('$userLogin is offline');
}
} else {
return Future.error('Failed to get stream info.');
return Future.error('Failed to get stream info');
}
}
@ -264,7 +261,7 @@ class TwitchApi {
if (userData.isNotEmpty) {
return UserTwitch.fromJson(userData.first);
} else {
return Future.error('User does not exist!');
return Future.error('User does not exist');
}
} else {
return Future.error('Failed to get user: ${decoded['message']}');
@ -306,7 +303,7 @@ class TwitchApi {
return channelData.map((e) => ChannelQuery.fromJson(e)).toList();
} else {
return Future.error('Failed to get channels.');
return Future.error('Failed to get channels');
}
}
@ -358,7 +355,7 @@ class TwitchApi {
return CategoriesTwitch.fromJson(decoded);
} else {
return Future.error('Failed to get categories.');
return Future.error('Failed to get categories');
}
}
@ -375,7 +372,7 @@ class TwitchApi {
return decoded['total'] as int;
} else {
return Future.error('Failed to get sub count.');
return Future.error('Failed to get sub count');
}
}
@ -389,7 +386,7 @@ class TwitchApi {
return ChatUsers.fromJson(decoded);
} else {
return Future.error('Failed to get chatters.');
return Future.error('Failed to get chatters');
}
}
@ -422,7 +419,7 @@ class TwitchApi {
return [];
}
} else {
return Future.error('User does not exist!');
return Future.error('User does not exist');
}
}

View file

@ -78,6 +78,23 @@ final navigatorKey = GlobalKey<NavigatorState>();
const gray = Color.fromRGBO(22, 22, 22, 1.0);
const purple = Color(0xff9146ff);
const inputTheme = InputDecorationTheme(
filled: true,
contentPadding: EdgeInsets.all(10.0),
border: OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(30.0)),
borderSide: BorderSide(style: BorderStyle.none),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(30.0)),
borderSide: BorderSide(style: BorderStyle.none),
),
disabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(30.0)),
borderSide: BorderSide(style: BorderStyle.none),
),
);
final lightTheme = ThemeData(
canvasColor: Colors.white,
splashFactory: Platform.isIOS ? NoSplash.splashFactory : null,
@ -102,6 +119,7 @@ final lightTheme = ThemeData(
labelColor: Colors.black,
unselectedLabelColor: Colors.grey,
),
inputDecorationTheme: inputTheme,
);
final darkTheme = ThemeData(
@ -126,6 +144,7 @@ final darkTheme = ThemeData(
),
dialogBackgroundColor: gray,
toggleableActiveColor: purple,
inputDecorationTheme: inputTheme,
);
final oledTheme = ThemeData(
@ -148,6 +167,7 @@ final oledTheme = ThemeData(
),
dialogBackgroundColor: Colors.black,
toggleableActiveColor: purple,
inputDecorationTheme: inputTheme,
);
class MyApp extends StatelessWidget {

View file

@ -1,10 +1,12 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:frosty/screens/channel/chat/emote_menu/emote_menu.dart';
import 'package:frosty/screens/channel/chat/widgets/chat_bottom_bar.dart';
import 'package:frosty/screens/channel/chat/widgets/chat_message.dart';
import 'package:frosty/screens/channel/chat/widgets/chat_user_modal.dart';
import 'package:frosty/screens/channel/stores/chat_store.dart';
import 'package:frosty/widgets/button.dart';
class Chat extends StatelessWidget {
final ChatStore chatStore;
@ -15,10 +17,6 @@ class Chat extends StatelessWidget {
Widget build(BuildContext context) {
return Observer(
builder: (context) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (chatStore.scrollController.hasClients) chatStore.scrollController.jumpTo(chatStore.scrollController.position.maxScrollExtent);
});
return Column(
children: [
Expanded(
@ -68,15 +66,21 @@ class Chat extends StatelessWidget {
FocusScope.of(context).unfocus();
if (chatStore.assetsStore.showEmoteMenu) chatStore.assetsStore.showEmoteMenu = false;
},
onLongPress: () => showModalBottomSheet(
context: context,
builder: (context) => ChatUserModal(
chatStore: chatStore,
username: message.user!,
userId: message.tags['user-id']!,
displayName: message.tags['display-name']!,
),
),
onLongPress: () {
HapticFeedback.lightImpact();
showModalBottomSheet(
backgroundColor: Colors.transparent,
isScrollControlled: true,
context: context,
builder: (context) => ChatUserModal(
chatStore: chatStore,
username: message.user!,
userId: message.tags['user-id']!,
displayName: message.tags['display-name']!,
),
);
},
child: chatMessage,
);
}
@ -88,20 +92,22 @@ class Chat extends StatelessWidget {
),
),
),
Observer(
builder: (_) => AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
child: chatStore.autoScroll
? null
: Container(
padding: const EdgeInsets.symmetric(horizontal: 10.0),
width: double.infinity,
child: ElevatedButton.icon(
Padding(
padding: const EdgeInsets.all(5.0),
child: Observer(
builder: (_) => AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
switchInCurve: Curves.easeOutCubic,
switchOutCurve: Curves.easeInCubic,
child: chatStore.autoScroll
? null
: Button(
padding: const EdgeInsets.symmetric(horizontal: 15.0, vertical: 10.0),
onPressed: chatStore.resumeScroll,
label: const Text('Resume Scroll'),
icon: const Icon(Icons.arrow_circle_down),
child: const Text('Resume Scroll'),
),
),
),
),
),
],
@ -109,11 +115,18 @@ class Chat extends StatelessWidget {
),
),
if (chatStore.settings.showBottomBar) ChatBottomBar(chatStore: chatStore),
if (chatStore.assetsStore.showEmoteMenu)
SizedBox(
height: MediaQuery.of(context).size.height / 3,
AnimatedContainer(
curve: Curves.ease,
duration: const Duration(milliseconds: 200),
height: chatStore.assetsStore.showEmoteMenu ? MediaQuery.of(context).size.height / 3 : 0,
child: AnimatedOpacity(
curve: Curves.ease,
opacity: chatStore.assetsStore.showEmoteMenu ? 1 : 0,
duration: const Duration(milliseconds: 200),
child: EmoteMenu(chatStore: chatStore),
),
onEnd: () => chatStore.scrollController.jumpTo(chatStore.scrollController.position.maxScrollExtent),
),
],
);
},

View file

@ -4,39 +4,43 @@ import 'package:frosty/screens/channel/chat/details/chat_modes.dart';
import 'package:frosty/screens/channel/chat/details/chat_users_list.dart';
import 'package:frosty/screens/channel/stores/chat_details_store.dart';
import 'package:frosty/screens/channel/stores/chat_store.dart';
import 'package:frosty/widgets/modal.dart';
class ChatDetails extends StatelessWidget {
final ChatDetailsStore chatDetails;
final ChatDetailsStore chatDetailsStore;
final ChatStore chatStore;
final String userLogin;
const ChatDetails({
Key? key,
required this.chatDetails,
required this.chatDetailsStore,
required this.chatStore,
required this.userLogin,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: FocusScope.of(context).unfocus,
child: SafeArea(
child: Observer(
builder: (_) => Column(
children: [
Padding(
padding: const EdgeInsets.symmetric(vertical: 15.0),
child: ChatModes(roomState: chatDetails.roomState),
),
Expanded(
child: ChattersList(
chatDetails: chatDetails,
chatStore: chatStore,
userLogin: userLogin,
return FrostyModal(
child: SizedBox(
height: MediaQuery.of(context).size.height * 0.8,
child: GestureDetector(
onTap: FocusScope.of(context).unfocus,
child: Observer(
builder: (_) => Column(
children: [
Padding(
padding: const EdgeInsets.symmetric(vertical: 15.0),
child: ChatModes(roomState: chatDetailsStore.roomState),
),
),
],
Expanded(
child: ChattersList(
chatDetailsStore: chatDetailsStore,
chatStore: chatStore,
userLogin: userLogin,
),
),
],
),
),
),
),

View file

@ -1,19 +1,26 @@
import 'package:flutter/material.dart';
import 'package:frosty/models/irc.dart';
import 'package:intl/intl.dart';
class ChatModes extends StatelessWidget {
final ROOMSTATE roomState;
const ChatModes({Key? key, required this.roomState}) : super(key: key);
String pluralize(String str, String count) => count == '1' ? str : '${str}s';
@override
Widget build(BuildContext context) {
final test = Intl.plural(2, other: 'test');
debugPrint(test);
return Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
Tooltip(
preferBelow: false,
message: 'Emote-Only Mode: ${roomState.emoteOnly != '0' ? 'On' : 'Off'}',
message: 'Emote-only mode ${roomState.emoteOnly != '0' ? 'on' : 'off'}',
child: Icon(
Icons.emoji_emotions_outlined,
color: roomState.emoteOnly != '0' ? Colors.yellow : Colors.grey,
@ -22,12 +29,10 @@ class ChatModes extends StatelessWidget {
Tooltip(
preferBelow: false,
message: roomState.followersOnly == '-1'
? 'Followers-Only Mode: Off'
? 'Followers-only mode off'
: roomState.followersOnly == '0'
? 'Followers-Only Mode: On'
: roomState.followersOnly == '1'
? 'Followers-Only Mode: On (${roomState.followersOnly} minute)'
: 'Followers-Only Mode: On (${roomState.followersOnly} minutes)',
? 'Followers-only mode on'
: 'Followers-only mode on (${roomState.followersOnly} ${pluralize('minute', roomState.followersOnly)})',
child: Icon(
Icons.favorite,
color: roomState.followersOnly != '-1' ? Colors.red : Colors.grey,
@ -35,7 +40,7 @@ class ChatModes extends StatelessWidget {
),
Tooltip(
preferBelow: false,
message: 'R9K Mode: ${roomState.r9k != '0' ? 'On' : 'Off'}',
message: 'R9K mode ${roomState.r9k != '0' ? 'on' : 'off'}',
child: Text(
'R9K',
style: TextStyle(color: roomState.r9k != '0' ? Colors.purple : Colors.grey),
@ -43,7 +48,7 @@ class ChatModes extends StatelessWidget {
),
Tooltip(
preferBelow: false,
message: 'Slow Mode: ${roomState.slowMode != '0' ? 'On (${roomState.slowMode} seconds)' : 'Off'}',
message: 'Slow mode ${roomState.slowMode != '0' ? 'on (${roomState.slowMode} ${pluralize('second', roomState.slowMode)})' : 'off'}',
child: Icon(
Icons.history_toggle_off,
color: roomState.slowMode != '0' ? Colors.blue : Colors.grey,
@ -51,7 +56,7 @@ class ChatModes extends StatelessWidget {
),
Tooltip(
preferBelow: false,
message: 'Subs-Only Mode: ${roomState.subMode != '0' ? 'On' : 'Off'}',
message: 'Subs-only mode ${roomState.subMode != '0' ? 'on' : 'off'}',
child: Icon(
Icons.attach_money,
color: roomState.subMode != '0' ? Colors.green : Colors.grey,

View file

@ -7,46 +7,26 @@ import 'package:frosty/core/auth/auth_store.dart';
import 'package:frosty/screens/channel/chat/widgets/chat_user_modal.dart';
import 'package:frosty/screens/channel/stores/chat_details_store.dart';
import 'package:frosty/screens/channel/stores/chat_store.dart';
import 'package:frosty/widgets/alert_message.dart';
import 'package:frosty/widgets/button.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:intl/intl.dart';
import 'package:provider/provider.dart';
class ChattersList extends StatefulWidget {
final ChatDetailsStore chatDetails;
class ChattersList extends StatelessWidget {
final ChatDetailsStore chatDetailsStore;
final ChatStore chatStore;
final String userLogin;
const ChattersList({
Key? key,
required this.chatDetails,
required this.chatDetailsStore,
required this.chatStore,
required this.userLogin,
}) : super(key: key);
@override
State<ChattersList> createState() => _ChattersListState();
}
class _ChattersListState extends State<ChattersList> {
final _scrollController = ScrollController();
final _textController = TextEditingController();
@override
void initState() {
super.initState();
_scrollController.addListener(() {
if (_scrollController.position.atEdge || _scrollController.position.outOfRange) {
widget.chatDetails.showJumpButton = false;
} else {
widget.chatDetails.showJumpButton = true;
}
});
widget.chatDetails.updateChatters(widget.userLogin);
}
@override
Widget build(BuildContext context) {
const headers = [
@ -59,147 +39,173 @@ class _ChattersListState extends State<ChattersList> {
'Viewers',
];
final chatDetailsStore = widget.chatDetails;
return Column(
children: [
Padding(
padding: const EdgeInsets.all(10.0),
child: TextField(
controller: _textController,
autocorrect: false,
onChanged: (text) => chatDetailsStore.filterText = text,
decoration: InputDecoration(
isDense: true,
labelText: 'Filter chatters',
contentPadding: const EdgeInsets.all(10.0),
suffixIcon: IconButton(
tooltip: 'Clear Filter',
onPressed: () {
FocusScope.of(context).unfocus();
chatDetailsStore.filterText = '';
_textController.clear();
},
icon: const Icon(Icons.clear),
return Observer(
builder: (context) {
if (chatDetailsStore.error != null) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const AlertMessage(
message: 'Failed to get chatters',
icon: Icons.error,
),
border: const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(15.0)),
const SizedBox(height: 10.0),
Button(
padding: const EdgeInsets.symmetric(horizontal: 15.0),
onPressed: chatDetailsStore.updateChatters,
child: const Text('Try Again'),
)
],
);
}
if (chatDetailsStore.chatUsers == null) {
return const LoadingIndicator(subtitle: 'Getting chatters...');
}
return Column(
children: [
Padding(
padding: const EdgeInsets.all(10.0),
child: Observer(
builder: (context) {
return TextField(
controller: chatDetailsStore.textController,
focusNode: chatDetailsStore.textFieldFocusNode,
autocorrect: false,
decoration: InputDecoration(
contentPadding: EdgeInsets.zero,
prefixIcon: const Icon(Icons.filter_list),
hintText: 'Filter chatters',
suffixIcon: chatDetailsStore.textFieldFocusNode.hasFocus || chatDetailsStore.filterText.isNotEmpty
? IconButton(
tooltip: chatDetailsStore.filterText.isEmpty ? 'Cancel' : 'Clear',
onPressed: () {
if (chatDetailsStore.filterText.isEmpty) chatDetailsStore.textFieldFocusNode.unfocus();
chatDetailsStore.textController.clear();
},
icon: const Icon(Icons.clear),
)
: null,
),
);
},
),
),
),
),
Expanded(
child: RefreshIndicator(
onRefresh: () async {
HapticFeedback.lightImpact();
await chatDetailsStore.updateChatters(widget.userLogin);
},
child: Stack(
alignment: AlignmentDirectional.bottomCenter,
children: [
Observer(
builder: (context) {
if (chatDetailsStore.error != null) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text('Failed to get chatters'),
TextButton(
onPressed: () => chatDetailsStore.updateChatters(widget.userLogin),
child: const Text('Try Again'),
)
],
);
}
Expanded(
child: RefreshIndicator(
onRefresh: () async {
HapticFeedback.lightImpact();
if (chatDetailsStore.chatUsers == null) {
return const LoadingIndicator(subtitle: Text('Getting chatters...'));
}
return CustomScrollView(
physics: const AlwaysScrollableScrollPhysics(),
controller: _scrollController,
slivers: [
SliverPadding(
padding: const EdgeInsets.only(left: 10.0, right: 10.0, top: 20.0),
sliver: SliverToBoxAdapter(
child: Text(
'${NumberFormat().format(chatDetailsStore.chatUsers?.chatterCount)} ${chatDetailsStore.chatUsers?.chatterCount == 1 ? 'Chatter' : 'Chatters'}',
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 18.0,
),
),
),
),
...chatDetailsStore.filteredUsers.expandIndexed(
(index, users) => [
if (users.isNotEmpty) ...[
chatDetailsStore.updateChatters();
},
child: Stack(
alignment: AlignmentDirectional.bottomCenter,
children: [
Observer(
builder: (context) {
return CustomScrollView(
physics: const AlwaysScrollableScrollPhysics(),
controller: chatDetailsStore.scrollController,
slivers: [
if (chatDetailsStore.filterText.isEmpty)
SliverPadding(
padding: const EdgeInsets.only(left: 10.0, right: 10.0, top: 20.0),
sliver: SliverToBoxAdapter(
child: SectionHeader(
headers[index],
fontSize: 12.0,
padding: const EdgeInsets.all(0.0),
),
),
),
SliverPadding(
padding: const EdgeInsets.only(left: 10.0, right: 10.0, top: 5.0),
sliver: SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) => InkWell(
child: Text(users[index]),
onLongPress: () async {
final userInfo = await context
.read<TwitchApi>()
.getUser(headers: context.read<AuthStore>().headersTwitch, userLogin: users[index]);
showModalBottomSheet(
context: context,
builder: (context) => ChatUserModal(
chatStore: widget.chatStore,
username: userInfo.login,
userId: userInfo.id,
displayName: userInfo.displayName,
),
);
},
child: Text(
'${NumberFormat().format(chatDetailsStore.chatUsers?.chatterCount)} ${chatDetailsStore.chatUsers?.chatterCount == 1 ? 'Chatter' : 'Chatters'}',
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 18.0,
),
childCount: users.length,
),
),
),
]
if (chatDetailsStore.chatUsers?.chatterCount == 0)
const SliverFillRemaining(
hasScrollBody: false,
child: Center(
child: AlertMessage(
message: 'No chatters found',
icon: Icons.person_off,
),
),
)
else if (chatDetailsStore.filteredUsers.expand((element) => element).isEmpty)
const SliverFillRemaining(
hasScrollBody: false,
child: Center(
child: AlertMessage(
message: 'No matching chatters',
icon: Icons.person_off,
),
),
)
else
...chatDetailsStore.filteredUsers.expandIndexed(
(index, users) => [
if (users.isNotEmpty) ...[
SliverPadding(
padding: const EdgeInsets.only(left: 10.0, right: 10.0, top: 20.0),
sliver: SliverToBoxAdapter(
child: SectionHeader(
headers[index],
padding: const EdgeInsets.symmetric(vertical: 5.0),
),
),
),
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) => InkWell(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 10.0, vertical: 5.0),
child: Text(users[index]),
),
onLongPress: () async {
HapticFeedback.lightImpact();
final userInfo = await context
.read<TwitchApi>()
.getUser(headers: context.read<AuthStore>().headersTwitch, userLogin: users[index]);
showModalBottomSheet(
backgroundColor: Colors.transparent,
isScrollControlled: true,
context: context,
builder: (context) => ChatUserModal(
chatStore: chatStore,
username: userInfo.login,
userId: userInfo.id,
displayName: userInfo.displayName,
),
);
},
),
childCount: users.length,
),
),
]
],
),
],
)
],
);
},
),
SafeArea(
child: Observer(
builder: (context) => AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
child: chatDetailsStore.showJumpButton ? ScrollToTopButton(scrollController: _scrollController) : null,
);
},
),
),
Observer(
builder: (context) => AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
switchInCurve: Curves.easeOutCubic,
switchOutCurve: Curves.easeInCubic,
child: chatDetailsStore.showJumpButton ? ScrollToTopButton(scrollController: chatDetailsStore.scrollController) : null,
),
),
],
),
],
),
),
),
),
],
],
);
},
);
}
@override
void dispose() {
_scrollController.dispose();
_textController.dispose();
widget.chatDetails.filterText = '';
super.dispose();
}
}

View file

@ -4,6 +4,7 @@ import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:frosty/screens/channel/chat/emote_menu/emote_menu_panel.dart';
import 'package:frosty/screens/channel/chat/emote_menu/recent_emotes_panel.dart';
import 'package:frosty/screens/channel/stores/chat_store.dart';
import 'package:frosty/widgets/button.dart';
class EmoteMenu extends StatefulWidget {
final ChatStore chatStore;
@ -31,28 +32,36 @@ class _EmoteMenuState extends State<EmoteMenu> {
];
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Divider(
height: 5.0,
thickness: 1.0,
),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: sections
.mapIndexed(
(index, section) => Observer(
builder: (context) => TextButton(
onPressed: () {
_pageContoller.animateToPage(
index,
duration: const Duration(milliseconds: 200),
curve: Curves.ease,
);
widget.chatStore.assetsStore.emoteMenuIndex = index;
},
style: index == widget.chatStore.assetsStore.emoteMenuIndex ? null : TextButton.styleFrom(primary: Colors.grey),
child: Text(
section,
style: const TextStyle(
letterSpacing: 0.5,
fontWeight: FontWeight.w600,
(index, section) => Padding(
padding: const EdgeInsets.only(right: 5.0),
child: Observer(
builder: (context) => Button(
onPressed: () {
_pageContoller.animateToPage(
index,
duration: const Duration(milliseconds: 200),
curve: Curves.ease,
);
widget.chatStore.assetsStore.emoteMenuIndex = index;
},
color: index == widget.chatStore.assetsStore.emoteMenuIndex ? Theme.of(context).colorScheme.secondary : Colors.grey,
child: Text(
section,
style: const TextStyle(
letterSpacing: 0.8,
fontWeight: FontWeight.w600,
),
),
),
),

View file

@ -3,6 +3,8 @@ import 'package:flutter/material.dart';
import 'package:frosty/constants/constants.dart';
import 'package:frosty/screens/channel/stores/chat_store.dart';
import 'package:frosty/screens/settings/stores/settings_store.dart';
import 'package:frosty/widgets/button.dart';
import 'package:frosty/widgets/dialog.dart';
import 'package:frosty/widgets/section_header.dart';
import 'package:provider/provider.dart';
@ -22,22 +24,23 @@ class _RecentEmotesPanelState extends State<RecentEmotesPanel> {
Future<void> _showClearDialog() {
return showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Clear Recent Emotes'),
builder: (context) => FrostyDialog(
title: 'Clear Recent Emotes',
content: const Text('Are you sure you want to clear your recent emotes?'),
actions: [
TextButton(
onPressed: Navigator.of(context).pop,
style: TextButton.styleFrom(primary: Colors.red),
child: const Text('Cancel'),
),
ElevatedButton(
Button(
onPressed: () {
setState(widget.chatStore.assetsStore.recentEmotes.clear);
Navigator.pop(context);
},
child: const Text('Yes'),
),
Button(
fill: true,
onPressed: Navigator.of(context).pop,
color: Colors.red.shade700,
child: const Text('Cancel'),
),
],
),
);
@ -55,14 +58,17 @@ class _RecentEmotesPanelState extends State<RecentEmotesPanel> {
padding: EdgeInsets.symmetric(vertical: 5.0, horizontal: 10.0),
),
const Spacer(),
SizedBox(
height: 30,
child: TextButton(
Container(
padding: const EdgeInsets.symmetric(horizontal: 5.0),
height: 25,
child: Button(
padding: EdgeInsets.zero,
onPressed: widget.chatStore.assetsStore.recentEmotes.isEmpty ? null : _showClearDialog,
color: Theme.of(context).colorScheme.secondary,
child: const Text(
'CLEAR',
style: TextStyle(
letterSpacing: 0.5,
letterSpacing: 0.8,
fontWeight: FontWeight.w500,
),
),

View file

@ -4,6 +4,7 @@ import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:frosty/constants/constants.dart';
import 'package:frosty/screens/channel/chat/details/chat_details.dart';
import 'package:frosty/screens/channel/stores/chat_store.dart';
import 'package:frosty/widgets/button.dart';
class ChatBottomBar extends StatelessWidget {
final ChatStore chatStore;
@ -70,7 +71,8 @@ class ChatBottomBar extends StatelessWidget {
padding: const EdgeInsets.symmetric(horizontal: 5.0),
itemCount: matchingChatters.length,
scrollDirection: Axis.horizontal,
itemBuilder: (context, index) => TextButton(
itemBuilder: (context, index) => Button(
color: Theme.of(context).colorScheme.secondary,
onPressed: () {
final split = chatStore.textController.text.split(' ')
..removeLast()
@ -84,71 +86,77 @@ class ChatBottomBar extends StatelessWidget {
),
)
],
Row(
children: [
IconButton(
icon: Icon(Icons.adaptive.more),
tooltip: 'Chat Details',
onPressed: () => showModalBottomSheet(
isScrollControlled: true,
context: context,
builder: (_) => SizedBox(
height: MediaQuery.of(context).size.height * 0.8,
child: ChatDetails(
chatDetails: chatStore.chatDetailsStore,
chatStore: chatStore,
userLogin: chatStore.channelName,
),
),
),
),
if (!chatStore.expandChat && chatStore.settings.chatWidth < 0.3 && MediaQuery.of(context).orientation == Orientation.landscape)
IconButton(
tooltip: 'Send a Message',
Padding(
padding: const EdgeInsets.symmetric(vertical: 5.0),
child: Row(
children: [
if (!chatStore.expandChat &&
chatStore.settings.chatWidth < 0.3 &&
chatStore.settings.showVideo &&
MediaQuery.of(context).orientation == Orientation.landscape)
IconButton(
tooltip: 'Send a message',
onPressed: () {
chatStore.expandChat = true;
chatStore.textFieldFocusNode.requestFocus();
},
icon: const Icon(Icons.chat))
else
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 5.0, horizontal: 10.0),
child: TextField(
textInputAction: TextInputAction.send,
focusNode: chatStore.textFieldFocusNode,
minLines: 1,
maxLines: 5,
enabled: chatStore.auth.isLoggedIn ? true : false,
decoration: InputDecoration(
suffixIcon: IconButton(
color: chatStore.assetsStore.showEmoteMenu ? Colors.deepPurple : null,
tooltip: 'Emote Menu',
icon: const Icon(Icons.emoji_emotions_outlined),
onPressed: () {
FocusScope.of(context).unfocus();
chatStore.assetsStore.showEmoteMenu = !chatStore.assetsStore.showEmoteMenu;
},
icon: const Icon(Icons.chat),
)
else
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 5.0, horizontal: 10.0),
child: TextField(
textInputAction: TextInputAction.send,
focusNode: chatStore.textFieldFocusNode,
minLines: 1,
maxLines: 5,
enabled: chatStore.auth.isLoggedIn ? true : false,
decoration: InputDecoration(
contentPadding: const EdgeInsets.fromLTRB(15.0, 10.0, 0.0, 10.0),
suffixIcon: IconButton(
color: chatStore.assetsStore.showEmoteMenu ? Theme.of(context).colorScheme.secondary : null,
tooltip: 'Emote menu',
icon: const Icon(Icons.emoji_emotions_outlined),
onPressed: () {
FocusScope.of(context).unfocus();
chatStore.assetsStore.showEmoteMenu = !chatStore.assetsStore.showEmoteMenu;
},
),
hintMaxLines: 1,
hintText: chatStore.auth.isLoggedIn
? 'Send a message ${chatStore.settings.chatDelay == 0 ? '' : '(${chatStore.settings.chatDelay.toInt()}s delay)'}'
: 'Log in to chat',
),
hintMaxLines: 1,
contentPadding: const EdgeInsets.all(10.0),
border: const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(15.0)),
),
labelText: chatStore.auth.isLoggedIn ? 'Send a message' : 'Log in to chat',
controller: chatStore.textController,
onSubmitted: chatStore.sendMessage,
),
controller: chatStore.textController,
onSubmitted: chatStore.sendMessage,
),
),
),
if (chatStore.settings.chatWidth >= 0.3 || chatStore.expandChat || MediaQuery.of(context).orientation == Orientation.portrait)
IconButton(
tooltip: 'Send',
icon: const Icon(Icons.send),
onPressed: chatStore.auth.isLoggedIn ? () => chatStore.sendMessage(chatStore.textController.text) : null,
)
],
if (chatStore.showSendButton &&
(chatStore.settings.chatWidth >= 0.3 || chatStore.expandChat || MediaQuery.of(context).orientation == Orientation.portrait))
IconButton(
tooltip: 'Send',
icon: const Icon(Icons.send),
onPressed: chatStore.auth.isLoggedIn ? () => chatStore.sendMessage(chatStore.textController.text) : null,
)
else
IconButton(
icon: Icon(Icons.adaptive.more),
tooltip: 'Chat details',
onPressed: () => showModalBottomSheet(
backgroundColor: Colors.transparent,
isScrollControlled: true,
context: context,
builder: (_) => ChatDetails(
chatDetailsStore: chatStore.chatDetailsStore,
chatStore: chatStore,
userLogin: chatStore.channelName,
),
),
),
],
),
),
],
);

View file

@ -87,12 +87,12 @@ class ChatMessage extends StatelessWidget {
if (banDuration == null)
if (ircMessage.command == Command.clearMessage)
const Text(
'Message Deleted',
'Message deleted',
style: TextStyle(fontWeight: FontWeight.bold),
)
else
const Text(
'User Permanently Banned',
'User permanently banned',
style: TextStyle(fontWeight: FontWeight.bold),
)
else

View file

@ -1,8 +1,12 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:frosty/constants/constants.dart';
import 'package:frosty/screens/channel/chat/widgets/chat_message.dart';
import 'package:frosty/screens/channel/stores/chat_store.dart';
import 'package:frosty/widgets/alert_message.dart';
import 'package:frosty/widgets/block_report_modal.dart';
import 'package:frosty/widgets/button.dart';
import 'package:frosty/widgets/modal.dart';
import 'package:frosty/widgets/profile_picture.dart';
import 'package:frosty/widgets/section_header.dart';
@ -28,10 +32,11 @@ class _ChatUserModalState extends State<ChatUserModal> {
@override
Widget build(BuildContext context) {
final userMessages = widget.chatStore.messages.reversed.where((message) => message.user == widget.username).toList();
final name = regexEnglish.hasMatch(widget.displayName) ? widget.displayName : '${widget.displayName} (${widget.username})';
return Scaffold(
body: SafeArea(
bottom: false,
return FrostyModal(
child: SizedBox(
height: MediaQuery.of(context).size.height * 0.5,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@ -42,27 +47,22 @@ class _ChatUserModalState extends State<ChatUserModal> {
),
title: Row(
children: [
Text(
widget.displayName,
style: const TextStyle(fontWeight: FontWeight.bold),
),
IconButton(
tooltip: 'Block or Report User',
onPressed: () => showModalBottomSheet(
context: context,
builder: (context) => BlockReportModal(
authStore: widget.chatStore.auth,
name: widget.displayName,
userLogin: widget.username,
userId: widget.userId,
Flexible(
child: Tooltip(
preferBelow: false,
message: name,
child: Text(
name,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontWeight: FontWeight.bold),
),
),
icon: Icon(Icons.adaptive.more),
),
],
),
trailing: widget.chatStore.auth.isLoggedIn
? OutlinedButton(
? Button(
padding: const EdgeInsets.symmetric(horizontal: 15.0),
onPressed: () {
widget.chatStore.textController.text = '@${widget.username} ';
Navigator.pop(context);
@ -71,47 +71,67 @@ class _ChatUserModalState extends State<ChatUserModal> {
child: const Text('Reply'),
)
: null,
onLongPress: () {
HapticFeedback.mediumImpact();
showModalBottomSheet(
backgroundColor: Colors.transparent,
context: context,
builder: (context) => BlockReportModal(
authStore: widget.chatStore.auth,
name: name,
userLogin: widget.username,
userId: widget.userId,
),
);
},
),
const SectionHeader(
'Recent Messages',
padding: EdgeInsets.all(10.0),
),
Expanded(
child: MediaQuery(
data: MediaQuery.of(context).copyWith(textScaleFactor: widget.chatStore.settings.messageScale),
child: DefaultTextStyle(
style: DefaultTextStyle.of(context).style.copyWith(fontSize: widget.chatStore.settings.fontSize),
child: ListView.separated(
reverse: true,
itemBuilder: (context, index) => InkWell(
onLongPress: () async {
await Clipboard.setData(ClipboardData(text: userMessages[index].message));
if (userMessages.isEmpty)
const Expanded(child: AlertMessage(message: 'No recent messages'))
else
Expanded(
child: MediaQuery(
data: MediaQuery.of(context).copyWith(textScaleFactor: widget.chatStore.settings.messageScale),
child: DefaultTextStyle(
style: DefaultTextStyle.of(context).style.copyWith(fontSize: widget.chatStore.settings.fontSize),
child: ListView.separated(
padding: EdgeInsets.zero,
reverse: true,
itemBuilder: (context, index) => InkWell(
onLongPress: () async {
HapticFeedback.lightImpact();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Message copied!'),
behavior: SnackBarBehavior.floating,
),
);
},
child: ChatMessage(
ircMessage: userMessages[index],
assetsStore: widget.chatStore.assetsStore,
settingsStore: widget.chatStore.settings,
await Clipboard.setData(ClipboardData(text: userMessages[index].message));
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: AlertMessage(message: 'Message copied to clipboard'),
behavior: SnackBarBehavior.floating,
),
);
},
child: ChatMessage(
ircMessage: userMessages[index],
assetsStore: widget.chatStore.assetsStore,
settingsStore: widget.chatStore.settings,
),
),
separatorBuilder: (context, index) => widget.chatStore.settings.showChatMessageDividers
? Divider(
height: widget.chatStore.settings.messageSpacing,
thickness: 1.0,
)
: SizedBox(height: widget.chatStore.settings.messageSpacing),
itemCount: userMessages.length,
),
separatorBuilder: (context, index) => widget.chatStore.settings.showChatMessageDividers
? Divider(
height: widget.chatStore.settings.messageSpacing,
thickness: 1.0,
)
: SizedBox(height: widget.chatStore.settings.messageSpacing),
itemCount: userMessages.length,
),
),
),
),
],
),
),

View file

@ -1,6 +1,6 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:frosty/api/twitch_api.dart';
import 'package:frosty/models/chatters.dart';
import 'package:frosty/models/irc.dart';
@ -13,6 +13,17 @@ class ChatDetailsStore = ChatDetailsStoreBase with _$ChatDetailsStore;
abstract class ChatDetailsStoreBase with Store {
final TwitchApi twitchApi;
final String channelName;
/// The scroll controller for handling the scroll to top button.
final scrollController = ScrollController();
/// The text controller for handling filtering the chatters.
final textController = TextEditingController();
/// The focus node for the textfield used for handling hiding/showing the cancel button.
final textFieldFocusNode = FocusNode();
/// The rules and modes being used in the chat.
@observable
var roomState = const ROOMSTATE();
@ -20,8 +31,10 @@ abstract class ChatDetailsStoreBase with Store {
@observable
var showJumpButton = false;
@observable
var filterText = '';
/// The current text being used to filter the chatters.
/// Changing this will automatically update [filteredUsers].
@readonly
var _filterText = '';
/// The list and types of chatters in the chat room.
@readonly
@ -36,7 +49,7 @@ abstract class ChatDetailsStoreBase with Store {
_chatUsers!.chatters.moderators,
_chatUsers!.chatters.vips,
_chatUsers!.chatters.viewers,
].map((e) => e.where((user) => user.contains(filterText)).toList());
].map((e) => e.where((user) => user.contains(_filterText)).toList());
@computed
List<String> get allChatters => [
@ -52,18 +65,36 @@ abstract class ChatDetailsStoreBase with Store {
@readonly
String? _error;
ChatDetailsStoreBase({required this.twitchApi});
ChatDetailsStoreBase({required this.twitchApi, required this.channelName}) {
scrollController.addListener(() {
if (scrollController.position.atEdge || scrollController.position.outOfRange) {
showJumpButton = false;
} else {
showJumpButton = true;
}
});
textController.addListener(() => _filterText = textController.text);
updateChatters();
}
@action
Future<void> updateChatters(String userLogin) async {
Future<void> updateChatters() async {
try {
_chatUsers = await twitchApi.getChatters(userLogin: userLogin);
_chatUsers = await twitchApi.getChatters(userLogin: channelName);
_error = null;
} on SocketException {
_error = 'Failed to connect :(';
_error = 'Failed to connect';
} catch (e) {
debugPrint(e.toString());
_error = e.toString();
}
}
void dispose() {
scrollController.dispose();
textController.dispose();
textFieldFocusNode.dispose();
}
}

View file

@ -56,19 +56,21 @@ mixin _$ChatDetailsStore on ChatDetailsStoreBase, Store {
});
}
late final _$filterTextAtom =
Atom(name: 'ChatDetailsStoreBase.filterText', context: context);
late final _$_filterTextAtom =
Atom(name: 'ChatDetailsStoreBase._filterText', context: context);
@override
String get filterText {
_$filterTextAtom.reportRead();
return super.filterText;
_$_filterTextAtom.reportRead();
return super._filterText;
}
@override
set filterText(String value) {
_$filterTextAtom.reportWrite(value, super.filterText, () {
super.filterText = value;
String get _filterText => filterText;
@override
set _filterText(String value) {
_$_filterTextAtom.reportWrite(value, super._filterText, () {
super._filterText = value;
});
}
@ -112,9 +114,8 @@ mixin _$ChatDetailsStore on ChatDetailsStoreBase, Store {
AsyncAction('ChatDetailsStoreBase.updateChatters', context: context);
@override
Future<void> updateChatters(String userLogin) {
return _$updateChattersAsyncAction
.run(() => super.updateChatters(userLogin));
Future<void> updateChatters() {
return _$updateChattersAsyncAction.run(() => super.updateChatters());
}
@override
@ -122,7 +123,6 @@ mixin _$ChatDetailsStore on ChatDetailsStoreBase, Store {
return '''
roomState: ${roomState},
showJumpButton: ${showJumpButton},
filterText: ${filterText},
filteredUsers: ${filteredUsers},
allChatters: ${allChatters}
''';

View file

@ -71,6 +71,9 @@ abstract class ChatStoreBase with Store {
@readonly
var _autoScroll = true;
@readonly
var _showSendButton = false;
@readonly
var _showEmoteAutocomplete = false;
@ -103,7 +106,7 @@ abstract class ChatStoreBase with Store {
));
assetsStore.init();
chatDetailsStore.updateChatters(channelName);
chatDetailsStore.updateChatters();
_messages.add(IRCMessage.createNotice(message: 'Connecting to chat...'));
@ -131,7 +134,7 @@ abstract class ChatStoreBase with Store {
if (assetsStore.showEmoteMenu) assetsStore.showEmoteMenu = false;
// Refresh the chatters list to keep autocomplete mentions updated.
if (settings.autocomplete) chatDetailsStore.updateChatters(channelName);
if (settings.autocomplete) chatDetailsStore.updateChatters();
}
// Un-expand the chat when unfocusing.
@ -140,10 +143,13 @@ abstract class ChatStoreBase with Store {
// Add a listener to the textfield that will show/hide the autocomplete bar if focused.
// Will also rebuild the autocomplete bar when typing, refreshing the results as the user types.
textController.addListener(
() => _showEmoteAutocomplete = !_showMentionAutocomplete && textFieldFocusNode.hasFocus && textController.text.split(' ').last.isNotEmpty);
textController
.addListener(() => _showEmoteAutocomplete = !_showMentionAutocomplete && textFieldFocusNode.hasFocus && textController.text.split(' ').last.isNotEmpty);
textController.addListener(() => _showMentionAutocomplete = textFieldFocusNode.hasFocus && textController.text.split(' ').last.startsWith('@'));
textController.addListener(() {
_showSendButton = textController.text.isNotEmpty;
_showMentionAutocomplete = textFieldFocusNode.hasFocus && textController.text.split(' ').last.startsWith('@');
});
}
/// Handle and process the provided string-representation of the IRC data.
@ -223,8 +229,7 @@ abstract class ChatStoreBase with Store {
_channel?.sink.add('PONG :tmi.twitch.tv');
return;
} else if (message.contains('Welcome, GLHF!')) {
_messages
.add(IRCMessage.createNotice(message: "Connected to $displayName${regexEnglish.hasMatch(displayName) ? '' : ' ($channelName)'}'s chat!"));
_messages.add(IRCMessage.createNotice(message: "Connected to $displayName${regexEnglish.hasMatch(displayName) ? '' : ' ($channelName)'}'s chat!"));
// Fetch the assets used in chat including badges and emotes.
assetsStore.assetsFuture(
@ -383,6 +388,8 @@ abstract class ChatStoreBase with Store {
textFieldFocusNode.dispose();
textController.dispose();
scrollController.dispose();
assetsStore.dispose();
chatDetailsStore.dispose();
}
}

View file

@ -45,6 +45,24 @@ mixin _$ChatStore on ChatStoreBase, Store {
});
}
late final _$_showSendButtonAtom =
Atom(name: 'ChatStoreBase._showSendButton', context: context);
bool get showSendButton {
_$_showSendButtonAtom.reportRead();
return super._showSendButton;
}
@override
bool get _showSendButton => showSendButton;
@override
set _showSendButton(bool value) {
_$_showSendButtonAtom.reportWrite(value, super._showSendButton, () {
super._showSendButton = value;
});
}
late final _$_showEmoteAutocompleteAtom =
Atom(name: 'ChatStoreBase._showEmoteAutocomplete', context: context);

View file

@ -132,6 +132,8 @@ abstract class VideoStoreBase with Store {
@action
Future<void> handleToggleOverlay() async {
if (settingsStore.toggleableOverlay) {
HapticFeedback.mediumImpact();
settingsStore.showOverlay = !settingsStore.showOverlay;
await controller?.loadUrl(videoUrl);

View file

@ -18,6 +18,9 @@ import 'package:frosty/screens/channel/stores/chat_store.dart';
import 'package:frosty/screens/channel/stores/video_store.dart';
import 'package:frosty/screens/settings/settings.dart';
import 'package:frosty/screens/settings/stores/settings_store.dart';
import 'package:frosty/widgets/button.dart';
import 'package:frosty/widgets/dialog.dart';
import 'package:frosty/widgets/modal.dart';
import 'package:frosty/widgets/profile_picture.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
@ -52,6 +55,7 @@ class _VideoChatState extends State<VideoChat> {
settings: context.read<SettingsStore>(),
chatDetailsStore: ChatDetailsStore(
twitchApi: context.read<TwitchApi>(),
channelName: widget.userLogin,
),
assetsStore: ChatAssetsStore(
twitchApi: context.read<TwitchApi>(),
@ -142,11 +146,14 @@ class _VideoChatState extends State<VideoChat> {
tooltip: 'Settings',
icon: const Icon(Icons.settings),
onPressed: () => showModalBottomSheet(
backgroundColor: Colors.transparent,
isScrollControlled: true,
context: context,
builder: (context) => SizedBox(
height: MediaQuery.of(context).size.height * 0.8,
child: Settings(settingsStore: settingsStore),
builder: (context) => FrostyModal(
child: SizedBox(
height: MediaQuery.of(context).size.height * 0.8,
child: Settings(settingsStore: settingsStore),
),
),
),
),
@ -168,9 +175,7 @@ class _VideoChatState extends State<VideoChat> {
builder: (context) {
final landscapeChat = AnimatedContainer(
duration: const Duration(milliseconds: 200),
width: _chatStore.expandChat
? MediaQuery.of(context).size.width / 2
: MediaQuery.of(context).size.width * _chatStore.settings.chatWidth,
width: _chatStore.expandChat ? MediaQuery.of(context).size.width / 2 : MediaQuery.of(context).size.width * _chatStore.settings.chatWidth,
curve: Curves.ease,
color: _chatStore.settings.fullScreen
? Colors.black.withOpacity(_chatStore.settings.fullScreenChatOverlayOpacity)
@ -208,9 +213,8 @@ class _VideoChatState extends State<VideoChat> {
],
)
: Row(
children: settingsStore.landscapeChatLeftSide
? [landscapeChat, Expanded(child: video)]
: [Expanded(child: video), landscapeChat],
children:
settingsStore.landscapeChatLeftSide ? [landscapeChat, Expanded(child: video)] : [Expanded(child: video), landscapeChat],
)
: Column(
children: [appBar, Expanded(child: chat)],
@ -295,12 +299,24 @@ class _VideoOverlay extends StatelessWidget {
Future<void> _showSleepTimerDialog(BuildContext context) {
return showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Sleep Timer'),
builder: (context) => FrostyDialog(
title: 'Sleep Timer',
content: Observer(
builder: (context) => Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
const Icon(Icons.timer),
Text(' ${videoStore.timeRemaining.toString().split('.')[0]}'),
const Spacer(),
IconButton(
tooltip: 'Cancel sleep timer',
onPressed: videoStore.sleepTimer != null && videoStore.sleepTimer!.isActive ? videoStore.cancelSleepTimer : null,
icon: const Icon(Icons.cancel),
),
],
),
Row(
children: [
DropdownButton(
@ -325,30 +341,12 @@ class _VideoOverlay extends StatelessWidget {
const Text('Minutes'),
],
),
if (videoStore.sleepTimer != null && videoStore.sleepTimer!.isActive)
Row(
children: [
const Icon(Icons.timer),
Text(' ${videoStore.timeRemaining.toString().split('.')[0]}'),
const Spacer(),
IconButton(
tooltip: 'Cancel Timer',
onPressed: videoStore.cancelSleepTimer,
icon: const Icon(Icons.cancel),
),
],
),
],
),
),
actions: [
TextButton(
onPressed: Navigator.of(context).pop,
style: TextButton.styleFrom(primary: Colors.red),
child: const Text('Dismiss'),
),
Observer(
builder: (context) => ElevatedButton(
builder: (context) => Button(
onPressed: videoStore.sleepHours == 0 && videoStore.sleepMinutes == 0
? null
: () => videoStore.updateSleepTimer(
@ -357,6 +355,12 @@ class _VideoOverlay extends StatelessWidget {
child: const Text('Set Timer'),
),
),
Button(
onPressed: Navigator.of(context).pop,
fill: true,
color: Colors.red.shade700,
child: const Text('Close'),
),
],
),
);
@ -385,18 +389,21 @@ class _VideoOverlay extends StatelessWidget {
color: Colors.white,
),
onPressed: () => showModalBottomSheet(
backgroundColor: Colors.transparent,
isScrollControlled: true,
context: context,
builder: (context) => SizedBox(
height: MediaQuery.of(context).size.height * 0.8,
child: Settings(settingsStore: videoStore.settingsStore),
builder: (context) => FrostyModal(
child: SizedBox(
height: MediaQuery.of(context).size.height * 0.8,
child: Settings(settingsStore: videoStore.settingsStore),
),
),
),
);
final chatOverlayButton = Observer(
builder: (_) => IconButton(
tooltip: videoStore.settingsStore.fullScreenChatOverlay ? 'Hide Chat Overlay' : 'Show Chat Overlay',
tooltip: videoStore.settingsStore.fullScreenChatOverlay ? 'Hide chat overlay' : 'Show chat overlay',
onPressed: () => videoStore.settingsStore.fullScreenChatOverlay = !videoStore.settingsStore.fullScreenChatOverlay,
icon: videoStore.settingsStore.fullScreenChatOverlay ? const Icon(Icons.chat_bubble_outline) : const Icon(Icons.chat_bubble),
color: Colors.white,
@ -413,7 +420,7 @@ class _VideoOverlay extends StatelessWidget {
);
final fullScreenButton = IconButton(
tooltip: videoStore.settingsStore.fullScreen ? 'Exit Fullscreen' : 'Enter Fullscreen',
tooltip: videoStore.settingsStore.fullScreen ? 'Exit fullscreen mode' : 'Enter fullscreen mode',
icon: videoStore.settingsStore.fullScreen
? const Icon(
Icons.fullscreen_exit,
@ -427,7 +434,7 @@ class _VideoOverlay extends StatelessWidget {
);
final sleepTimerButton = IconButton(
tooltip: 'Sleep Timer',
tooltip: 'Sleep timer',
icon: const Icon(
Icons.timer,
color: Colors.white,
@ -436,7 +443,7 @@ class _VideoOverlay extends StatelessWidget {
);
final rotateButton = IconButton(
tooltip: orientation == Orientation.portrait ? 'Enter Landscape Mode' : 'Exit Landscape Mode',
tooltip: orientation == Orientation.portrait ? 'Enter landscape mode' : 'Exit landscape mode',
icon: const Icon(
Icons.screen_rotation,
color: Colors.white,
@ -609,7 +616,7 @@ class _VideoOverlay extends StatelessWidget {
),
if (Platform.isIOS && videoStore.settingsStore.pictureInPicture)
IconButton(
tooltip: 'Picture-in-Picture',
tooltip: 'Picture-in-picture',
icon: const Icon(
Icons.picture_in_picture_alt_rounded,
color: Colors.white,

View file

@ -8,6 +8,8 @@ import 'package:frosty/screens/home/top/top_section.dart';
import 'package:frosty/screens/home/widgets/streams_list.dart';
import 'package:frosty/screens/settings/settings.dart';
import 'package:frosty/screens/settings/stores/settings_store.dart';
import 'package:frosty/widgets/button.dart';
import 'package:frosty/widgets/dialog.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
@ -33,14 +35,8 @@ class _HomeState extends State<Home> {
return showDialog(
barrierDismissible: false,
context: context,
builder: (context) => AlertDialog(
scrollable: true,
title: Text(
'Frosty v${packageInfo.version} (${packageInfo.buildNumber})',
style: const TextStyle(
fontWeight: FontWeight.bold,
),
),
builder: (context) => FrostyDialog(
title: 'Frosty v${packageInfo.version} (${packageInfo.buildNumber})',
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
@ -56,7 +52,7 @@ class _HomeState extends State<Home> {
],
),
actions: [
TextButton(
Button(
onPressed: () {
prefs.setBool('first_run', false);
@ -107,32 +103,53 @@ class _HomeState extends State<Home> {
],
),
body: SafeArea(
child: Observer(
builder: (_) => IndexedStack(
index: _homeStore.selectedIndex,
children: [
if (_authStore.isLoggedIn) const StreamsList(listType: ListType.followed),
const TopSection(),
const Search(),
],
),
child: Column(
children: [
Expanded(
child: Observer(
builder: (_) => IndexedStack(
index: _homeStore.selectedIndex,
children: [
if (_authStore.isLoggedIn)
StreamsList(
listType: ListType.followed,
scrollController: _homeStore.followedScrollController,
),
TopSection(
homeStore: _homeStore,
),
Search(
scrollController: _homeStore.searchScrollController,
),
],
),
),
),
const Divider(height: 1.0, thickness: 1.0),
],
),
),
bottomNavigationBar: Observer(
builder: (_) => BottomNavigationBar(
unselectedFontSize: 12.0,
selectedFontSize: 12.0,
type: BottomNavigationBarType.fixed,
items: [
if (_authStore.isLoggedIn)
const BottomNavigationBarItem(
icon: Icon(Icons.favorite),
label: 'Followed',
tooltip: 'Followed streams',
),
const BottomNavigationBarItem(
icon: Icon(Icons.arrow_upward),
label: 'Top',
tooltip: 'Top streams and categories',
),
const BottomNavigationBarItem(
icon: Icon(Icons.search),
label: 'Search',
tooltip: 'Search for channels and categories',
),
],
currentIndex: _homeStore.selectedIndex,

View file

@ -6,11 +6,19 @@ import 'package:frosty/core/auth/auth_store.dart';
import 'package:frosty/screens/home/search/search_results_categories.dart';
import 'package:frosty/screens/home/search/search_results_channels.dart';
import 'package:frosty/screens/home/stores/search_store.dart';
import 'package:frosty/widgets/alert_message.dart';
import 'package:frosty/widgets/section_header.dart';
import 'package:provider/provider.dart';
/// The search section that contians search history and search results for channels and categories.
class Search extends StatefulWidget {
const Search({Key? key}) : super(key: key);
// The scroll controller for handling scroll to top functionality.
final ScrollController scrollController;
const Search({
Key? key,
required this.scrollController,
}) : super(key: key);
@override
State<Search> createState() => _SearchState();
@ -22,8 +30,6 @@ class _SearchState extends State<Search> {
twitchApi: context.read<TwitchApi>(),
);
final _textEditingController = TextEditingController();
@override
Widget build(BuildContext context) {
const headerPadding = EdgeInsets.fromLTRB(15.0, 20.0, 15.0, 10.0);
@ -32,45 +38,53 @@ class _SearchState extends State<Search> {
children: [
Padding(
padding: const EdgeInsets.symmetric(vertical: 5.0, horizontal: 10.0),
child: TextField(
controller: _textEditingController,
autocorrect: false,
decoration: InputDecoration(
isDense: true,
labelText: 'Find a channel or category',
contentPadding: const EdgeInsets.all(10.0),
border: const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(15.0)),
),
suffixIcon: IconButton(
tooltip: 'Clear Search',
onPressed: () {
FocusScope.of(context).unfocus();
setState(() {
_textEditingController.clear();
});
},
icon: const Icon(Icons.clear),
),
),
onSubmitted: _searchStore.handleQuery,
child: Observer(
builder: (context) {
return TextField(
controller: _searchStore.textEditingController,
focusNode: _searchStore.textFieldFocusNode,
autocorrect: false,
decoration: InputDecoration(
contentPadding: const EdgeInsets.all(0.0),
hintText: 'Find a channel or category',
prefixIcon: const Icon(Icons.search),
suffixIcon: _searchStore.textFieldFocusNode.hasFocus || _searchStore.searchText.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
tooltip: _searchStore.searchText.isEmpty ? 'Cancel' : 'Clear',
onPressed: () {
if (_searchStore.searchText.isEmpty) _searchStore.textFieldFocusNode.unfocus();
_searchStore.textEditingController.clear();
},
)
: null,
),
onSubmitted: _searchStore.handleQuery,
);
},
),
),
Expanded(
child: Observer(
builder: (context) {
if (_textEditingController.text.isEmpty || _searchStore.channelFuture == null || _searchStore.categoryFuture == null) {
if (_searchStore.textEditingController.text.isEmpty) {
if (_searchStore.searchHistory.isEmpty) {
return const AlertMessage(
message: 'No recent searches',
icon: Icons.search_off,
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (_searchStore.searchHistory.isNotEmpty)
const SectionHeader(
'History',
fontSize: 12.0,
padding: headerPadding,
),
const SectionHeader(
'History',
padding: headerPadding,
),
Expanded(
child: ListView(
controller: widget.scrollController,
children: _searchStore.searchHistory
.mapIndexed(
(index, searchTerm) => ListTile(
@ -79,15 +93,13 @@ class _SearchState extends State<Search> {
trailing: IconButton(
tooltip: 'Remove',
icon: const Icon(Icons.cancel),
onPressed: () => setState(() {
_searchStore.searchHistory.removeAt(index);
}),
onPressed: () => _searchStore.searchHistory.removeAt(index),
),
onTap: () {
setState(() {
_textEditingController.text = searchTerm;
_searchStore.handleQuery(searchTerm);
});
_searchStore.textEditingController.text = searchTerm;
_searchStore.handleQuery(searchTerm);
_searchStore.textEditingController.selection =
TextSelection.fromPosition(TextPosition(offset: _searchStore.textEditingController.text.length));
},
),
)
@ -98,22 +110,21 @@ class _SearchState extends State<Search> {
);
}
return CustomScrollView(
controller: widget.scrollController,
slivers: [
const SliverToBoxAdapter(
child: SectionHeader(
'Channels',
fontSize: 12.0,
padding: headerPadding,
),
),
SearchResultsChannels(
searchStore: _searchStore,
query: _textEditingController.text,
query: _searchStore.searchText,
),
const SliverToBoxAdapter(
child: SectionHeader(
'Categories',
fontSize: 12.0,
padding: headerPadding,
),
),
@ -129,7 +140,7 @@ class _SearchState extends State<Search> {
@override
void dispose() {
_textEditingController.dispose();
_searchStore.dispose();
super.dispose();
}
}

View file

@ -3,6 +3,7 @@ import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:frosty/models/category.dart';
import 'package:frosty/screens/home/stores/search_store.dart';
import 'package:frosty/screens/home/widgets/category_card.dart';
import 'package:frosty/widgets/alert_message.dart';
import 'package:frosty/widgets/loading_indicator.dart';
import 'package:mobx/mobx.dart';
@ -13,12 +14,6 @@ class SearchResultsCategories extends StatelessWidget {
@override
Widget build(BuildContext context) {
final size = MediaQuery.of(context).size;
final pixelRatio = MediaQuery.of(context).devicePixelRatio;
final artWidth = (size.width * pixelRatio) ~/ 3;
final artHeight = (artWidth * (4 / 3)).toInt();
return Observer(
builder: (context) {
final future = searchStore.categoryFuture;
@ -26,14 +21,21 @@ class SearchResultsCategories extends StatelessWidget {
switch (future!.status) {
case FutureStatus.pending:
return const SliverToBoxAdapter(
child: LoadingIndicator(
subtitle: Text('Loading categories...'),
child: SizedBox(
height: 100.0,
child: LoadingIndicator(
subtitle: 'Loading categories...',
),
),
);
case FutureStatus.rejected:
return const SliverToBoxAdapter(
child: Center(
child: Text('Failed to get categories'),
child: SizedBox(
height: 100.0,
child: AlertMessage(
message: 'Failed to get categories',
icon: Icons.error,
),
),
);
case FutureStatus.fulfilled:
@ -41,8 +43,12 @@ class SearchResultsCategories extends StatelessWidget {
if (categories == null) {
return const SliverToBoxAdapter(
child: Center(
child: Text('Failed to get categories'),
child: SizedBox(
height: 100.0,
child: AlertMessage(
message: 'Failed to get categories',
icon: Icons.error,
),
),
);
}
@ -51,7 +57,7 @@ class SearchResultsCategories extends StatelessWidget {
return const SliverToBoxAdapter(
child: SizedBox(
height: 100.0,
child: Center(child: Text('No matching categories')),
child: AlertMessage(message: 'No matching categories'),
),
);
}
@ -64,8 +70,6 @@ class SearchResultsCategories extends StatelessWidget {
(category) => GridTile(
child: CategoryCard(
category: category,
width: artWidth,
height: artHeight,
),
),
)

View file

@ -1,9 +1,12 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:frosty/constants/constants.dart';
import 'package:frosty/models/channel.dart';
import 'package:frosty/screens/channel/video_chat.dart';
import 'package:frosty/screens/home/stores/search_store.dart';
import 'package:frosty/widgets/alert_message.dart';
import 'package:frosty/widgets/animate_scale.dart';
import 'package:frosty/widgets/block_report_modal.dart';
import 'package:frosty/widgets/loading_indicator.dart';
import 'package:frosty/widgets/profile_picture.dart';
@ -39,11 +42,15 @@ class _SearchResultsChannelsState extends State<SearchResultsChannels> {
),
),
);
} catch (e) {
} catch (error) {
final snackBar = SnackBar(
content: Text(e.toString()),
content: AlertMessage(
message: error.toString(),
icon: Icons.error,
),
behavior: SnackBarBehavior.floating,
);
ScaffoldMessenger.of(context).showSnackBar(snackBar);
}
}
@ -58,13 +65,17 @@ class _SearchResultsChannelsState extends State<SearchResultsChannels> {
case FutureStatus.pending:
return const SliverToBoxAdapter(
child: LoadingIndicator(
subtitle: Text('Loading channels...'),
subtitle: 'Loading channels...',
),
);
case FutureStatus.rejected:
return const SliverToBoxAdapter(
child: Center(
child: Text('Failed to get channels'),
child: SizedBox(
height: 100.0,
child: AlertMessage(
message: 'Failed to get channels',
icon: Icons.error,
),
),
);
case FutureStatus.fulfilled:
@ -78,28 +89,7 @@ class _SearchResultsChannelsState extends State<SearchResultsChannels> {
final displayName =
regexEnglish.hasMatch(channel.displayName) ? channel.displayName : '${channel.displayName} (${channel.broadcasterLogin})';
return ListTile(
title: Text(displayName),
leading: ProfilePicture(userLogin: channel.broadcasterLogin),
trailing: channel.isLive
? ClipRRect(
borderRadius: BorderRadius.circular(5.0),
child: Container(
color: const Color(0xFFF44336),
padding: const EdgeInsets.all(10.0),
child: const Text(
'LIVE',
style: TextStyle(
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
),
)
: null,
subtitle: channel.isLive
? Text('Uptime: ${DateTime.now().difference(DateTime.parse(channel.startedAt)).toString().split('.')[0]}')
: null,
return AnimateScale(
onTap: () => Navigator.push(
context,
MaterialPageRoute(
@ -110,14 +100,41 @@ class _SearchResultsChannelsState extends State<SearchResultsChannels> {
),
),
),
onLongPress: () => showModalBottomSheet(
context: context,
builder: (context) => BlockReportModal(
authStore: widget.searchStore.authStore,
name: displayName,
userLogin: channel.broadcasterLogin,
userId: channel.id,
),
onLongPress: () {
HapticFeedback.lightImpact();
showModalBottomSheet(
backgroundColor: Colors.transparent,
context: context,
builder: (context) => BlockReportModal(
authStore: widget.searchStore.authStore,
name: displayName,
userLogin: channel.broadcasterLogin,
userId: channel.id,
),
);
},
child: ListTile(
title: Text(displayName),
leading: ProfilePicture(userLogin: channel.broadcasterLogin),
trailing: channel.isLive
? ClipRRect(
borderRadius: BorderRadius.circular(5.0),
child: Container(
color: const Color(0xFFF44336),
padding: const EdgeInsets.all(10.0),
child: const Text(
'LIVE',
style: TextStyle(
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
),
)
: null,
subtitle:
channel.isLive ? Text('Uptime: ${DateTime.now().difference(DateTime.parse(channel.startedAt)).toString().split('.')[0]}') : null,
),
);
},

View file

@ -1,6 +1,5 @@
import 'dart:io';
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';
@ -21,18 +20,8 @@ abstract class CategoriesStoreBase with Store {
String? _categoriesCursor;
/// The loading status for pagination.
@readonly
bool _isLoading = false;
bool get isLoading => _isLoading;
/// Returns whether or not there are more streams and loading status for pagination.
bool get hasMore => _isLoading == false && _categoriesCursor != null;
/// The scroll controller used for handling scroll to top.
final scrollController = ScrollController();
/// Whether or not the scroll to top button is visible.
@observable
var showJumpButton = false;
/// The current visible categories, sorted by total viewers.
@readonly
@ -42,20 +31,15 @@ abstract class CategoriesStoreBase with Store {
@readonly
String? _error;
CategoriesStoreBase({required this.authStore, required this.twitchApi}) {
scrollController.addListener(() {
if (scrollController.position.atEdge || scrollController.position.outOfRange) {
showJumpButton = false;
} else {
showJumpButton = true;
}
});
/// Returns whether or not there are more streams and loading status for pagination.
@computed
bool get hasMore => _isLoading == false && _categoriesCursor != null;
CategoriesStoreBase({required this.authStore, required this.twitchApi}) {
getCategories();
}
// Fetches the top categories based on the current cursor.
@action
Future<void> getCategories() async {
_isLoading = true;
@ -64,6 +48,7 @@ abstract class CategoriesStoreBase with Store {
headers: authStore.headersTwitch,
cursor: _categoriesCursor,
);
if (_categoriesCursor == null) {
_categories = result.data.asObservable();
} else {
@ -73,7 +58,7 @@ abstract class CategoriesStoreBase with Store {
_error = null;
} on SocketException {
_error = 'Failed to connect :(';
_error = 'Failed to connect';
} catch (e) {
_error = e.toString();
}
@ -88,8 +73,4 @@ abstract class CategoriesStoreBase with Store {
return getCategories();
}
void dispose() {
scrollController.dispose();
}
}

View file

@ -9,19 +9,28 @@ part of 'categories_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, no_leading_underscores_for_local_identifiers
mixin _$CategoriesStore on CategoriesStoreBase, Store {
late final _$showJumpButtonAtom =
Atom(name: 'CategoriesStoreBase.showJumpButton', context: context);
Computed<bool>? _$hasMoreComputed;
@override
bool get showJumpButton {
_$showJumpButtonAtom.reportRead();
return super.showJumpButton;
bool get hasMore => (_$hasMoreComputed ??= Computed<bool>(() => super.hasMore,
name: 'CategoriesStoreBase.hasMore'))
.value;
late final _$_isLoadingAtom =
Atom(name: 'CategoriesStoreBase._isLoading', context: context);
bool get isLoading {
_$_isLoadingAtom.reportRead();
return super._isLoading;
}
@override
set showJumpButton(bool value) {
_$showJumpButtonAtom.reportWrite(value, super.showJumpButton, () {
super.showJumpButton = value;
bool get _isLoading => isLoading;
@override
set _isLoading(bool value) {
_$_isLoadingAtom.reportWrite(value, super._isLoading, () {
super._isLoading = value;
});
}
@ -61,14 +70,6 @@ mixin _$CategoriesStore on CategoriesStoreBase, Store {
});
}
late final _$getCategoriesAsyncAction =
AsyncAction('CategoriesStoreBase.getCategories', context: context);
@override
Future<void> getCategories() {
return _$getCategoriesAsyncAction.run(() => super.getCategories());
}
late final _$CategoriesStoreBaseActionController =
ActionController(name: 'CategoriesStoreBase', context: context);
@ -86,7 +87,7 @@ mixin _$CategoriesStore on CategoriesStoreBase, Store {
@override
String toString() {
return '''
showJumpButton: ${showJumpButton}
hasMore: ${hasMore}
''';
}
}

View file

@ -1,31 +1,105 @@
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import 'package:frosty/core/auth/auth_store.dart';
import 'package:mobx/mobx.dart';
part 'home_store.g.dart';
/// The home store that handles scrolling and navigating between tabs on the home screen.
class HomeStore = HomeStoreBase with _$HomeStore;
abstract class HomeStoreBase with Store {
final AuthStore authStore;
late final ReactionDisposer _disposeReaction;
/// The scroll controller for controlling the scroll to top on the followed section.
final followedScrollController = ScrollController();
/// The scroll controllers for controlling the scroll to top on the top section.
/// One controller is for the top streams tab and the other is for the top categories tab.
final topSectionScrollControllers = [
ScrollController(),
ScrollController(),
];
/// The scroll controller for controlling the scroll to top on the search section.
final searchScrollController = ScrollController();
/// The current index of the top section tab. Changes when switching between the streams and categories tabs.
var topSectionCurrentIndex = 0;
/// The current selected index/tab of the bottom navigation bar.
@readonly
var _selectedIndex = 0;
late final ReactionDisposer _disposeReaction;
HomeStoreBase({required this.authStore}) {
// Setup a reaction that will return to the first tab when logging in/out.
// This will prevent an out of range index error when last visiting the search screen and logging out.
_disposeReaction = reaction(
(_) => authStore.isLoggedIn,
(_) => _selectedIndex = 0,
);
}
@readonly
var _selectedIndex = 0;
@action
void handleTap(int index) {
// If tapping a different tab, set the new index to show.
if (index != _selectedIndex) {
_selectedIndex = index;
} else {
const duration = Duration(milliseconds: 500);
// Use different logic if logged in/out since there will be one less tab when logged out.
if (authStore.isLoggedIn) {
if (index == 0) {
// If on the followed tab and tapping the followed tab, scroll to the top.
followedScrollController.animateTo(
0.0,
duration: duration,
curve: Curves.easeOutCubic,
);
} else if (index == 1) {
// If on the top section, scroll to the top of the tab based on the current top tab.
topSectionScrollControllers[topSectionCurrentIndex].animateTo(
0.0,
duration: duration,
curve: Curves.easeOutCubic,
);
} else {
// If on the search tab and tapping the search tab, scroll to the top.
searchScrollController.animateTo(
0.0,
duration: duration,
curve: Curves.easeOutCubic,
);
}
} else {
if (index == 0) {
// If on the top section, scroll to the top of the tab based on the current top tab.
topSectionScrollControllers[topSectionCurrentIndex].animateTo(
0.0,
duration: duration,
curve: Curves.easeOutCubic,
);
} else {
// If on the search tab and tapping the search tab, scroll to the top.
searchScrollController.animateTo(
0.0,
duration: duration,
curve: Curves.easeOutCubic,
);
}
}
}
}
void dispose() => _disposeReaction();
void dispose() {
_disposeReaction();
followedScrollController.dispose();
for (final controller in topSectionScrollControllers) {
controller.dispose();
}
}
}

View file

@ -3,7 +3,6 @@ import 'dart:io';
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';
import 'package:frosty/models/stream.dart';
import 'package:mobx/mobx.dart';
@ -21,30 +20,33 @@ abstract class ListStoreBase with Store {
/// The type of list that this store is handling.
final ListType listType;
/// The category to use if the [listType] is category streams.
final CategoryTwitch? categoryInfo;
/// The category id to use when fetching streams if the [listType] is [ListType.category].
final String? categoryId;
/// The scroll controller used for handling scroll to top (if provided).
/// If provided, will use the [ScrollToTop] widget to scroll to the top of the list instead of the bottom tab bar.
final ScrollController? scrollController;
/// The pagination cursor for the streams.
String? _streamsCursor;
/// Returns whether or not there are more streams and loading status for pagination.
@computed
bool get hasMore => _isLoading == false && _streamsCursor != null;
/// The loading status for pagination.
@readonly
bool _isLoading = false;
bool get isLoading => _isLoading;
/// The scroll controller used for handling scroll to top.
final scrollController = ScrollController();
/// Whether or not the scroll to top button is visible.
@observable
var showJumpButton = false;
/// The list of the fetched streams.
@readonly
var _allStreams = ObservableList<StreamTwitch>();
/// Whether or not the scroll to top button is visible.
@observable
var showJumpButton = false;
/// The list of the fetched streams with blocked users filtered out.
@computed
ObservableList<StreamTwitch> get streams => _allStreams
.where((streamInfo) => !authStore.user.blockedUsers.map((blockedUser) => blockedUser.userId).contains(streamInfo.userId))
@ -59,15 +61,18 @@ abstract class ListStoreBase with Store {
required this.authStore,
required this.twitchApi,
required this.listType,
this.categoryInfo,
this.categoryId,
this.scrollController,
}) {
scrollController.addListener(() {
if (scrollController.position.atEdge || scrollController.position.outOfRange) {
showJumpButton = false;
} else {
showJumpButton = true;
}
});
if (scrollController != null) {
scrollController!.addListener(() {
if (scrollController!.position.atEdge || scrollController!.position.outOfRange) {
showJumpButton = false;
} else {
showJumpButton = true;
}
});
}
switch (listType) {
case ListType.followed:
@ -81,7 +86,6 @@ abstract class ListStoreBase with Store {
}
/// Fetches the streams based on the type and current cursor.
@action
Future<void> getStreams() async {
_isLoading = true;
@ -103,7 +107,7 @@ abstract class ListStoreBase with Store {
break;
case ListType.category:
newStreams = await twitchApi.getStreamsUnderCategory(
gameId: categoryInfo!.id,
gameId: categoryId!,
headers: authStore.headersTwitch,
cursor: _streamsCursor,
);
@ -119,7 +123,7 @@ abstract class ListStoreBase with Store {
_error = null;
} on SocketException {
_error = 'Failed to connect :(';
_error = 'Failed to connect';
} catch (e) {
_error = e.toString();
}
@ -135,9 +139,14 @@ abstract class ListStoreBase with Store {
return getStreams();
}
void dispose() => scrollController.dispose();
void dispose() => scrollController?.dispose();
}
/// The possible types of lists that can be displayed.
///
/// [ListType.followed] is the list of streams that the user is following.
/// [ListType.top] is the list of top streams.
/// [ListType.category] is the list of streams under a category.
enum ListType {
followed,
top,

View file

@ -9,6 +9,12 @@ 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, no_leading_underscores_for_local_identifiers
mixin _$ListStore on ListStoreBase, Store {
Computed<bool>? _$hasMoreComputed;
@override
bool get hasMore => (_$hasMoreComputed ??=
Computed<bool>(() => super.hasMore, name: 'ListStoreBase.hasMore'))
.value;
Computed<ObservableList<StreamTwitch>>? _$streamsComputed;
@override
@ -17,19 +23,21 @@ mixin _$ListStore on ListStoreBase, Store {
name: 'ListStoreBase.streams'))
.value;
late final _$showJumpButtonAtom =
Atom(name: 'ListStoreBase.showJumpButton', context: context);
late final _$_isLoadingAtom =
Atom(name: 'ListStoreBase._isLoading', context: context);
@override
bool get showJumpButton {
_$showJumpButtonAtom.reportRead();
return super.showJumpButton;
bool get isLoading {
_$_isLoadingAtom.reportRead();
return super._isLoading;
}
@override
set showJumpButton(bool value) {
_$showJumpButtonAtom.reportWrite(value, super.showJumpButton, () {
super.showJumpButton = value;
bool get _isLoading => isLoading;
@override
set _isLoading(bool value) {
_$_isLoadingAtom.reportWrite(value, super._isLoading, () {
super._isLoading = value;
});
}
@ -51,6 +59,22 @@ mixin _$ListStore on ListStoreBase, Store {
});
}
late final _$showJumpButtonAtom =
Atom(name: 'ListStoreBase.showJumpButton', context: context);
@override
bool get showJumpButton {
_$showJumpButtonAtom.reportRead();
return super.showJumpButton;
}
@override
set showJumpButton(bool value) {
_$showJumpButtonAtom.reportWrite(value, super.showJumpButton, () {
super.showJumpButton = value;
});
}
late final _$_errorAtom =
Atom(name: 'ListStoreBase._error', context: context);
@ -69,14 +93,6 @@ mixin _$ListStore on ListStoreBase, Store {
});
}
late final _$getStreamsAsyncAction =
AsyncAction('ListStoreBase.getStreams', context: context);
@override
Future<void> getStreams() {
return _$getStreamsAsyncAction.run(() => super.getStreams());
}
late final _$ListStoreBaseActionController =
ActionController(name: 'ListStoreBase', context: context);
@ -95,6 +111,7 @@ mixin _$ListStore on ListStoreBase, Store {
String toString() {
return '''
showJumpButton: ${showJumpButton},
hasMore: ${hasMore},
streams: ${streams}
''';
}

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';
@ -14,6 +15,13 @@ abstract class SearchStoreBase with Store {
final TwitchApi twitchApi;
final textEditingController = TextEditingController();
final textFieldFocusNode = FocusNode();
@readonly
var _searchText = '';
@readonly
var _searchHistory = ObservableList<String>();
@ -32,21 +40,29 @@ abstract class SearchStoreBase with Store {
// Retrieve the instance that will allow us to retrieve local search history.
final prefs = await SharedPreferences.getInstance();
// Retrieve the search history from local storage. If it doesn't exist, use an empty list.
_searchHistory = prefs.getStringList('search_history')?.asObservable() ?? ObservableList<String>();
// Create a reaction that will limit the history to 8 entries and update it to local storage automatically.
autorun((_) {
if (_searchHistory.length > 8) _searchHistory.removeLast();
prefs.setStringList('search_history', _searchHistory);
});
// Add a listener to update the cancel button visibility whenever the text changes.
textEditingController.addListener(() => _searchText = textEditingController.text);
}
/// Obtain the channels and categories that match the provided [query].
@action
Future<void> handleQuery(String query) async {
void handleQuery(String query) {
if (query.isEmpty) return;
// Move the query to the most recent result (top of the stack).
_searchHistory.remove(query);
_searchHistory.insert(0, query);
// Fetch the matching channels, sort it by live status, and then set it.
_channelFuture = twitchApi
.searchChannels(
query: query,
@ -59,6 +75,7 @@ abstract class SearchStoreBase with Store {
},
).asObservable();
// Fetch and set the categories that match the query.
_categoryFuture = twitchApi
.searchCategories(
query: query,
@ -67,8 +84,15 @@ abstract class SearchStoreBase with Store {
.asObservable();
}
/// Find a specific channel provided the [query].
/// This is used for channels that may not show up in the search results.
Future<Channel> searchChannel(String query) async {
final user = await twitchApi.getUser(userLogin: query, headers: authStore.headersTwitch);
return await twitchApi.getChannel(userId: user.id, headers: authStore.headersTwitch);
}
void dispose() {
textEditingController.dispose();
textFieldFocusNode.dispose();
}
}

View file

@ -9,6 +9,24 @@ part of 'search_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, no_leading_underscores_for_local_identifiers
mixin _$SearchStore on SearchStoreBase, Store {
late final _$_searchTextAtom =
Atom(name: 'SearchStoreBase._searchText', context: context);
String get searchText {
_$_searchTextAtom.reportRead();
return super._searchText;
}
@override
String get _searchText => searchText;
@override
set _searchText(String value) {
_$_searchTextAtom.reportWrite(value, super._searchText, () {
super._searchText = value;
});
}
late final _$_searchHistoryAtom =
Atom(name: 'SearchStoreBase._searchHistory', context: context);
@ -71,12 +89,18 @@ mixin _$SearchStore on SearchStoreBase, Store {
return _$initAsyncAction.run(() => super.init());
}
late final _$handleQueryAsyncAction =
AsyncAction('SearchStoreBase.handleQuery', context: context);
late final _$SearchStoreBaseActionController =
ActionController(name: 'SearchStoreBase', context: context);
@override
Future<void> handleQuery(String query) {
return _$handleQueryAsyncAction.run(() => super.handleQuery(query));
void handleQuery(String query) {
final _$actionInfo = _$SearchStoreBaseActionController.startAction(
name: 'SearchStoreBase.handleQuery');
try {
return super.handleQuery(query);
} finally {
_$SearchStoreBaseActionController.endAction(_$actionInfo);
}
}
@override

View file

@ -5,12 +5,17 @@ import 'package:frosty/api/twitch_api.dart';
import 'package:frosty/core/auth/auth_store.dart';
import 'package:frosty/screens/home/stores/categories_store.dart';
import 'package:frosty/screens/home/widgets/category_card.dart';
import 'package:frosty/widgets/alert_message.dart';
import 'package:frosty/widgets/loading_indicator.dart';
import 'package:frosty/widgets/scroll_to_top_button.dart';
import 'package:provider/provider.dart';
class Categories extends StatefulWidget {
const Categories({Key? key}) : super(key: key);
final ScrollController scrollController;
const Categories({
Key? key,
required this.scrollController,
}) : super(key: key);
@override
State<Categories> createState() => _CategoriesState();
@ -26,12 +31,6 @@ class _CategoriesState extends State<Categories> with AutomaticKeepAliveClientMi
Widget build(BuildContext context) {
super.build(context);
final size = MediaQuery.of(context).size;
final pixelRatio = MediaQuery.of(context).devicePixelRatio;
final artWidth = (size.width * pixelRatio) ~/ 3;
final artHeight = (artWidth * (4 / 3)).toInt();
return RefreshIndicator(
onRefresh: () async {
HapticFeedback.lightImpact();
@ -39,7 +38,10 @@ class _CategoriesState extends State<Categories> with AutomaticKeepAliveClientMi
if (_categoriesStore.error != null) {
final snackBar = SnackBar(
content: Text(_categoriesStore.error!),
content: AlertMessage(
message: _categoriesStore.error!,
icon: Icons.error,
),
behavior: SnackBarBehavior.floating,
);
@ -49,37 +51,50 @@ class _CategoriesState extends State<Categories> with AutomaticKeepAliveClientMi
},
child: Observer(
builder: (_) {
if (_categoriesStore.categories.isEmpty && _categoriesStore.isLoading && _categoriesStore.error == null) {
return const LoadingIndicator(subtitle: Text('Loading categories...'));
Widget? statusWidget;
if (_categoriesStore.error != null) {
statusWidget = AlertMessage(
message: _categoriesStore.error!,
icon: Icons.error,
);
}
return Stack(
alignment: AlignmentDirectional.bottomCenter,
children: [
GridView.builder(
physics: const AlwaysScrollableScrollPhysics(),
controller: _categoriesStore.scrollController,
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
),
itemCount: _categoriesStore.categories.length,
itemBuilder: (context, index) {
if (index > _categoriesStore.categories.length / 2 && _categoriesStore.hasMore) {
_categoriesStore.getCategories();
}
return CategoryCard(
category: _categoriesStore.categories[index],
width: artWidth,
height: artHeight,
);
},
),
Observer(
builder: (context) => AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
child: _categoriesStore.showJumpButton ? ScrollToTopButton(scrollController: _categoriesStore.scrollController) : null,
),
),
],
if (_categoriesStore.categories.isEmpty) {
if (_categoriesStore.isLoading && _categoriesStore.error == null) {
statusWidget = const LoadingIndicator(subtitle: 'Loading categories...');
} else {
statusWidget = const AlertMessage(message: 'No top categories');
}
}
if (statusWidget != null) {
return CustomScrollView(
slivers: [
SliverFillRemaining(
child: Center(
child: statusWidget,
),
)
],
);
}
return GridView.builder(
physics: const AlwaysScrollableScrollPhysics(),
controller: widget.scrollController,
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
),
itemCount: _categoriesStore.categories.length,
itemBuilder: (context, index) {
if (index > _categoriesStore.categories.length - 8 && _categoriesStore.hasMore) {
_categoriesStore.getCategories();
}
return CategoryCard(
category: _categoriesStore.categories[index],
);
},
);
},
),
@ -88,10 +103,4 @@ class _CategoriesState extends State<Categories> with AutomaticKeepAliveClientMi
@override
bool get wantKeepAlive => true;
@override
void dispose() {
_categoriesStore.dispose();
super.dispose();
}
}

View file

@ -2,43 +2,65 @@ import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:frosty/api/twitch_api.dart';
import 'package:frosty/core/auth/auth_store.dart';
import 'package:frosty/models/category.dart';
import 'package:frosty/screens/home/stores/list_store.dart';
import 'package:frosty/screens/home/widgets/stream_card.dart';
import 'package:frosty/screens/settings/stores/settings_store.dart';
import 'package:frosty/widgets/alert_message.dart';
import 'package:frosty/widgets/loading_indicator.dart';
import 'package:frosty/widgets/scroll_to_top_button.dart';
import 'package:provider/provider.dart';
/// A widget that displays a list of streams under the provided [categoryId].
/// This differs from the normal [StreamsList] in that it uses slivers to display the box art on top.
class CategoryStreams extends StatefulWidget {
final ListStore listStore;
/// The category name, used for the header on the box art sliver.
final String categoryName;
const CategoryStreams({Key? key, required this.listStore}) : super(key: key);
/// The category id, used for fetching the relevant streams in the [ListStore].
final String categoryId;
const CategoryStreams({
Key? key,
required this.categoryName,
required this.categoryId,
}) : super(key: key);
@override
State<CategoryStreams> createState() => _CategoryStreamsState();
}
class _CategoryStreamsState extends State<CategoryStreams> {
late final _listStore = ListStore(
authStore: context.read<AuthStore>(),
twitchApi: context.read<TwitchApi>(),
listType: ListType.category,
categoryId: widget.categoryId,
scrollController: ScrollController(),
);
@override
Widget build(BuildContext context) {
// Calculate the dimensions of the box art based on the screen width.
final size = MediaQuery.of(context).size;
final pixelRatio = MediaQuery.of(context).devicePixelRatio;
final artWidth = (size.width * pixelRatio).toInt();
final artHeight = (artWidth * (4 / 3)).toInt();
final thumbnailWidth = (size.width * pixelRatio) ~/ 3;
final thumbnailHeight = (thumbnailWidth * (9 / 16)).toInt();
return Scaffold(
body: RefreshIndicator(
onRefresh: () async {
HapticFeedback.lightImpact();
await widget.listStore.refreshStreams();
await _listStore.refreshStreams();
if (widget.listStore.error != null) {
if (_listStore.error != null) {
final snackBar = SnackBar(
content: Text(widget.listStore.error!),
content: AlertMessage(
message: _listStore.error!,
icon: Icons.error,
),
behavior: SnackBarBehavior.floating,
);
@ -48,15 +70,12 @@ class _CategoryStreamsState extends State<CategoryStreams> {
},
child: Observer(
builder: (context) {
if (widget.listStore.streams.isEmpty && widget.listStore.isLoading && widget.listStore.error == null) {
return const LoadingIndicator(subtitle: Text('Loading streams...'));
}
return Stack(
alignment: AlignmentDirectional.bottomCenter,
children: [
CustomScrollView(
physics: const BouncingScrollPhysics(parent: AlwaysScrollableScrollPhysics()),
controller: widget.listStore.scrollController,
controller: _listStore.scrollController,
slivers: [
SliverAppBar(
stretch: true,
@ -69,55 +88,89 @@ class _CategoryStreamsState extends State<CategoryStreams> {
],
centerTitle: true,
title: Text(
widget.listStore.categoryInfo!.name,
widget.categoryName,
style: Theme.of(context).textTheme.headline6?.copyWith(fontWeight: FontWeight.bold),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
background: CachedNetworkImage(
imageUrl: widget.listStore.categoryInfo!.boxArtUrl.replaceRange(
widget.listStore.categoryInfo!.boxArtUrl.lastIndexOf('-') + 1,
null,
'${artWidth}x$artHeight.jpg',
),
placeholder: (context, url) => const LoadingIndicator(),
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 > widget.listStore.streams.length / 2 && widget.listStore.hasMore) {
widget.listStore.getStreams();
}
return Observer(
builder: (context) => StreamCard(
listStore: widget.listStore,
streamInfo: widget.listStore.streams[index],
width: thumbnailWidth,
height: thumbnailHeight,
showUptime: context.read<SettingsStore>().showThumbnailUptime,
showThumbnail: context.read<SettingsStore>().showThumbnails,
showCategory: false,
background: FutureBuilder(
future: context.read<TwitchApi>().getCategory(
headers: context.read<AuthStore>().headersTwitch,
gameId: _listStore.categoryId!,
),
);
builder: (context, AsyncSnapshot<CategoriesTwitch> snapshot) {
return snapshot.hasData
? CachedNetworkImage(
imageUrl: snapshot.data!.data.first.boxArtUrl.replaceRange(
snapshot.data!.data.first.boxArtUrl.lastIndexOf('-') + 1,
null,
'${artWidth}x$artHeight.jpg',
),
placeholder: (context, url) => const LoadingIndicator(),
color: const Color.fromRGBO(255, 255, 255, 0.5),
colorBlendMode: BlendMode.modulate,
fit: BoxFit.cover,
)
: const SizedBox();
},
childCount: widget.listStore.streams.length,
),
),
),
if (_listStore.error != null)
SliverFillRemaining(
hasScrollBody: false,
child: AlertMessage(
message: _listStore.error!,
icon: Icons.error,
),
)
else if (_listStore.streams.isEmpty)
if (_listStore.isLoading && _listStore.error == null)
const SliverFillRemaining(
hasScrollBody: false,
child: LoadingIndicator(
subtitle: 'Loading streams...',
),
)
else
const SliverFillRemaining(
hasScrollBody: false,
child: AlertMessage(
message: 'No streams found',
),
)
else
SliverSafeArea(
top: false,
sliver: SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
if (index > _listStore.streams.length - 8 && _listStore.hasMore) {
_listStore.getStreams();
}
return Observer(
builder: (context) => StreamCard(
streamInfo: _listStore.streams[index],
showUptime: context.read<SettingsStore>().showThumbnailUptime,
showThumbnail: context.read<SettingsStore>().showThumbnails,
large: context.read<SettingsStore>().largeStreamCard,
showCategory: false,
),
);
},
childCount: _listStore.streams.length,
),
),
),
],
),
SafeArea(
child: Observer(
builder: (context) => AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
child: widget.listStore.showJumpButton ? ScrollToTopButton(scrollController: widget.listStore.scrollController) : null,
switchInCurve: Curves.easeOutCubic,
switchOutCurve: Curves.easeInCubic,
child: _listStore.showJumpButton ? ScrollToTopButton(scrollController: _listStore.scrollController!) : null,
),
),
),
@ -128,4 +181,10 @@ class _CategoryStreamsState extends State<CategoryStreams> {
),
);
}
@override
void dispose() {
_listStore.dispose();
super.dispose();
}
}

View file

@ -1,44 +1,73 @@
import 'package:flutter/material.dart';
import 'package:frosty/screens/home/stores/home_store.dart';
import 'package:frosty/screens/home/stores/list_store.dart';
import 'package:frosty/screens/home/top/categories.dart';
import 'package:frosty/screens/home/widgets/streams_list.dart';
class TopSection extends StatelessWidget {
const TopSection({Key? key}) : super(key: key);
class TopSection extends StatefulWidget {
final HomeStore homeStore;
const TopSection({
Key? key,
required this.homeStore,
}) : super(key: key);
@override
State<TopSection> createState() => _TopSectionState();
}
class _TopSectionState extends State<TopSection> with SingleTickerProviderStateMixin {
late final _tabBarController = TabController(length: 2, vsync: this);
@override
void initState() {
super.initState();
_tabBarController.addListener(() => widget.homeStore.topSectionCurrentIndex = _tabBarController.index);
}
@override
Widget build(BuildContext context) {
return DefaultTabController(
length: 2,
child: Column(
children: const [
TabBar(
indicatorColor: Color(0xff9146ff),
tabs: [
Tab(
child: Text(
'Streams',
style: TextStyle(fontWeight: FontWeight.w600),
),
return Column(
children: [
TabBar(
controller: _tabBarController,
indicatorColor: const Color(0xff9146ff),
tabs: const [
Tab(
child: Text(
'Streams',
style: TextStyle(fontWeight: FontWeight.w600),
),
Tab(
child: Text(
'Categories',
style: TextStyle(fontWeight: FontWeight.w600),
),
),
Tab(
child: Text(
'Categories',
style: TextStyle(fontWeight: FontWeight.w600),
),
),
],
),
Expanded(
child: TabBarView(
controller: _tabBarController,
children: [
StreamsList(
listType: ListType.top,
scrollController: widget.homeStore.topSectionScrollControllers[0],
),
Categories(
scrollController: widget.homeStore.topSectionScrollControllers[1],
),
],
),
Expanded(
child: TabBarView(
children: [
StreamsList(listType: ListType.top),
Categories(),
],
),
),
],
),
),
],
);
}
@override
void dispose() {
_tabBarController.dispose();
super.dispose();
}
}

View file

@ -1,39 +1,34 @@
import 'package:cached_network_image/cached_network_image.dart';
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:frosty/screens/home/stores/list_store.dart';
import 'package:frosty/screens/home/top/category_streams.dart';
import 'package:frosty/widgets/animate_scale.dart';
import 'package:frosty/widgets/loading_indicator.dart';
import 'package:provider/provider.dart';
/// A tappable card widget that displays a category's box art and name.
/// A tappable card widget that displays a category's box art and name under.
class CategoryCard extends StatelessWidget {
final CategoryTwitch category;
final int width;
final int height;
const CategoryCard({
Key? key,
required this.category,
required this.width,
required this.height,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return InkWell(
// Calculate the dimmensions of the box art based on the current dimmensions of the screen.
final size = MediaQuery.of(context).size;
final pixelRatio = MediaQuery.of(context).devicePixelRatio;
final artWidth = (size.width * pixelRatio) ~/ 3;
final artHeight = (artWidth * (4 / 3)).toInt();
return AnimateScale(
onTap: () => Navigator.push(
context,
MaterialPageRoute(
builder: (context) => CategoryStreams(
listStore: ListStore(
twitchApi: context.read<TwitchApi>(),
authStore: context.read<AuthStore>(),
listType: ListType.category,
categoryInfo: category,
),
categoryName: category.name,
categoryId: category.id,
),
),
),
@ -47,7 +42,7 @@ class CategoryCard extends StatelessWidget {
child: AspectRatio(
aspectRatio: 3 / 4,
child: CachedNetworkImage(
imageUrl: category.boxArtUrl.replaceRange(category.boxArtUrl.lastIndexOf('-') + 1, null, '${width}x$height.jpg'),
imageUrl: category.boxArtUrl.replaceRange(category.boxArtUrl.lastIndexOf('-') + 1, null, '${artWidth}x$artHeight.jpg'),
placeholder: (context, url) => const LoadingIndicator(),
),
),

View file

@ -1,12 +1,14 @@
import 'dart:math';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:frosty/api/twitch_api.dart';
import 'package:flutter/services.dart';
import 'package:frosty/constants/constants.dart';
import 'package:frosty/core/auth/auth_store.dart';
import 'package:frosty/models/stream.dart';
import 'package:frosty/screens/channel/video_chat.dart';
import 'package:frosty/screens/home/stores/list_store.dart';
import 'package:frosty/screens/home/top/category_streams.dart';
import 'package:frosty/widgets/animate_scale.dart';
import 'package:frosty/widgets/block_report_modal.dart';
import 'package:frosty/widgets/loading_indicator.dart';
import 'package:frosty/widgets/profile_picture.dart';
@ -14,188 +16,226 @@ import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
/// A tappable card widget that displays a stream's thumbnail and details.
class StreamCard extends StatefulWidget {
final ListStore listStore;
class StreamCard extends StatelessWidget {
final StreamTwitch streamInfo;
final int width;
final int height;
final bool showUptime;
final bool showThumbnail;
final bool large;
final bool showCategory;
const StreamCard({
Key? key,
required this.listStore,
required this.streamInfo,
required this.width,
required this.height,
required this.showUptime,
required this.showThumbnail,
required this.large,
this.showCategory = true,
}) : super(key: key);
@override
State<StreamCard> createState() => _StreamCardState();
}
class _StreamCardState extends State<StreamCard> {
@override
Widget build(BuildContext context) {
// Get a new URL for the thumbnail every 5 minutes so that the image is updated on refresh.
// This method adds a random value to the end of the URL to override the cached image.
final time = DateTime.now();
final cacheUrlExtension = time.day.toString() + time.hour.toString() + (time.minute ~/ 5).toString();
final thumbnail = AspectRatio(
// Calculate the width and height of the thumbnail based on the device width and the stream card size setting.
// Constraint the resolution to 1920x1080 since that's the max resolution of the Twitch API.
final size = MediaQuery.of(context).size;
final pixelRatio = MediaQuery.of(context).devicePixelRatio;
final thumbnailWidth = min((size.width * pixelRatio) ~/ (large ? 1 : 3), 1920);
final thumbnailHeight = min((thumbnailWidth * (9 / 16)).toInt(), 1080);
final image = AspectRatio(
aspectRatio: 16 / 9,
child: CachedNetworkImage(
imageUrl: widget.streamInfo.thumbnailUrl.replaceFirst('-{width}x{height}', '-${widget.width}x${widget.height}') + cacheUrlExtension,
imageUrl: streamInfo.thumbnailUrl.replaceFirst('-{width}x{height}', '-${thumbnailWidth}x$thumbnailHeight') + cacheUrlExtension,
placeholder: (context, url) => const LoadingIndicator(),
useOldImageOnUrlChange: true,
),
);
final streamerName = regexEnglish.hasMatch(widget.streamInfo.userName)
? widget.streamInfo.userName
: '${widget.streamInfo.userName} (${widget.streamInfo.userLogin})';
final thumbnail = large
? Container(
foregroundDecoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.transparent,
Colors.black,
],
),
),
child: image,
)
: image;
return InkWell(
final streamerName = regexEnglish.hasMatch(streamInfo.userName) ? streamInfo.userName : '${streamInfo.userName} (${streamInfo.userLogin})';
final subFontSize = large ? 16.0 : 14.0;
final fontColor = large ? Colors.white : DefaultTextStyle.of(context).style.color;
final imageSection = ClipRRect(
borderRadius: large ? const BorderRadius.all(Radius.circular(10.0)) : const BorderRadius.all(Radius.circular(5.0)),
child: showUptime
? Stack(
alignment: AlignmentDirectional.bottomEnd,
children: [
thumbnail,
if (large)
Padding(
padding: const EdgeInsets.all(10.0),
child: Text(
DateTime.now().difference(DateTime.parse(streamInfo.startedAt)).toString().split('.')[0],
style: TextStyle(
fontSize: 16.0,
color: Colors.white.withOpacity(0.8),
),
),
)
else
Container(
color: const Color.fromRGBO(0, 0, 0, 0.5),
padding: const EdgeInsets.symmetric(horizontal: 2.0),
child: Text(
DateTime.now().difference(DateTime.parse(streamInfo.startedAt)).toString().split('.')[0],
style: const TextStyle(fontSize: 12, color: Colors.white),
),
)
],
)
: thumbnail,
);
final streamInfoSection = Padding(
padding: large ? const EdgeInsets.all(10.0) : const EdgeInsets.only(left: 10.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
ProfilePicture(
userLogin: streamInfo.userLogin,
radius: 10.0,
),
const SizedBox(width: 5.0),
Flexible(
child: Tooltip(
message: streamerName,
preferBelow: false,
child: Text(
streamerName,
style: TextStyle(
fontSize: large ? 20.0 : 16.0,
fontWeight: FontWeight.bold,
color: fontColor,
),
overflow: TextOverflow.ellipsis,
),
),
),
],
),
const SizedBox(height: 5.0),
Tooltip(
message: streamInfo.title.trim(),
preferBelow: false,
padding: const EdgeInsets.all(10.0),
child: Text(
streamInfo.title.trim(),
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontWeight: FontWeight.w500,
fontSize: subFontSize,
color: fontColor,
),
),
),
const SizedBox(height: 5.0),
if (showCategory) ...[
InkWell(
child: Tooltip(
message: streamInfo.gameName,
preferBelow: false,
child: Text(
streamInfo.gameName,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: subFontSize,
color: fontColor?.withOpacity(0.8),
),
),
),
onTap: () => Navigator.push(
context,
MaterialPageRoute(
builder: (context) => CategoryStreams(
categoryName: streamInfo.gameName,
categoryId: streamInfo.gameId,
),
),
),
),
const SizedBox(height: 5.0),
],
Text(
'${NumberFormat().format(streamInfo.viewerCount)} viewers',
style: TextStyle(
fontSize: subFontSize,
color: fontColor?.withOpacity(0.8),
),
),
],
),
);
return AnimateScale(
onTap: () => Navigator.push(
context,
MaterialPageRoute(
builder: (context) => VideoChat(
userId: widget.streamInfo.userId,
userName: widget.streamInfo.userName,
userLogin: widget.streamInfo.userLogin,
userId: streamInfo.userId,
userName: streamInfo.userName,
userLogin: streamInfo.userLogin,
),
),
),
onLongPress: () => showModalBottomSheet(
context: context,
builder: (context) => BlockReportModal(
authStore: widget.listStore.authStore,
name: streamerName,
userLogin: widget.streamInfo.userLogin,
userId: widget.streamInfo.userId,
),
),
child: Padding(
padding: EdgeInsets.symmetric(vertical: 10.0, horizontal: widget.showThumbnail ? 15.0 : 5.0),
child: Row(
children: [
if (widget.showThumbnail)
Flexible(
flex: 1,
child: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(5.0)),
child: widget.showUptime
? Stack(
alignment: AlignmentDirectional.bottomEnd,
children: [
thumbnail,
Container(
color: const Color.fromRGBO(0, 0, 0, 0.5),
padding: const EdgeInsets.symmetric(horizontal: 2.0),
child: Text(
DateTime.now().difference(DateTime.parse(widget.streamInfo.startedAt)).toString().split('.')[0],
style: const TextStyle(fontSize: 12, color: Colors.white),
),
)
],
)
: thumbnail,
),
),
Flexible(
flex: 2,
child: Padding(
padding: const EdgeInsets.only(left: 10.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
ProfilePicture(
userLogin: widget.streamInfo.userLogin,
radius: 10.0,
),
const SizedBox(width: 5.0),
Flexible(
child: Tooltip(
message: streamerName,
preferBelow: false,
child: Text(
streamerName,
style: const TextStyle(
fontSize: 16.0,
fontWeight: FontWeight.bold,
),
overflow: TextOverflow.ellipsis,
),
),
),
],
),
const SizedBox(height: 5.0),
Tooltip(
message: widget.streamInfo.title.trim(),
preferBelow: false,
padding: const EdgeInsets.all(10.0),
child: Text(
widget.streamInfo.title.trim(),
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontWeight: FontWeight.w500,
),
),
),
const SizedBox(height: 5.0),
if (widget.showCategory) ...[
InkWell(
child: Tooltip(
message: widget.streamInfo.gameName,
preferBelow: false,
child: Text(
widget.streamInfo.gameName,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: DefaultTextStyle.of(context).style.color?.withOpacity(0.8),
),
),
),
onTap: () async {
final category = await context
.read<TwitchApi>()
.getCategory(headers: context.read<AuthStore>().headersTwitch, gameId: widget.streamInfo.gameId);
onLongPress: () {
HapticFeedback.mediumImpact();
if (!mounted) return;
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => CategoryStreams(
listStore: ListStore(
twitchApi: context.read<TwitchApi>(),
authStore: context.read<AuthStore>(),
listType: ListType.category,
categoryInfo: category.data.first,
),
),
),
);
},
),
const SizedBox(height: 5.0),
],
Text(
'${NumberFormat().format(widget.streamInfo.viewerCount)} viewers',
style: TextStyle(
color: DefaultTextStyle.of(context).style.color?.withOpacity(0.8),
),
showModalBottomSheet(
backgroundColor: Colors.transparent,
context: context,
builder: (context) => BlockReportModal(
authStore: context.read<AuthStore>(),
name: streamerName,
userLogin: streamInfo.userLogin,
userId: streamInfo.userId,
),
);
},
child: Padding(
padding: EdgeInsets.symmetric(vertical: 10.0, horizontal: showThumbnail ? 15.0 : 5.0),
child: large
? Stack(
alignment: AlignmentDirectional.bottomStart,
children: [if (showThumbnail) imageSection, streamInfoSection],
)
: Row(
children: [
if (showThumbnail)
Flexible(
flex: 1,
child: imageSection,
),
],
),
Flexible(
flex: 2,
child: streamInfoSection,
),
],
),
),
],
),
),
);
}

View file

@ -3,22 +3,26 @@ import 'package:flutter/services.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:frosty/api/twitch_api.dart';
import 'package:frosty/core/auth/auth_store.dart';
import 'package:frosty/models/category.dart';
import 'package:frosty/screens/home/stores/list_store.dart';
import 'package:frosty/screens/home/widgets/stream_card.dart';
import 'package:frosty/screens/settings/stores/settings_store.dart';
import 'package:frosty/widgets/alert_message.dart';
import 'package:frosty/widgets/loading_indicator.dart';
import 'package:frosty/widgets/scroll_to_top_button.dart';
import 'package:provider/provider.dart';
/// A widget that displays a list of followed or top streams based on the provided [listType].
/// For a widget that displays the top streams under a category, refer to [CategoryStreams].
class StreamsList extends StatefulWidget {
/// The type of list to display.
final ListType listType;
final CategoryTwitch? categoryTwitch;
/// The scroll controller to use for scroll to top functionality.
final ScrollController scrollController;
const StreamsList({
Key? key,
required this.listType,
this.categoryTwitch,
required this.scrollController,
}) : super(key: key);
@override
@ -30,26 +34,24 @@ class _StreamsListState extends State<StreamsList> with AutomaticKeepAliveClient
authStore: context.read<AuthStore>(),
twitchApi: context.read<TwitchApi>(),
listType: widget.listType,
categoryInfo: widget.categoryTwitch,
);
@override
Widget build(BuildContext context) {
super.build(context);
final size = MediaQuery.of(context).size;
final pixelRatio = MediaQuery.of(context).devicePixelRatio;
final thumbnailWidth = (size.width * pixelRatio) ~/ 3;
final thumbnailHeight = (thumbnailWidth * (9 / 16)).toInt();
return RefreshIndicator(
onRefresh: () async {
HapticFeedback.lightImpact();
await _listStore.refreshStreams();
if (_listStore.error != null) {
final snackBar = SnackBar(
content: Text(_listStore.error!),
content: AlertMessage(
message: _listStore.error!,
icon: Icons.error,
),
behavior: SnackBarBehavior.floating,
);
@ -59,39 +61,54 @@ class _StreamsListState extends State<StreamsList> with AutomaticKeepAliveClient
},
child: Observer(
builder: (_) {
if (_listStore.streams.isEmpty && _listStore.isLoading && _listStore.error == null) {
return const LoadingIndicator(subtitle: Text('Loading streams...'));
Widget? statusWidget;
if (_listStore.error != null) {
statusWidget = AlertMessage(
message: _listStore.error!,
icon: Icons.error,
);
}
return Stack(
alignment: AlignmentDirectional.bottomCenter,
children: [
ListView.builder(
physics: const AlwaysScrollableScrollPhysics(),
controller: _listStore.scrollController,
itemCount: _listStore.streams.length,
itemBuilder: (context, index) {
if (index > _listStore.streams.length / 2 && _listStore.hasMore) {
_listStore.getStreams();
}
return Observer(
builder: (context) => StreamCard(
listStore: _listStore,
streamInfo: _listStore.streams[index],
width: thumbnailWidth,
height: thumbnailHeight,
showThumbnail: context.read<SettingsStore>().showThumbnails,
showUptime: context.read<SettingsStore>().showThumbnailUptime,
),
);
},
),
Observer(
builder: (context) => AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
child: _listStore.showJumpButton ? ScrollToTopButton(scrollController: _listStore.scrollController) : null,
if (_listStore.streams.isEmpty) {
if (_listStore.isLoading && _listStore.error == null) {
statusWidget = const LoadingIndicator(subtitle: 'Loading streams...');
} else {
statusWidget = AlertMessage(message: widget.listType == ListType.followed ? 'No followed streams' : 'No top streams');
}
}
if (statusWidget != null) {
return CustomScrollView(
slivers: [
SliverFillRemaining(
child: Center(
child: statusWidget,
),
)
],
);
}
return ListView.builder(
physics: const AlwaysScrollableScrollPhysics(),
controller: widget.scrollController,
itemCount: _listStore.streams.length,
itemBuilder: (context, index) {
if (index > _listStore.streams.length - 8 && _listStore.hasMore) {
debugPrint('$index ${_listStore.streams.length}');
_listStore.getStreams();
}
return Observer(
builder: (context) => StreamCard(
streamInfo: _listStore.streams[index],
showThumbnail: context.read<SettingsStore>().showThumbnails,
large: context.read<SettingsStore>().largeStreamCard,
showUptime: context.read<SettingsStore>().showThumbnailUptime,
),
),
],
);
},
);
},
),

View file

@ -5,7 +5,10 @@ import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:frosty/constants/constants.dart';
import 'package:frosty/core/auth/auth_store.dart';
import 'package:frosty/screens/settings/stores/settings_store.dart';
import 'package:frosty/widgets/alert_message.dart';
import 'package:frosty/widgets/block_button.dart';
import 'package:frosty/widgets/button.dart';
import 'package:frosty/widgets/dialog.dart';
import 'package:webview_flutter/webview_flutter.dart';
class AccountSettings extends StatelessWidget {
@ -60,30 +63,12 @@ class ProfileCard extends StatelessWidget {
Future<void> _showLoginDialog(BuildContext context) {
return showDialog(
context: context,
builder: (context) => SimpleDialog(
children: [
ColoredBox(
color: const Color.fromRGBO(145, 70, 255, 0.8),
child: SimpleDialogOption(
padding: const EdgeInsets.all(24.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Image.asset(
'assets/icons/TwitchGlitchWhite.png',
height: 25,
),
const SizedBox(width: 10),
const Text(
'Connect with Twitch',
style: TextStyle(
fontSize: 16,
color: Colors.white,
fontWeight: FontWeight.w600,
),
),
],
),
builder: (context) => FrostyDialog(
title: 'Log In',
content: Column(
children: [
Button(
padding: const EdgeInsets.symmetric(horizontal: 30.0, vertical: 15.0),
onPressed: () => Navigator.push(
context,
MaterialPageRoute(
@ -101,29 +86,40 @@ class ProfileCard extends StatelessWidget {
},
),
),
icon: Image.asset(
'assets/icons/TwitchGlitchWhite.png',
height: 25,
),
child: const Text(
'Connect with Twitch',
style: TextStyle(
fontSize: 16,
color: Colors.white,
fontWeight: FontWeight.w600,
),
),
),
),
const SizedBox(height: 20.0),
const Center(
child: Text(
'Or',
style: TextStyle(fontStyle: FontStyle.italic),
const SizedBox(height: 20.0),
const Center(
child: Text(
'Or',
style: TextStyle(fontWeight: FontWeight.w600),
),
),
),
const SizedBox(height: 5.0),
SimpleDialogOption(
child: TextField(
const SizedBox(height: 20.0),
TextField(
autocorrect: false,
textAlign: TextAlign.center,
decoration: const InputDecoration(
labelText: 'Token',
hintText: 'Token',
),
onSubmitted: (token) {
authStore.login(token: token);
Navigator.pop(context);
},
),
)
],
)
],
),
),
);
}
@ -131,22 +127,23 @@ class ProfileCard extends StatelessWidget {
Future<void> _showLogoutDialog(BuildContext context) {
return showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Log Out'),
builder: (context) => FrostyDialog(
title: 'Log Out',
content: const Text('Are you sure you want to log out?'),
actions: [
TextButton(
onPressed: Navigator.of(context).pop,
style: TextButton.styleFrom(primary: Colors.red),
child: const Text('Cancel'),
),
ElevatedButton(
Button(
onPressed: () {
authStore.logout();
Navigator.pop(context);
},
child: const Text('Yes'),
),
Button(
fill: true,
onPressed: Navigator.of(context).pop,
color: Colors.red.shade700,
child: const Text('Cancel'),
)
],
),
);
@ -154,51 +151,56 @@ class ProfileCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Center(
child: Observer(
builder: (context) {
if (authStore.error != null) {
return ListTile(
title: const Text('Failed to Connect'),
trailing: OutlinedButton(
onPressed: authStore.init,
child: const Text('Try Again'),
),
);
}
if (authStore.isLoggedIn && authStore.user.details != null) {
return ListTile(
leading: CircleAvatar(
foregroundImage: CachedNetworkImageProvider(authStore.user.details!.profileImageUrl),
),
title: Text(
authStore.user.details!.displayName,
style: const TextStyle(fontWeight: FontWeight.w600),
),
trailing: OutlinedButton.icon(
onPressed: () => _showLogoutDialog(context),
icon: const Icon(Icons.logout_outlined),
label: const Text('Log Out'),
style: OutlinedButton.styleFrom(primary: Colors.red),
),
);
}
return Observer(
builder: (context) {
if (authStore.error != null) {
return ListTile(
isThreeLine: true,
leading: const Icon(
Icons.no_accounts,
size: 40,
),
title: const Text('Anonymous User'),
subtitle: const Text('Log in to chat, view followed streams, and more.'),
trailing: ElevatedButton.icon(
onPressed: () => _showLoginDialog(context),
icon: const Icon(Icons.login),
label: const Text('Log In'),
leading: const Icon(Icons.error),
title: const Text('Failed to connect'),
trailing: Button(
onPressed: authStore.init,
padding: const EdgeInsets.symmetric(horizontal: 15.0),
child: const Text('Try Again'),
),
);
},
),
}
if (authStore.isLoggedIn && authStore.user.details != null) {
return ListTile(
leading: CircleAvatar(
foregroundImage: CachedNetworkImageProvider(authStore.user.details!.profileImageUrl),
),
title: Text(
authStore.user.details!.displayName,
style: const TextStyle(fontWeight: FontWeight.w600),
),
trailing: Button(
onPressed: () => _showLogoutDialog(context),
icon: const Icon(Icons.logout_outlined),
padding: const EdgeInsets.symmetric(horizontal: 15.0),
fill: true,
color: Colors.red.shade700,
child: const Text('Log Out'),
),
);
}
return ListTile(
isThreeLine: true,
leading: const Icon(
Icons.no_accounts,
size: 40,
),
title: const Text('Anonymous User'),
subtitle: const Text('Log in to chat, view followed streams, and more.'),
trailing: Button(
color: Theme.of(context).colorScheme.secondary,
onPressed: () => _showLoginDialog(context),
fill: true,
padding: const EdgeInsets.symmetric(horizontal: 15.0),
icon: const Icon(Icons.login),
child: const Text('Log In'),
),
);
},
);
}
}
@ -220,21 +222,32 @@ class BlockedUsers extends StatelessWidget {
body: RefreshIndicator(
onRefresh: () async {
HapticFeedback.lightImpact();
await authStore.user.refreshBlockedUsers(headers: authStore.headersTwitch);
},
child: Observer(
builder: (context) {
if (authStore.user.blockedUsers.isEmpty) {
return const Center(
child: Text('You don\'t have any blocked users.'),
child: AlertMessage(
message: 'No blocked users',
),
);
}
return ListView(
children: authStore.user.blockedUsers.map(
(user) {
final displayName = regexEnglish.hasMatch(user.displayName) ? user.displayName : '${user.displayName} (${user.userLogin})';
return ListTile(
title: Text(displayName),
title: Tooltip(
preferBelow: false,
message: displayName,
child: Text(
displayName,
overflow: TextOverflow.ellipsis,
),
),
trailing: BlockButton(
authStore: authStore,
targetUser: displayName,

View file

@ -1,3 +1,5 @@
import 'dart:io';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
@ -41,7 +43,7 @@ class _ChatSettingsState extends State<ChatSettings> {
SwitchListTile.adaptive(
isThreeLine: true,
title: const Text('Autocomplete'),
subtitle: const Text('Shows a bar that suggests matching emotes and mentions when typing.'),
subtitle: const Text('Shows a bar that suggests matching emotes and mentions while typing.'),
value: settingsStore.autocomplete,
onChanged: settingsStore.showBottomBar ? (newValue) => settingsStore.autocomplete = newValue : null,
),
@ -49,31 +51,38 @@ class _ChatSettingsState extends State<ChatSettings> {
ListTile(
title: Row(
children: [
const Text('Message Delay'),
const Text('Message delay'),
const Spacer(),
Text('${settingsStore.chatDelay.toInt()} ${settingsStore.chatDelay == 1.0 ? 'second' : 'seconds'}'),
],
),
subtitle: Slider.adaptive(
value: settingsStore.chatDelay,
min: 0.0,
max: 30.0,
divisions: 30,
onChanged: (newValue) => settingsStore.chatDelay = newValue,
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Slider.adaptive(
value: settingsStore.chatDelay,
min: 0.0,
max: 30.0,
divisions: 30,
onChanged: (newValue) => settingsStore.chatDelay = newValue,
),
Text('Adds a delay before each message is rendered in chat. ${Platform.isIOS ? '15 seconds is recommended for iOS.' : ''}'),
const SizedBox(height: 15),
],
),
),
const SectionHeader(
'Layout',
fontSize: 12.0,
padding: sectionPadding,
showDivider: true,
),
SwitchListTile.adaptive(
title: const Text('Bottom Bar'),
title: const Text('Bottom bar'),
value: settingsStore.showBottomBar,
onChanged: (newValue) => settingsStore.showBottomBar = newValue,
),
SwitchListTile.adaptive(
title: const Text('Landscape Chat on Left Side'),
title: const Text('Landscape chat on left side'),
value: settingsStore.landscapeChatLeftSide,
onChanged: (newValue) => settingsStore.landscapeChatLeftSide = newValue,
),
@ -81,77 +90,90 @@ class _ChatSettingsState extends State<ChatSettings> {
ListTile(
title: Row(
children: [
const Text('Landscape Chat Width'),
const Text('Chat width'),
const Spacer(),
Text('${(settingsStore.chatWidth * 100).toStringAsFixed(0)}%'),
],
),
subtitle: Slider.adaptive(
value: settingsStore.chatWidth,
min: 0.2,
max: 0.6,
divisions: 8,
onChanged: (newValue) => settingsStore.chatWidth = newValue,
subtitle: Column(
children: [
Slider.adaptive(
value: settingsStore.chatWidth,
min: 0.2,
max: 0.6,
divisions: 8,
onChanged: (newValue) => settingsStore.chatWidth = newValue,
),
const Text('Sets the width of the chat in fullscreen and theater mode.'),
const SizedBox(height: 15),
],
),
),
const SizedBox(height: 15.0),
ListTile(
title: Row(
children: [
const Text('Landscape Chat Overlay Opacity'),
const Text('Chat overlay opacity'),
const Spacer(),
Text('${(settingsStore.fullScreenChatOverlayOpacity * 100).toStringAsFixed(0)}%'),
],
),
subtitle: Slider.adaptive(
value: settingsStore.fullScreenChatOverlayOpacity,
min: 0.0,
max: 1.0,
divisions: 10,
onChanged: (newValue) => settingsStore.fullScreenChatOverlayOpacity = newValue,
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Slider.adaptive(
value: settingsStore.fullScreenChatOverlayOpacity,
min: 0.0,
max: 1.0,
divisions: 10,
onChanged: (newValue) => settingsStore.fullScreenChatOverlayOpacity = newValue,
),
const Text('Sets the opacity (transparency) of the overlay chat in fullscreen mode.'),
const SizedBox(height: 15),
],
),
),
const SectionHeader(
'Emotes',
fontSize: 12.0,
padding: sectionPadding,
showDivider: true,
),
SwitchListTile.adaptive(
isThreeLine: true,
title: const Text('Zero-Width Emotes'),
title: const Text('Zero-width emotes'),
subtitle: const Text('Shows "stacked" emotes from BetterTTV and 7TV.'),
value: settingsStore.showZeroWidth,
onChanged: (newValue) => settingsStore.showZeroWidth = newValue,
),
const SectionHeader(
'Message Appearance',
fontSize: 12.0,
padding: sectionPadding,
showDivider: true,
),
SwitchListTile.adaptive(
isThreeLine: true,
title: const Text('Readable Name Colors'),
title: const Text('Readable name colors'),
subtitle: const Text('Adjusts the lightness value of overly bright or dark names.'),
value: settingsStore.useReadableColors,
onChanged: (newValue) => settingsStore.useReadableColors = newValue,
),
SwitchListTile.adaptive(
isThreeLine: true,
title: const Text('Show Deleted Messages'),
title: const Text('Show deleted messages'),
subtitle: const Text('Restores the original message of deleted messages.'),
value: settingsStore.showDeletedMessages,
onChanged: (newValue) => settingsStore.showDeletedMessages = newValue,
),
SwitchListTile.adaptive(
isThreeLine: true,
title: const Text('Message Dividers'),
title: const Text('Message dividers'),
subtitle: const Text('Shows a subtle divider between each message.'),
value: settingsStore.showChatMessageDividers,
onChanged: (newValue) => settingsStore.showChatMessageDividers = newValue,
),
ListTile(
isThreeLine: true,
title: const Text('Message Timestamps'),
title: const Text('Message timestamps'),
subtitle: const Text('Shows timestamps for when a message was sent.'),
trailing: DropdownButton(
value: settingsStore.timestampType,
@ -166,8 +188,8 @@ class _ChatSettingsState extends State<ChatSettings> {
),
const SectionHeader(
'Message Sizing',
fontSize: 12.0,
padding: sectionPadding,
showDivider: true,
),
ExpansionTile(
title: const Text('Preview'),
@ -229,7 +251,7 @@ class _ChatSettingsState extends State<ChatSettings> {
ListTile(
title: Row(
children: [
const Text('Badge Scale'),
const Text('Badge scale'),
const Spacer(),
Text('${settingsStore.badgeScale.toStringAsFixed(2)}x'),
],
@ -245,7 +267,7 @@ class _ChatSettingsState extends State<ChatSettings> {
ListTile(
title: Row(
children: [
const Text('Emote Scale'),
const Text('Emote scale'),
const Spacer(),
Text('${settingsStore.emoteScale.toStringAsFixed(2)}x'),
],
@ -261,7 +283,7 @@ class _ChatSettingsState extends State<ChatSettings> {
ListTile(
title: Row(
children: [
const Text('Message Scale'),
const Text('Message scale'),
const Spacer(),
Text('${settingsStore.messageScale.toStringAsFixed(2)}x'),
],
@ -277,7 +299,7 @@ class _ChatSettingsState extends State<ChatSettings> {
ListTile(
title: Row(
children: [
const Text('Message Spacing'),
const Text('Message spacing'),
const Spacer(),
Text(settingsStore.messageSpacing.toStringAsFixed(0).toString()),
],
@ -293,7 +315,7 @@ class _ChatSettingsState extends State<ChatSettings> {
ListTile(
title: Row(
children: [
const Text('Font Size'),
const Text('Font size'),
const Spacer(),
Text(settingsStore.fontSize.toInt().toString()),
],

View file

@ -36,20 +36,25 @@ class GeneralSettings extends StatelessWidget {
),
),
SwitchListTile.adaptive(
title: const Text('Stream Card Thumbnails'),
title: const Text('Large stream card'),
value: settingsStore.largeStreamCard,
onChanged: (newValue) => settingsStore.largeStreamCard = newValue,
),
SwitchListTile.adaptive(
title: const Text('Stream card thumbnails'),
value: settingsStore.showThumbnails,
onChanged: (newValue) => settingsStore.showThumbnails = newValue,
),
SwitchListTile.adaptive(
isThreeLine: true,
title: const Text('Stream Uptime on Thumbnails'),
title: const Text('Stream uptime on thumbnails'),
subtitle: const Text('Shows the uptime of the stream in the HH:MM:SS format.'),
value: settingsStore.showThumbnailUptime,
onChanged: settingsStore.showThumbnails ? (newValue) => settingsStore.showThumbnailUptime = newValue : null,
),
SwitchListTile.adaptive(
isThreeLine: true,
title: const Text('Launch URLs in External Browser'),
title: const Text('Launch URLs in external browser'),
subtitle: const Text('Opens links in the default external browser.'),
value: settingsStore.launchUrlExternal,
onChanged: (newValue) => settingsStore.launchUrlExternal = newValue,

View file

@ -1,9 +1,13 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:frosty/constants/constants.dart';
import 'package:frosty/screens/settings/stores/settings_store.dart';
import 'package:frosty/widgets/alert_message.dart';
import 'package:frosty/widgets/button.dart';
import 'package:frosty/widgets/dialog.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:sentry_flutter/sentry_flutter.dart';
import 'package:url_launcher/url_launcher.dart';
@ -24,28 +28,33 @@ class _OtherSettingsState extends State<OtherSettings> {
Future<void> _showConfirmDialog(BuildContext context) {
return showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Reset All Settings'),
builder: (context) => FrostyDialog(
title: 'Reset All Settings',
content: const Text('Are you sure you want to reset all settings?'),
actions: [
TextButton(
onPressed: Navigator.of(context).pop,
style: TextButton.styleFrom(primary: Colors.red),
child: const Text('Cancel'),
),
ElevatedButton(
Button(
onPressed: () {
HapticFeedback.heavyImpact();
widget.settingsStore.resetAllSettings();
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Settings reset!'),
content: AlertMessage(message: 'All settings reset'),
behavior: SnackBarBehavior.floating,
),
);
},
child: const Text('Yes'),
),
Button(
fill: true,
onPressed: Navigator.of(context).pop,
color: Colors.red.shade700,
child: const Text('Cancel'),
),
],
),
);
@ -54,6 +63,7 @@ class _OtherSettingsState extends State<OtherSettings> {
@override
Widget build(BuildContext context) {
return ExpansionTile(
childrenPadding: const EdgeInsets.only(bottom: 10.0),
leading: const Icon(Icons.help),
title: const Text(
'Other',
@ -89,7 +99,9 @@ class _OtherSettingsState extends State<OtherSettings> {
),
Observer(
builder: (_) => SwitchListTile.adaptive(
title: const Text('Send Anonymous Crash Logs'),
title: const Text('Send anonymous crash logs'),
isThreeLine: true,
subtitle: const Text('Help improve Frosty by sending anonymous crash logs through Sentry.'),
value: widget.settingsStore.sendCrashLogs,
onChanged: (newValue) {
if (newValue == true) {
@ -101,32 +113,39 @@ class _OtherSettingsState extends State<OtherSettings> {
},
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 15.0),
width: double.infinity,
child: OutlinedButton.icon(
...[
Button(
padding: const EdgeInsets.symmetric(horizontal: 15.0, vertical: 10.0),
icon: const Icon(Icons.delete_sweep),
label: const Text('Clear Image Cache'),
child: const Text('Clear Image Cache'),
onPressed: () async {
HapticFeedback.mediumImpact();
await DefaultCacheManager().emptyCache();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Image cache cleared!'),
content: AlertMessage(message: 'Image cache cleared'),
behavior: SnackBarBehavior.floating,
),
);
},
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 15.0),
width: double.infinity,
child: OutlinedButton.icon(
icon: const Icon(Icons.restore),
label: const Text('Reset All Settings'),
Button(
padding: const EdgeInsets.symmetric(horizontal: 15.0, vertical: 10.0),
onPressed: () => _showConfirmDialog(context),
icon: const Icon(Icons.restore),
child: const Text('Reset All Settings'),
)
].map(
(button) => Container(
padding: const EdgeInsets.symmetric(
horizontal: 15.0,
vertical: 5.0,
),
width: double.infinity,
child: button,
),
),
],

View file

@ -29,7 +29,7 @@ class VideoSettings extends StatelessWidget {
),
SwitchListTile.adaptive(
isThreeLine: true,
title: const Text('Custom Overlay'),
title: const Text('Custom overlay'),
subtitle: const Text('Replaces Twitch\'s default overlay with a mobile-friendly version.'),
value: settingsStore.showOverlay,
onChanged: settingsStore.showVideo ? (newValue) => settingsStore.showOverlay = newValue : null,
@ -37,14 +37,14 @@ class VideoSettings extends StatelessWidget {
if (Platform.isIOS)
SwitchListTile.adaptive(
isThreeLine: true,
title: const Text('Picture-in-Picture Button (experimental)'),
subtitle: const Text('Adds a button to enter PiP mode on the bottom right of the overlay. MAY CAUSE freezes/crashes.'),
title: const Text('Picture-in-picture Button (experimental)'),
subtitle: const Text('Adds a button to enter PiP mode on the bottom right of the overlay (may cause freezes/crashes).'),
value: settingsStore.pictureInPicture,
onChanged: settingsStore.showVideo && settingsStore.showOverlay ? (newValue) => settingsStore.pictureInPicture = newValue : null,
),
SwitchListTile.adaptive(
isThreeLine: true,
title: const Text('Long-Press Player to Toggle Overlay'),
title: const Text('Long-press player to toggle overlay'),
subtitle: const Text('Allows switching between Twitch\'s default overlay and the custom overlay.'),
value: settingsStore.toggleableOverlay,
onChanged: settingsStore.showVideo ? (newValue) => settingsStore.toggleableOverlay = newValue : null,
@ -53,17 +53,24 @@ class VideoSettings extends StatelessWidget {
ListTile(
title: Row(
children: [
const Text('Custom Overlay Opacity'),
const Text('Custom overlay opacity'),
const Spacer(),
Text('${(settingsStore.overlayOpacity * 100).toStringAsFixed(0)}%'),
],
),
subtitle: Slider.adaptive(
value: settingsStore.overlayOpacity,
min: 0.0,
max: 1.0,
divisions: 10,
onChanged: (newValue) => settingsStore.overlayOpacity = newValue,
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Slider.adaptive(
value: settingsStore.overlayOpacity,
min: 0.0,
max: 1.0,
divisions: 10,
onChanged: (newValue) => settingsStore.overlayOpacity = newValue,
),
const Text('Adjusts the opacity (transparency) of the stream overlay when active.'),
const SizedBox(height: 15),
],
),
),
],

View file

@ -25,13 +25,13 @@ class Settings extends StatelessWidget {
actions: [
if (Platform.isAndroid)
IconButton(
tooltip: 'Support the App',
tooltip: 'Support the app',
onPressed: () => launchUrl(Uri.parse('https://www.buymeacoffee.com/tommychow'),
mode: settingsStore.launchUrlExternal ? LaunchMode.externalApplication : LaunchMode.inAppWebView),
icon: const FaIcon(FontAwesomeIcons.circleDollarToSlot),
),
IconButton(
tooltip: 'View Source on GitHub',
tooltip: 'View source on GitHub',
onPressed: () => launchUrl(Uri.parse('https://github.com/tommyxchow/frosty'),
mode: settingsStore.launchUrlExternal ? LaunchMode.externalApplication : LaunchMode.inAppWebView),
icon: const FaIcon(FontAwesomeIcons.github),

View file

@ -15,6 +15,7 @@ abstract class _SettingsStoreBase with Store {
// * General Settings
static const defaultThemeType = ThemeType.system;
static const defaultShowThumbnails = true;
static const defaultLargeStreamCard = false;
static const defaultShowThumbnailUptime = false;
static const defaultLaunchUrlExternal = false;
@ -26,6 +27,10 @@ abstract class _SettingsStoreBase with Store {
@observable
var showThumbnails = defaultShowThumbnails;
@JsonKey(defaultValue: defaultLargeStreamCard)
@observable
var largeStreamCard = defaultLargeStreamCard;
@JsonKey(defaultValue: defaultShowThumbnailUptime)
@observable
var showThumbnailUptime = defaultShowThumbnailUptime;
@ -37,8 +42,9 @@ abstract class _SettingsStoreBase with Store {
@action
void resetGeneralSettings() {
themeType = defaultThemeType;
showThumbnailUptime = defaultShowThumbnailUptime;
showThumbnails = defaultShowThumbnails;
largeStreamCard = defaultLargeStreamCard;
showThumbnailUptime = defaultShowThumbnailUptime;
launchUrlExternal = defaultLaunchUrlExternal;
}

View file

@ -12,6 +12,7 @@ SettingsStore _$SettingsStoreFromJson(Map<String, dynamic> json) =>
unknownValue: ThemeType.system) ??
ThemeType.system
..showThumbnails = json['showThumbnails'] as bool? ?? true
..largeStreamCard = json['largeStreamCard'] as bool? ?? false
..showThumbnailUptime = json['showThumbnailUptime'] as bool? ?? false
..launchUrlExternal = json['launchUrlExternal'] as bool? ?? false
..showVideo = json['showVideo'] as bool? ?? true
@ -49,6 +50,7 @@ Map<String, dynamic> _$SettingsStoreToJson(SettingsStore instance) =>
<String, dynamic>{
'themeType': _$ThemeTypeEnumMap[instance.themeType],
'showThumbnails': instance.showThumbnails,
'largeStreamCard': instance.largeStreamCard,
'showThumbnailUptime': instance.showThumbnailUptime,
'launchUrlExternal': instance.launchUrlExternal,
'showVideo': instance.showVideo,
@ -130,6 +132,22 @@ mixin _$SettingsStore on _SettingsStoreBase, Store {
});
}
late final _$largeStreamCardAtom =
Atom(name: '_SettingsStoreBase.largeStreamCard', context: context);
@override
bool get largeStreamCard {
_$largeStreamCardAtom.reportRead();
return super.largeStreamCard;
}
@override
set largeStreamCard(bool value) {
_$largeStreamCardAtom.reportWrite(value, super.largeStreamCard, () {
super.largeStreamCard = value;
});
}
late final _$showThumbnailUptimeAtom =
Atom(name: '_SettingsStoreBase.showThumbnailUptime', context: context);
@ -641,6 +659,7 @@ mixin _$SettingsStore on _SettingsStoreBase, Store {
return '''
themeType: ${themeType},
showThumbnails: ${showThumbnails},
largeStreamCard: ${largeStreamCard},
showThumbnailUptime: ${showThumbnailUptime},
launchUrlExternal: ${launchUrlExternal},
showVideo: ${showVideo},

View file

@ -0,0 +1,34 @@
import 'package:flutter/material.dart';
/// A simple widget that displays an alert message in the center.
class AlertMessage extends StatelessWidget {
final String message;
final IconData icon;
const AlertMessage({
Key? key,
required this.message,
final this.icon = Icons.info,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
icon,
color: Colors.grey,
),
const SizedBox(width: 5.0),
Text(
message,
style: const TextStyle(
fontWeight: FontWeight.w500,
color: Colors.grey,
),
),
],
);
}
}

View file

@ -0,0 +1,66 @@
import 'package:flutter/material.dart';
/// A widget than animates its scale when tapped/held on.
class AnimateScale extends StatefulWidget {
final void Function()? onTap;
final void Function()? onLongPress;
final Widget child;
const AnimateScale({
Key? key,
this.onTap,
this.onLongPress,
required this.child,
}) : super(key: key);
@override
State<AnimateScale> createState() => _AnimateScaleState();
}
class _AnimateScaleState extends State<AnimateScale> with SingleTickerProviderStateMixin {
late final _animationController = AnimationController(
vsync: this,
upperBound: 0.05,
);
@override
Widget build(BuildContext context) {
const duration = Duration(milliseconds: 200);
const downCurve = Curves.easeOutCubic;
const upCurve = Curves.easeInCubic;
return InkWell(
onTap: widget.onTap,
onTapDown: (_) => _animationController.animateTo(
_animationController.upperBound,
curve: downCurve,
duration: duration,
),
onLongPress: widget.onLongPress,
onTapUp: (_) => _animationController.animateTo(
_animationController.lowerBound,
curve: upCurve,
duration: duration,
),
onTapCancel: () => _animationController.animateTo(
_animationController.lowerBound,
curve: upCurve,
duration: duration,
),
child: AnimatedBuilder(
animation: _animationController,
child: widget.child,
builder: (context, child) => Transform.scale(
scale: 1 - _animationController.value,
child: child,
),
),
);
}
@override
void dispose() {
_animationController.dispose();
super.dispose();
}
}

View file

@ -1,6 +1,8 @@
import 'package:flutter/material.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:frosty/core/auth/auth_store.dart';
import 'package:frosty/widgets/button.dart';
import 'package:frosty/widgets/dialog.dart';
class BlockButton extends StatelessWidget {
final AuthStore authStore;
@ -24,17 +26,12 @@ class BlockButton extends StatelessWidget {
}) {
return showDialog(
context: context,
builder: (context) => AlertDialog(
title: isBlocked ? const Text('Unblock') : const Text('Block'),
builder: (context) => FrostyDialog(
title: isBlocked ? 'Unblock' : 'Block',
content: Text(
'Are you sure you want to ${isBlocked ? 'unblock $targetUser?' : 'block $targetUser? This will remove them from channel lists, search results, and chat messages.'}'),
actions: [
TextButton(
onPressed: Navigator.of(context).pop,
style: TextButton.styleFrom(primary: Colors.red),
child: const Text('Cancel'),
),
ElevatedButton(
Button(
onPressed: () {
if (isBlocked) {
authStore.user.unblock(targetId: targetUserId, headers: authStore.headersTwitch);
@ -49,6 +46,12 @@ class BlockButton extends StatelessWidget {
},
child: const Text('Yes'),
),
Button(
fill: true,
onPressed: Navigator.of(context).pop,
color: Colors.red.shade700,
child: const Text('Cancel'),
),
],
),
);
@ -60,16 +63,18 @@ class BlockButton extends StatelessWidget {
builder: (context) {
final isBlocked = authStore.user.blockedUsers.where((blockedUser) => blockedUser.userId == targetUserId).isNotEmpty;
return OutlinedButton.icon(
return Button(
padding: const EdgeInsets.symmetric(horizontal: 15.0, vertical: 10.0),
icon: const Icon(Icons.block),
label: isBlocked ? Text(simple ? 'Unblock' : 'Unblock $targetUser') : Text(simple ? 'Block' : 'Block $targetUser'),
color: Colors.red.shade700,
fill: true,
onPressed: () => _showDialog(
context,
isBlocked: isBlocked,
targetUser: targetUser,
targetUserId: targetUserId,
),
style: OutlinedButton.styleFrom(primary: Colors.red),
child: isBlocked ? Text(simple ? 'Unblock' : 'Unblock $targetUser') : Text(simple ? 'Block' : 'Block $targetUser'),
);
},
);

View file

@ -1,6 +1,9 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:frosty/core/auth/auth_store.dart';
import 'package:frosty/widgets/block_button.dart';
import 'package:frosty/widgets/modal.dart';
import 'package:frosty/widgets/report_button.dart';
class BlockReportModal extends StatelessWidget {
@ -19,17 +22,10 @@ class BlockReportModal extends StatelessWidget {
@override
Widget build(BuildContext context) {
return SafeArea(
return FrostyModal(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(
width: 50.0,
child: Divider(
height: 25.0,
thickness: 3.0,
),
),
if (authStore.isLoggedIn)
Container(
padding: const EdgeInsets.symmetric(horizontal: 10.0),
@ -41,6 +37,7 @@ class BlockReportModal extends StatelessWidget {
simple: false,
),
),
const SizedBox(height: 10.0),
Container(
padding: const EdgeInsets.symmetric(horizontal: 10.0),
width: double.infinity,
@ -49,6 +46,7 @@ class BlockReportModal extends StatelessWidget {
displayName: name,
),
),
if (Platform.isAndroid) const SizedBox(height: 20.0),
],
),
);

121
lib/widgets/button.dart Normal file
View file

@ -0,0 +1,121 @@
import 'dart:io';
import 'package:flutter/material.dart';
/// A custom button that scales down when tapped/held on (sorta like a real button).
class Button extends StatefulWidget {
final Color? color;
final bool fill;
final EdgeInsets padding;
final double? fontSize;
final Widget? icon;
final Function()? onPressed;
final Widget child;
const Button({
Key? key,
this.color,
this.fill = false,
this.padding = const EdgeInsets.symmetric(vertical: 15.0, horizontal: 10.0),
this.fontSize,
this.icon,
required this.onPressed,
required this.child,
}) : super(key: key);
@override
State<Button> createState() => _ButtonState();
}
class _ButtonState extends State<Button> with SingleTickerProviderStateMixin {
late final _animationController = AnimationController(
vsync: this,
upperBound: 0.05,
);
@override
Widget build(BuildContext context) {
final buttonStyle = ElevatedButton.styleFrom(
primary: widget.color == null || widget.fill ? widget.color : Colors.transparent,
onPrimary: widget.fill ? null : widget.color,
padding: widget.padding,
splashFactory: Platform.isIOS ? NoSplash.splashFactory : null,
textStyle: TextStyle(fontWeight: FontWeight.bold, fontSize: widget.fontSize),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(30.0)),
elevation: widget.color == null ? 10.0 : 0.0,
).copyWith(
elevation: (widget.color == null || widget.fill) && widget.onPressed != null
? MaterialStateProperty.resolveWith(
(states) {
if (states.contains(MaterialState.pressed)) {
return 0.0;
} else {
return 5.0;
}
},
)
: MaterialStateProperty.all(0.0),
);
final button = widget.icon == null
? ElevatedButton(
style: buttonStyle,
onPressed: widget.onPressed,
child: widget.child,
)
: ElevatedButton.icon(
style: buttonStyle,
onPressed: widget.onPressed,
icon: widget.icon!,
label: widget.child,
);
const duration = Duration(milliseconds: 200);
return AnimatedBuilder(
animation: _animationController,
child: widget.onPressed == null
? button
: Listener(
onPointerDown: (_) {
if (mounted) {
_animationController.animateTo(
_animationController.upperBound,
curve: Curves.easeOutCubic,
duration: duration,
);
}
},
onPointerUp: (_) {
if (mounted) {
_animationController.animateTo(
_animationController.lowerBound,
curve: Curves.easeInCubic,
duration: duration,
);
}
},
onPointerCancel: (_) {
if (mounted) {
_animationController.animateTo(
_animationController.lowerBound,
curve: Curves.easeInCubic,
duration: duration,
);
}
},
child: button,
),
builder: (context, child) => Transform.scale(
scale: 1 - _animationController.value,
child: child,
),
);
}
@override
void dispose() {
_animationController.dispose();
super.dispose();
}
}

45
lib/widgets/dialog.dart Normal file
View file

@ -0,0 +1,45 @@
import 'package:flutter/material.dart';
/// A custom dialog that makes the title bold, puts the actions in a column, and adds a subtle border.
class FrostyDialog extends StatelessWidget {
final String title;
final Widget content;
final List<Widget>? actions;
const FrostyDialog({
Key? key,
this.actions,
required this.title,
required this.content,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return AlertDialog(
titlePadding: const EdgeInsets.fromLTRB(30.0, 30.0, 30.0, 20.0),
contentPadding: const EdgeInsets.fromLTRB(30.0, 0.0, 30.0, 30.0),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20.0),
),
title: Text(
title,
style: const TextStyle(fontWeight: FontWeight.bold),
),
content: SingleChildScrollView(
child: Column(children: [
content,
if (actions != null) ...[
const SizedBox(height: 20.0),
...?actions?.map(
(action) => Container(
width: double.infinity,
padding: const EdgeInsets.only(top: 10.0),
child: action,
),
),
],
]),
),
);
}
}

View file

@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
class LoadingIndicator extends StatelessWidget {
final Widget? subtitle;
final String? subtitle;
final double spacing;
const LoadingIndicator({Key? key, this.subtitle, this.spacing = 10.0}) : super(key: key);
@ -11,15 +11,19 @@ class LoadingIndicator extends StatelessWidget {
if (subtitle == null) {
return const Center(child: CircularProgressIndicator.adaptive());
}
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const CircularProgressIndicator.adaptive(),
SizedBox(height: spacing),
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const CircularProgressIndicator.adaptive(),
SizedBox(height: spacing),
Text(
subtitle!,
],
),
style: const TextStyle(
fontWeight: FontWeight.w500,
color: Colors.grey,
),
),
],
);
}
}

38
lib/widgets/modal.dart Normal file
View file

@ -0,0 +1,38 @@
import 'package:flutter/material.dart';
class FrostyModal extends StatelessWidget {
final Widget child;
const FrostyModal({Key? key, required this.child}) : super(key: key);
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.symmetric(horizontal: 15.0),
decoration: BoxDecoration(
color: Theme.of(context).dialogBackgroundColor,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(20.0),
topRight: Radius.circular(20.0),
),
),
child: SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const FractionallySizedBox(
widthFactor: 0.25,
child: SizedBox(
child: Divider(
height: 25.0,
thickness: 3.0,
),
),
),
Material(child: child),
],
),
),
);
}
}

View file

@ -7,31 +7,34 @@ import 'package:provider/provider.dart';
class ProfilePicture extends StatelessWidget {
final String userLogin;
final double? radius;
final double radius;
const ProfilePicture({
Key? key,
required this.userLogin,
this.radius,
this.radius = 20,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return FutureBuilder(
future: context.read<TwitchApi>().getUser(userLogin: userLogin, headers: context.read<AuthStore>().headersTwitch),
builder: (context, AsyncSnapshot<UserTwitch> snapshot) {
return AnimatedOpacity(
duration: const Duration(milliseconds: 500),
opacity: snapshot.connectionState == ConnectionState.done && snapshot.hasData ? 1.0 : 0.0,
child: CircleAvatar(
radius: radius,
backgroundColor: Colors.transparent,
foregroundImage: snapshot.connectionState == ConnectionState.done && snapshot.hasData
? CachedNetworkImageProvider(snapshot.data!.profileImageUrl)
: null,
),
);
},
final diameter = radius * 2;
return ClipOval(
child: FutureBuilder(
future: context.read<TwitchApi>().getUser(userLogin: userLogin, headers: context.read<AuthStore>().headersTwitch),
builder: (context, AsyncSnapshot<UserTwitch> snapshot) {
return snapshot.hasData
? CachedNetworkImage(
width: diameter,
height: diameter,
imageUrl: snapshot.data!.profileImageUrl,
)
: SizedBox(
width: diameter,
height: diameter,
);
},
),
);
}
}

View file

@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:frosty/widgets/button.dart';
import 'package:webview_flutter/webview_flutter.dart';
class ReportButton extends StatelessWidget {
@ -13,7 +14,7 @@ class ReportButton extends StatelessWidget {
@override
Widget build(BuildContext context) {
return OutlinedButton.icon(
return Button(
icon: const Icon(Icons.report),
onPressed: () => Navigator.push(
context,
@ -31,8 +32,10 @@ class ReportButton extends StatelessWidget {
},
),
),
label: Text('Report $displayName'),
style: OutlinedButton.styleFrom(primary: Colors.red),
padding: const EdgeInsets.symmetric(horizontal: 15.0, vertical: 10.0),
color: Colors.red.shade700,
fill: true,
child: Text('Report $displayName'),
);
}
}

View file

@ -1,5 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:frosty/widgets/button.dart';
class ScrollToTopButton extends StatelessWidget {
final ScrollController scrollController;
@ -8,21 +8,20 @@ class ScrollToTopButton extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Tooltip(
message: 'Scroll to Top',
preferBelow: false,
child: FloatingActionButton(
onPressed: () {
HapticFeedback.lightImpact();
scrollController.animateTo(
return Padding(
padding: const EdgeInsets.only(bottom: 5.0),
child: Tooltip(
message: 'Scroll to top',
preferBelow: false,
child: Button(
onPressed: () => scrollController.animateTo(
0.0,
curve: Curves.fastOutSlowIn,
curve: Curves.easeOutCubic,
duration: const Duration(milliseconds: 500),
);
},
mini: true,
heroTag: null,
child: const Icon(Icons.keyboard_arrow_up),
),
padding: const EdgeInsets.symmetric(horizontal: 25.0, vertical: 10.0),
child: const Icon(Icons.keyboard_arrow_up),
),
),
);
}

View file

@ -4,26 +4,37 @@ class SectionHeader extends StatelessWidget {
final String text;
final EdgeInsets padding;
final double? fontSize;
final bool showDivider;
const SectionHeader(
this.text, {
Key? key,
this.padding = const EdgeInsets.fromLTRB(10.0, 20.0, 10.0, 5.0),
this.fontSize,
this.showDivider = false,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Padding(
padding: padding,
child: Text(
text.toUpperCase(),
style: TextStyle(
color: DefaultTextStyle.of(context).style.color?.withOpacity(0.8),
fontSize: fontSize,
fontWeight: FontWeight.w600,
letterSpacing: 0.5,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
text.toUpperCase(),
style: TextStyle(
fontSize: fontSize,
fontWeight: FontWeight.w600,
letterSpacing: 0.8,
),
),
if (showDivider)
const Divider(
height: 10.0,
thickness: 1.0,
),
],
),
);
}

View file

@ -355,7 +355,7 @@ packages:
name: flutter_svg
url: "https://pub.dartlang.org"
source: hosted
version: "1.1.0"
version: "1.1.1"
flutter_test:
dependency: "direct dev"
description: flutter
@ -512,7 +512,7 @@ packages:
name: mobx
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.7+2"
version: "2.0.7+4"
mobx_codegen:
dependency: "direct dev"
description:
@ -617,7 +617,7 @@ packages:
name: path_provider_android
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.15"
version: "2.0.16"
path_provider_ios:
dependency: transitive
description:
@ -736,14 +736,14 @@ packages:
name: sentry
url: "https://pub.dartlang.org"
source: hosted
version: "6.6.0"
version: "6.6.1"
sentry_flutter:
dependency: "direct main"
description:
name: sentry_flutter
url: "https://pub.dartlang.org"
source: hosted
version: "6.6.0"
version: "6.6.1"
shared_preferences:
dependency: "direct main"
description:

View file

@ -42,7 +42,7 @@ dependencies:
cached_network_image: ^3.1.0
webview_flutter: ^3.0.0
provider: ^6.0.1
mobx: 2.0.7+2
mobx: ^2.0.7+4
flutter_mobx: ^2.0.0
shared_preferences: ^2.0.8
sentry_flutter: ^6.2.2
@ -69,7 +69,7 @@ dev_dependencies:
json_serializable: ^6.0.0
mobx_codegen: ^2.0.6
flutter_native_splash: ^2.0.5
flutter_launcher_icons: "^0.9.2"
flutter_launcher_icons: ^0.9.3
# For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec