Add onboarding (#229)
* Move settings titles * Add initial onboarding * Reorder other settings for consistency with onboarding * Update onboarding content
This commit is contained in:
parent
7881ce22b4
commit
2569e7274a
13 changed files with 344 additions and 27 deletions
|
|
@ -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,
|
||||
);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
24
lib/screens/onboarding/onboarding_intro.dart
Normal file
24
lib/screens/onboarding/onboarding_intro.dart
Normal 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(),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
38
lib/screens/onboarding/onboarding_login.dart
Normal file
38
lib/screens/onboarding/onboarding_login.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
142
lib/screens/onboarding/onboarding_scaffold.dart
Normal file
142
lib/screens/onboarding/onboarding_scaffold.dart
Normal 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'),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
52
lib/screens/onboarding/onboarding_setup.dart
Normal file
52
lib/screens/onboarding/onboarding_setup.dart
Normal 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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
46
lib/screens/onboarding/onboarding_welcome.dart
Normal file
46
lib/screens/onboarding/onboarding_welcome.dart
Normal 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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue