Add onboarding (#229)

* Move settings titles

* Add initial onboarding

* Reorder other settings for consistency with onboarding

* Update onboarding content
This commit is contained in:
Tommy Chow 2023-01-19 02:27:51 -05:00 • committed by GitHub
parent 7881ce22b4
commit 2569e7274a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 344 additions and 27 deletions

View file

@ -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<FFZApi>(create: (_) => ffzApiService),
Provider<SevenTVApi>(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,
);
},

View file

@ -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<Home> {
@override
Widget build(BuildContext context) {
SystemChrome.setPreferredOrientations([]);
return GestureDetector(
onTap: FocusScope.of(context).unfocus,
child: Scaffold(

View file

@ -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<PackageInfo>(
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(),
);
},
);
}
}

View file

@ -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<AuthStore>();
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,
),
),
);
}
}

View file

@ -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'),
),
)
],
),
),
),
);
}
}

View file

@ -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<SettingsStore>();
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(),
);
}
}

View file

@ -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(),
);
}
}

View file

@ -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,
),
);

View file

@ -24,10 +24,6 @@ class _ChatSettingsState extends State<ChatSettings> {
@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<ChatSettings> {
),
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<ChatSettings> {
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',

View file

@ -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(

View file

@ -85,14 +85,14 @@ class _OtherSettingsState extends State<OtherSettings> {
),
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(

View file

@ -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<NavigationDecision> handleNavigation(NavigationRequest navigation) {
FutureOr<NavigationDecision> 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.

View file

@ -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,