diff --git a/lib/main.dart b/lib/main.dart index 53b0deef..a031fc26 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -11,6 +11,7 @@ import 'package:frosty/apis/seventv_api.dart'; import 'package:frosty/apis/twitch_api.dart'; import 'package:frosty/constants.dart'; import 'package:frosty/screens/home/home.dart'; +import 'package:frosty/screens/onboarding/onboarding_intro.dart'; import 'package:frosty/screens/settings/stores/auth_store.dart'; import 'package:frosty/screens/settings/stores/settings_store.dart'; import 'package:http/http.dart'; @@ -71,7 +72,7 @@ void main() async { Provider(create: (_) => ffzApiService), Provider(create: (_) => sevenTVApiService), ], - child: const MyApp(), + child: MyApp(firstRun: prefs.getBool('first_run') ?? false), ); }, ), @@ -202,7 +203,12 @@ final oledTheme = ThemeData( ); class MyApp extends StatelessWidget { - const MyApp({Key? key}) : super(key: key); + final bool firstRun; + + const MyApp({ + Key? key, + this.firstRun = false, + }) : super(key: key); @override Widget build(BuildContext context) { @@ -223,7 +229,7 @@ class MyApp extends StatelessWidget { : settingsStore.themeType == ThemeType.light ? ThemeMode.light : ThemeMode.dark, - home: const Home(), + home: firstRun ? const OnboardingIntro() : const Home(), navigatorKey: navigatorKey, ); }, diff --git a/lib/screens/home/home.dart b/lib/screens/home/home.dart index 8b2c6617..6719e7cc 100644 --- a/lib/screens/home/home.dart +++ b/lib/screens/home/home.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_mobx/flutter_mobx.dart'; import 'package:frosty/screens/home/home_store.dart'; import 'package:frosty/screens/home/search/search.dart'; @@ -74,6 +75,8 @@ class _HomeState extends State { @override Widget build(BuildContext context) { + SystemChrome.setPreferredOrientations([]); + return GestureDetector( onTap: FocusScope.of(context).unfocus, child: Scaffold( diff --git a/lib/screens/onboarding/onboarding_intro.dart b/lib/screens/onboarding/onboarding_intro.dart new file mode 100644 index 00000000..2b096790 --- /dev/null +++ b/lib/screens/onboarding/onboarding_intro.dart @@ -0,0 +1,24 @@ +import 'package:flutter/material.dart'; +import 'package:frosty/screens/onboarding/onboarding_login.dart'; +import 'package:frosty/screens/onboarding/onboarding_scaffold.dart'; +import 'package:package_info_plus/package_info_plus.dart'; + +class OnboardingIntro extends StatelessWidget { + const OnboardingIntro({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: PackageInfo.fromPlatform(), + builder: (context, snapshot) { + return OnboardingScaffold( + header: 'Frosty', + subtitle: 'A mobile Twitch client with 7TV, BetterTTV (BTTV), and FrankerFaceZ (FFZ) support.', + showLogo: true, + disclaimer: snapshot.hasData ? 'v${snapshot.data?.version} (${snapshot.data?.buildNumber})' : null, + route: const OnboardingLogin(), + ); + }, + ); + } +} diff --git a/lib/screens/onboarding/onboarding_login.dart b/lib/screens/onboarding/onboarding_login.dart new file mode 100644 index 00000000..4feda941 --- /dev/null +++ b/lib/screens/onboarding/onboarding_login.dart @@ -0,0 +1,38 @@ +import 'package:flutter/material.dart'; +import 'package:frosty/screens/onboarding/onboarding_scaffold.dart'; +import 'package:frosty/screens/onboarding/onboarding_setup.dart'; +import 'package:frosty/screens/settings/stores/auth_store.dart'; +import 'package:frosty/widgets/app_bar.dart'; +import 'package:provider/provider.dart'; +import 'package:simple_icons/simple_icons.dart'; +import 'package:webview_flutter/webview_flutter.dart'; + +class OnboardingLogin extends StatelessWidget { + const OnboardingLogin({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + final authStore = context.read(); + + return OnboardingScaffold( + header: 'Log in', + subtitle: 'Frosty needs your permission in order to enable the ability to chat, view followed streams, and more.', + disclaimer: + 'Frosty only asks for the necessary permissions through the official Twitch API. You\'ll be able to review them before authorizing.', + buttonText: 'Connect with Twitch', + buttonIcon: const Icon(SimpleIcons.twitch), + skipRoute: const OnboardingSetup(), + route: Scaffold( + appBar: const FrostyAppBar( + title: Text('Connect with Twitch'), + ), + body: WebView( + initialUrl: authStore.loginUri.toString(), + navigationDelegate: (navigation) => + authStore.handleNavigation(navigation: navigation, routeAfter: const OnboardingSetup()), + javascriptMode: JavascriptMode.unrestricted, + ), + ), + ); + } +} diff --git a/lib/screens/onboarding/onboarding_scaffold.dart b/lib/screens/onboarding/onboarding_scaffold.dart new file mode 100644 index 00000000..9b966b81 --- /dev/null +++ b/lib/screens/onboarding/onboarding_scaffold.dart @@ -0,0 +1,142 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:frosty/widgets/button.dart'; + +class OnboardingScaffold extends StatelessWidget { + final String header; + final String? subtitle; + final String? disclaimer; + final Widget? content; + final String? buttonText; + final Widget? buttonIcon; + final Widget route; + final Widget? skipRoute; + final bool showLogo; + final bool isLast; + + const OnboardingScaffold({ + Key? key, + required this.header, + this.subtitle, + this.disclaimer, + this.content, + this.buttonText, + this.buttonIcon, + required this.route, + this.skipRoute, + this.showLogo = false, + this.isLast = false, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + SystemChrome.setPreferredOrientations([ + DeviceOrientation.portraitUp, + DeviceOrientation.portraitDown, + ]); + + return Scaffold( + appBar: AppBar(), + body: SafeArea( + child: SizedBox( + width: double.infinity, + child: Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(20, 50, 20, 20), + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (showLogo) ...[ + SvgPicture.asset( + 'assets/icons/logo.svg', + height: 80, + ), + const SizedBox(width: 10), + ], + Text( + header, + style: const TextStyle( + fontSize: 48, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + if (subtitle != null) ...[ + const SizedBox(height: 20), + Opacity( + opacity: 0.8, + child: Text( + subtitle!, + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 16), + ), + ), + ], + ], + ), + ), + Expanded( + child: Padding( + padding: EdgeInsets.symmetric(vertical: content == null ? 20.0 : 0.0), + child: content ?? const SizedBox(), + ), + ), + if (disclaimer != null) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 5.0), + child: Opacity( + opacity: 0.5, + child: Text( + disclaimer!, + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 12), + ), + ), + ), + Container( + padding: const EdgeInsets.all(10), + width: double.infinity, + child: Button( + onPressed: () => isLast + ? Navigator.pushAndRemoveUntil( + context, + MaterialPageRoute(builder: (context) => route), + (_) => false, + ) + : Navigator.push( + context, + MaterialPageRoute( + builder: (context) => route, + ), + ), + icon: buttonIcon, + child: Text(buttonText ?? 'Next'), + ), + ), + if (skipRoute != null) + Container( + padding: const EdgeInsets.fromLTRB(10, 0, 10, 10), + width: double.infinity, + child: Button( + color: Colors.grey, + onPressed: () => Navigator.push( + context, + MaterialPageRoute( + builder: (context) => skipRoute!, + ), + ), + child: const Text('Skip'), + ), + ) + ], + ), + ), + ), + ); + } +} diff --git a/lib/screens/onboarding/onboarding_setup.dart b/lib/screens/onboarding/onboarding_setup.dart new file mode 100644 index 00000000..f9588a98 --- /dev/null +++ b/lib/screens/onboarding/onboarding_setup.dart @@ -0,0 +1,52 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_mobx/flutter_mobx.dart'; +import 'package:frosty/constants.dart'; +import 'package:frosty/screens/onboarding/onboarding_scaffold.dart'; +import 'package:frosty/screens/onboarding/onboarding_welcome.dart'; +import 'package:frosty/screens/settings/stores/settings_store.dart'; +import 'package:frosty/screens/settings/widgets/settings_list_select.dart'; +import 'package:frosty/screens/settings/widgets/settings_list_switch.dart'; +import 'package:provider/provider.dart'; +import 'package:sentry_flutter/sentry_flutter.dart'; + +class OnboardingSetup extends StatelessWidget { + const OnboardingSetup({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + final settingsStore = context.read(); + + return OnboardingScaffold( + header: 'Setup', + subtitle: 'Let\'s tweak some settings before you get started. You can always change these later.', + content: Observer( + builder: (context) { + return ListView( + children: [ + SettingsListSelect( + title: 'Theme', + selectedOption: themeNames[settingsStore.themeType.index], + options: themeNames, + onChanged: (newTheme) => settingsStore.themeType = ThemeType.values[themeNames.indexOf(newTheme)], + ), + SettingsListSwitch( + title: 'Send anonymous crash logs', + subtitle: const Text('Help improve Frosty by sending anonymous crash logs through Sentry.io.'), + value: settingsStore.sendCrashLogs, + onChanged: (newValue) { + if (newValue == true) { + SentryFlutter.init((options) => options.tracesSampleRate = sampleRate); + } else { + Sentry.close(); + } + settingsStore.sendCrashLogs = newValue; + }, + ), + ], + ); + }, + ), + route: const OnboardingWelcome(), + ); + } +} diff --git a/lib/screens/onboarding/onboarding_welcome.dart b/lib/screens/onboarding/onboarding_welcome.dart new file mode 100644 index 00000000..aeb2f324 --- /dev/null +++ b/lib/screens/onboarding/onboarding_welcome.dart @@ -0,0 +1,46 @@ +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:frosty/screens/home/home.dart'; +import 'package:frosty/screens/onboarding/onboarding_scaffold.dart'; + +class OnboardingWelcome extends StatelessWidget { + const OnboardingWelcome({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + const text = [ + 'Check out the GitHub repo at the top-right of the settings page to explore Frosty\'s source code, report bugs, make feature requests, and more.', + 'Links to the full changelog and FAQ are in the settings page under "Other".', + 'Don\'t forget to leave a rating and/or review on the app store!' + ]; + + return OnboardingScaffold( + header: 'Welcome!', + subtitle: 'Just a few more things...', + content: Opacity( + opacity: 0.8, + child: ListView( + padding: const EdgeInsets.symmetric(horizontal: 20.0), + children: text + .mapIndexed((index, sentence) => Padding( + padding: const EdgeInsets.symmetric(vertical: 10.0), + child: ListTile( + leading: Text( + '${index + 1}.', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + title: Text(sentence), + ), + )) + .toList(), + ), + ), + buttonText: 'Let\'s go!', + isLast: true, + route: const Home(), + ); + } +} diff --git a/lib/screens/settings/account/widgets/profile_card.dart b/lib/screens/settings/account/widgets/profile_card.dart index 03e48a44..566047d2 100644 --- a/lib/screens/settings/account/widgets/profile_card.dart +++ b/lib/screens/settings/account/widgets/profile_card.dart @@ -35,7 +35,7 @@ class ProfileCard extends StatelessWidget { ), body: WebView( initialUrl: authStore.loginUri.toString(), - navigationDelegate: authStore.handleNavigation, + navigationDelegate: (navigation) => authStore.handleNavigation(navigation: navigation), javascriptMode: JavascriptMode.unrestricted, ), ); diff --git a/lib/screens/settings/chat_settings.dart b/lib/screens/settings/chat_settings.dart index 1ef94920..e40eeec8 100644 --- a/lib/screens/settings/chat_settings.dart +++ b/lib/screens/settings/chat_settings.dart @@ -24,10 +24,6 @@ class _ChatSettingsState extends State { @override Widget build(BuildContext context) { - const landscapeCutouts = ['None', 'Left', 'Right', 'Both']; - - const timestamps = ['Disabled', '12-hour', '24-hour']; - final settingsStore = widget.settingsStore; return Observer( @@ -150,9 +146,10 @@ class _ChatSettingsState extends State { ), SettingsListSelect( title: 'Message timestamps', - selectedOption: timestamps[settingsStore.timestampType.index], - options: timestamps, - onChanged: (newValue) => settingsStore.timestampType = TimestampType.values[timestamps.indexOf(newValue)], + selectedOption: timestampNames[settingsStore.timestampType.index], + options: timestampNames, + onChanged: (newValue) => + settingsStore.timestampType = TimestampType.values[timestampNames.indexOf(newValue)], ), const SectionHeader('Delay'), SettingsListSlider( @@ -209,10 +206,10 @@ class _ChatSettingsState extends State { SettingsListSelect( title: 'Fill notch side', subtitle: 'Overrides and fills the available space in devices with a display notch.', - selectedOption: landscapeCutouts[settingsStore.landscapeCutout.index], - options: landscapeCutouts, + selectedOption: landscapeCutoutNames[settingsStore.landscapeCutout.index], + options: landscapeCutoutNames, onChanged: (newValue) => - settingsStore.landscapeCutout = LandscapeCutoutType.values[landscapeCutouts.indexOf(newValue)], + settingsStore.landscapeCutout = LandscapeCutoutType.values[landscapeCutoutNames.indexOf(newValue)], ), SettingsListSlider( title: 'Chat width', diff --git a/lib/screens/settings/general_settings.dart b/lib/screens/settings/general_settings.dart index 5ec57d17..2c0e6c04 100644 --- a/lib/screens/settings/general_settings.dart +++ b/lib/screens/settings/general_settings.dart @@ -12,17 +12,15 @@ class GeneralSettings extends StatelessWidget { @override Widget build(BuildContext context) { - const themes = ['System', 'Light', 'Dark', 'Black']; - return Observer( builder: (context) => ListView( children: [ const SectionHeader('Display'), SettingsListSelect( title: 'Theme', - selectedOption: themes[settingsStore.themeType.index], - options: themes, - onChanged: (newTheme) => settingsStore.themeType = ThemeType.values[themes.indexOf(newTheme)], + selectedOption: themeNames[settingsStore.themeType.index], + options: themeNames, + onChanged: (newTheme) => settingsStore.themeType = ThemeType.values[themeNames.indexOf(newTheme)], ), const SectionHeader('Stream card'), SettingsListSwitch( diff --git a/lib/screens/settings/other_settings.dart b/lib/screens/settings/other_settings.dart index a86a74dc..d47fec26 100644 --- a/lib/screens/settings/other_settings.dart +++ b/lib/screens/settings/other_settings.dart @@ -85,14 +85,14 @@ class _OtherSettingsState extends State { ), FrostyListTile( leading: const Icon(Icons.launch_rounded), - title: 'FAQ', - onTap: () => launchUrl(Uri.parse('https://www.frostyapp.io/#faq'), + title: 'Changelog', + onTap: () => launchUrl(Uri.parse('https://github.com/tommyxchow/frosty/releases'), mode: widget.settingsStore.launchUrlExternal ? LaunchMode.externalApplication : LaunchMode.inAppWebView), ), FrostyListTile( leading: const Icon(Icons.launch_rounded), - title: 'Changelog', - onTap: () => launchUrl(Uri.parse('https://github.com/tommyxchow/frosty/releases'), + title: 'FAQ', + onTap: () => launchUrl(Uri.parse('https://www.frostyapp.io/#faq'), mode: widget.settingsStore.launchUrlExternal ? LaunchMode.externalApplication : LaunchMode.inAppWebView), ), FrostyListTile( diff --git a/lib/screens/settings/stores/auth_store.dart b/lib/screens/settings/stores/auth_store.dart index d270555a..7cc79f7b 100644 --- a/lib/screens/settings/stores/auth_store.dart +++ b/lib/screens/settings/stores/auth_store.dart @@ -66,7 +66,7 @@ abstract class AuthBase with Store { ); /// Navigation handler for the login webview. Fires on every navigation request (whenever the URL changes). - FutureOr handleNavigation(NavigationRequest navigation) { + FutureOr handleNavigation({required NavigationRequest navigation, Widget? routeAfter}) { // Check if the URL is the redirect URI. if (navigation.url.startsWith('https://twitch.tv/login')) { // Extract the token from the query parameters. @@ -81,9 +81,14 @@ abstract class AuthBase with Store { // When redirected to the redirect_uri, there will be another redirect to "https://www.twitch.tv/?no-reload=true". // Checking for this will ensure that the user has automatically logged in to Twitch on the WebView itself. if (navigation.url == 'https://www.twitch.tv/?no-reload=true') { - // Pop twice, once to dismiss the WebView and again to dismiss the Login dialog. - navigatorKey.currentState?.pop(); - navigatorKey.currentState?.pop(); + if (routeAfter != null) { + navigatorKey.currentState?.pop(); + navigatorKey.currentState?.push(MaterialPageRoute(builder: (context) => routeAfter)); + } else { + // Pop twice, once to dismiss the WebView and again to dismiss the Login dialog. + navigatorKey.currentState?.pop(); + navigatorKey.currentState?.pop(); + } } // Always allow navigation to the next URL. diff --git a/lib/screens/settings/stores/settings_store.dart b/lib/screens/settings/stores/settings_store.dart index f6b28dfb..1ac06015 100644 --- a/lib/screens/settings/stores/settings_store.dart +++ b/lib/screens/settings/stores/settings_store.dart @@ -295,6 +295,8 @@ abstract class _SettingsStoreBase with Store { } } +const themeNames = ['System', 'Light', 'Dark', 'Black']; + enum ThemeType { system, light, @@ -302,12 +304,16 @@ enum ThemeType { black, } +const timestampNames = ['Disabled', '12-hour', '24-hour']; + enum TimestampType { disabled, twelve, twentyFour, } +const landscapeCutoutNames = ['None', 'Left', 'Right', 'Both']; + enum LandscapeCutoutType { none, left,