Merge pull request #150 from tommyxchow/90-redesign
Redesign and tweak various widgets and styles
This commit is contained in:
commit
c911767008
59 changed files with 2113 additions and 1214 deletions
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
''';
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
''';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
''';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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},
|
||||
|
|
|
|||
34
lib/widgets/alert_message.dart
Normal file
34
lib/widgets/alert_message.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
66
lib/widgets/animate_scale.dart
Normal file
66
lib/widgets/animate_scale.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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'),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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
121
lib/widgets/button.dart
Normal 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
45
lib/widgets/dialog.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
38
lib/widgets/modal.dart
Normal 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),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
10
pubspec.lock
10
pubspec.lock
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue