feat: New onboarding design
parent
a15b510c78
commit
87afa8ac3d
Binary file not shown.
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 36 KiB |
Binary file not shown.
Before Width: | Height: | Size: 211 KiB |
Binary file not shown.
After Width: | Height: | Size: 212 KiB |
@ -0,0 +1,191 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:adaptive_dialog/adaptive_dialog.dart';
|
||||
import 'package:flutter_gen/gen_l10n/l10n.dart';
|
||||
import 'package:flutter_web_auth/flutter_web_auth.dart';
|
||||
import 'package:future_loading_dialog/future_loading_dialog.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
import 'package:universal_html/html.dart' as html;
|
||||
import 'package:vrouter/vrouter.dart';
|
||||
|
||||
import 'package:fluffychat/config/app_config.dart';
|
||||
import 'package:fluffychat/pages/connect/connect_page_view.dart';
|
||||
import 'package:fluffychat/utils/localized_exception_extension.dart';
|
||||
import 'package:fluffychat/utils/platform_infos.dart';
|
||||
import 'package:fluffychat/widgets/matrix.dart';
|
||||
|
||||
class ConnectPage extends StatefulWidget {
|
||||
const ConnectPage({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<ConnectPage> createState() => ConnectPageController();
|
||||
}
|
||||
|
||||
class ConnectPageController extends State<ConnectPage> {
|
||||
final TextEditingController usernameController = TextEditingController();
|
||||
String? signupError;
|
||||
bool loading = false;
|
||||
|
||||
void pickAvatar() async {
|
||||
final source = !PlatformInfos.isMobile
|
||||
? ImageSource.gallery
|
||||
: await showModalActionSheet<ImageSource>(
|
||||
context: context,
|
||||
title: L10n.of(context)!.changeYourAvatar,
|
||||
actions: [
|
||||
SheetAction(
|
||||
key: ImageSource.camera,
|
||||
label: L10n.of(context)!.openCamera,
|
||||
isDefaultAction: true,
|
||||
icon: Icons.camera_alt_outlined,
|
||||
),
|
||||
SheetAction(
|
||||
key: ImageSource.gallery,
|
||||
label: L10n.of(context)!.openGallery,
|
||||
icon: Icons.photo_outlined,
|
||||
),
|
||||
],
|
||||
);
|
||||
if (source == null) return;
|
||||
final picked = await ImagePicker().pickImage(
|
||||
source: source,
|
||||
imageQuality: 50,
|
||||
maxWidth: 512,
|
||||
maxHeight: 512,
|
||||
);
|
||||
setState(() {
|
||||
Matrix.of(context).loginAvatar = picked;
|
||||
});
|
||||
}
|
||||
|
||||
void signUp() async {
|
||||
usernameController.text = usernameController.text.trim();
|
||||
final localpart =
|
||||
usernameController.text.toLowerCase().replaceAll(' ', '_');
|
||||
if (localpart.isEmpty) {
|
||||
setState(() {
|
||||
signupError = L10n.of(context)!.pleaseChooseAUsername;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
signupError = null;
|
||||
loading = true;
|
||||
});
|
||||
|
||||
try {
|
||||
try {
|
||||
await Matrix.of(context).getLoginClient().register(username: localpart);
|
||||
} on MatrixException catch (e) {
|
||||
if (!e.requireAdditionalAuthentication) rethrow;
|
||||
}
|
||||
setState(() {
|
||||
loading = false;
|
||||
});
|
||||
Matrix.of(context).loginUsername = usernameController.text;
|
||||
VRouter.of(context).to('signup');
|
||||
} catch (e, s) {
|
||||
Logs().d('Sign up failed', e, s);
|
||||
setState(() {
|
||||
signupError = e.toLocalizedString(context);
|
||||
loading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
bool _supportsFlow(String flowType) =>
|
||||
Matrix.of(context)
|
||||
.loginHomeserverSummary
|
||||
?.loginFlows
|
||||
.any((flow) => flow.type == flowType) ??
|
||||
false;
|
||||
|
||||
bool get supportsSso =>
|
||||
(PlatformInfos.isMobile ||
|
||||
PlatformInfos.isWeb ||
|
||||
PlatformInfos.isMacOS) &&
|
||||
_supportsFlow('m.login.sso');
|
||||
|
||||
bool get supportsLogin => _supportsFlow('m.login.password');
|
||||
|
||||
void login() => VRouter.of(context).to('login');
|
||||
|
||||
Map<String, dynamic>? _rawLoginTypes;
|
||||
|
||||
List<IdentityProvider>? get identityProviders {
|
||||
final loginTypes = _rawLoginTypes;
|
||||
if (loginTypes == null) return null;
|
||||
final rawProviders = loginTypes.tryGetList('flows')!.singleWhere((flow) =>
|
||||
flow['type'] == AuthenticationTypes.sso)['identity_providers'];
|
||||
final list = (rawProviders as List)
|
||||
.map((json) => IdentityProvider.fromJson(json))
|
||||
.toList();
|
||||
if (PlatformInfos.isCupertinoStyle) {
|
||||
list.sort((a, b) => a.brand == 'apple' ? -1 : 1);
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
void ssoLoginAction(String id) async {
|
||||
final redirectUrl = kIsWeb
|
||||
? html.window.origin! + '/web/auth.html'
|
||||
: AppConfig.appOpenUrlScheme.toLowerCase() + '://login';
|
||||
final url =
|
||||
'${Matrix.of(context).getLoginClient().homeserver?.toString()}/_matrix/client/r0/login/sso/redirect/${Uri.encodeComponent(id)}?redirectUrl=${Uri.encodeQueryComponent(redirectUrl)}';
|
||||
final urlScheme = Uri.parse(redirectUrl).scheme;
|
||||
final result = await FlutterWebAuth.authenticate(
|
||||
url: url,
|
||||
callbackUrlScheme: urlScheme,
|
||||
);
|
||||
final token = Uri.parse(result).queryParameters['loginToken'];
|
||||
if (token?.isEmpty ?? false) return;
|
||||
|
||||
await showFutureLoadingDialog(
|
||||
context: context,
|
||||
future: () => Matrix.of(context).getLoginClient().login(
|
||||
LoginType.mLoginToken,
|
||||
token: token,
|
||||
initialDeviceDisplayName: PlatformInfos.clientName,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (supportsSso) {
|
||||
Matrix.of(context)
|
||||
.getLoginClient()
|
||||
.request(
|
||||
RequestType.GET,
|
||||
'/client/r0/login',
|
||||
)
|
||||
.then((loginTypes) => setState(() {
|
||||
_rawLoginTypes = loginTypes;
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => ConnectPageView(this);
|
||||
}
|
||||
|
||||
class IdentityProvider {
|
||||
final String? id;
|
||||
final String? name;
|
||||
final String? icon;
|
||||
final String? brand;
|
||||
|
||||
IdentityProvider({this.id, this.name, this.icon, this.brand});
|
||||
|
||||
factory IdentityProvider.fromJson(Map<String, dynamic> json) =>
|
||||
IdentityProvider(
|
||||
id: json['id'],
|
||||
name: json['name'],
|
||||
icon: json['icon'],
|
||||
brand: json['brand'],
|
||||
);
|
||||
}
|
@ -0,0 +1,184 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:flutter_gen/gen_l10n/l10n.dart';
|
||||
|
||||
import 'package:fluffychat/config/themes.dart';
|
||||
import 'package:fluffychat/pages/connect/connect_page.dart';
|
||||
import 'package:fluffychat/widgets/layouts/login_scaffold.dart';
|
||||
import 'package:fluffychat/widgets/matrix.dart';
|
||||
import 'sso_button.dart';
|
||||
|
||||
class ConnectPageView extends StatelessWidget {
|
||||
final ConnectPageController controller;
|
||||
const ConnectPageView(this.controller, {Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final avatar = Matrix.of(context).loginAvatar;
|
||||
final identityProviders = controller.identityProviders;
|
||||
return LoginScaffold(
|
||||
appBar: AppBar(
|
||||
automaticallyImplyLeading: !controller.loading,
|
||||
backgroundColor: Colors.transparent,
|
||||
iconTheme: const IconThemeData(color: Colors.white),
|
||||
elevation: 0,
|
||||
centerTitle: true,
|
||||
title: Text(
|
||||
Matrix.of(context).getLoginClient().homeserver?.host ?? '',
|
||||
style: const TextStyle(color: Colors.white),
|
||||
),
|
||||
),
|
||||
body: ListView(
|
||||
children: [
|
||||
if (Matrix.of(context).loginRegistrationSupported ?? false) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Center(
|
||||
child: Material(
|
||||
borderRadius: BorderRadius.circular(64),
|
||||
elevation: 10,
|
||||
color: Colors.transparent,
|
||||
child: CircleAvatar(
|
||||
radius: 64,
|
||||
backgroundColor: Colors.white.withAlpha(200),
|
||||
child: Stack(
|
||||
children: [
|
||||
Center(
|
||||
child: avatar == null
|
||||
? const Icon(
|
||||
Icons.person_outlined,
|
||||
color: Colors.black,
|
||||
size: 64,
|
||||
)
|
||||
: FutureBuilder<Uint8List>(
|
||||
future: avatar.readAsBytes(),
|
||||
builder: (context, snapshot) {
|
||||
final bytes = snapshot.data;
|
||||
if (bytes == null) {
|
||||
return const CircularProgressIndicator
|
||||
.adaptive();
|
||||
}
|
||||
return Image.memory(bytes);
|
||||
},
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
child: FloatingActionButton(
|
||||
mini: true,
|
||||
onPressed: controller.pickAvatar,
|
||||
backgroundColor: Colors.white,
|
||||
foregroundColor: Colors.black,
|
||||
child: const Icon(Icons.camera_alt_outlined),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: TextField(
|
||||
controller: controller.usernameController,
|
||||
onSubmitted: (_) => controller.signUp(),
|
||||
decoration: FluffyThemes.loginTextFieldDecoration(
|
||||
prefixIcon: const Icon(Icons.account_box_outlined),
|
||||
hintText: L10n.of(context)!.chooseAUsername,
|
||||
errorText: controller.signupError,
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Hero(
|
||||
tag: 'loginButton',
|
||||
child: ElevatedButton(
|
||||
onPressed: controller.loading ? null : controller.signUp,
|
||||
style: ElevatedButton.styleFrom(
|
||||
primary: Colors.white.withAlpha(200),
|
||||
onPrimary: Colors.black,
|
||||
shadowColor: Colors.white,
|
||||
),
|
||||
child: controller.loading
|
||||
? const LinearProgressIndicator()
|
||||
: Text(L10n.of(context)!.signUp),
|
||||
),
|
||||
),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
const Expanded(child: Divider(color: Colors.white)),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Text(
|
||||
L10n.of(context)!.or,
|
||||
style: const TextStyle(color: Colors.white),
|
||||
),
|
||||
),
|
||||
const Expanded(child: Divider(color: Colors.white)),
|
||||
],
|
||||
),
|
||||
],
|
||||
if (controller.supportsSso)
|
||||
identityProviders == null
|
||||
? const SizedBox(
|
||||
height: 74,
|
||||
child: Center(
|
||||
child: CircularProgressIndicator.adaptive(
|
||||
backgroundColor: Colors.white,
|
||||
)),
|
||||
)
|
||||
: Center(
|
||||
child: identityProviders.length == 1
|
||||
? Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: ElevatedButton(
|
||||
onPressed: () => controller
|
||||
.ssoLoginAction(identityProviders.single.id!),
|
||||
style: ElevatedButton.styleFrom(
|
||||
primary: Colors.white.withAlpha(200),
|
||||
onPrimary: Colors.black,
|
||||
shadowColor: Colors.white,
|
||||
),
|
||||
child: Text(identityProviders.single.name ??
|
||||
identityProviders.single.brand ??
|
||||
L10n.of(context)!.loginWithOneClick),
|
||||
),
|
||||
)
|
||||
: Wrap(
|
||||
children: [
|
||||
for (final identityProvider in identityProviders)
|
||||
SsoButton(
|
||||
onPressed: () => controller
|
||||
.ssoLoginAction(identityProvider.id!),
|
||||
identityProvider: identityProvider,
|
||||
),
|
||||
].toList(),
|
||||
),
|
||||
),
|
||||
if (controller.supportsLogin)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Hero(
|
||||
tag: 'signinButton',
|
||||
child: ElevatedButton(
|
||||
onPressed: controller.loading ? () {} : controller.login,
|
||||
style: ElevatedButton.styleFrom(
|
||||
primary: Colors.white.withAlpha(200),
|
||||
onPrimary: Colors.black,
|
||||
shadowColor: Colors.white,
|
||||
),
|
||||
child: Text(L10n.of(context)!.login),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -0,0 +1,64 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter_gen/gen_l10n/l10n.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
|
||||
import 'package:fluffychat/pages/connect/connect_page.dart';
|
||||
import 'package:fluffychat/widgets/matrix.dart';
|
||||
|
||||
class SsoButton extends StatelessWidget {
|
||||
final IdentityProvider identityProvider;
|
||||
final void Function()? onPressed;
|
||||
const SsoButton({
|
||||
Key? key,
|
||||
required this.identityProvider,
|
||||
this.onPressed,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return InkWell(
|
||||
onTap: onPressed,
|
||||
borderRadius: BorderRadius.circular(7),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8.0),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Material(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(7),
|
||||
clipBehavior: Clip.hardEdge,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(2.0),
|
||||
child: identityProvider.icon == null
|
||||
? const Icon(Icons.web_outlined)
|
||||
: CachedNetworkImage(
|
||||
imageUrl: Uri.parse(identityProvider.icon!)
|
||||
.getDownloadLink(
|
||||
Matrix.of(context).getLoginClient())
|
||||
.toString(),
|
||||
width: 32,
|
||||
height: 32,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
identityProvider.name ??
|
||||
identityProvider.brand ??
|
||||
L10n.of(context)!.singlesignon,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -0,0 +1,45 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
class LoginScaffold extends StatelessWidget {
|
||||
final Widget body;
|
||||
final AppBar? appBar;
|
||||
|
||||
const LoginScaffold({
|
||||
Key? key,
|
||||
required this.body,
|
||||
this.appBar,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
SystemChrome.setSystemUIOverlayStyle(
|
||||
const SystemUiOverlayStyle(
|
||||
statusBarIconBrightness: Brightness.light,
|
||||
statusBarColor: Colors.transparent,
|
||||
systemNavigationBarContrastEnforced: false,
|
||||
systemNavigationBarColor: Colors.transparent,
|
||||
systemNavigationBarIconBrightness: Brightness.light,
|
||||
),
|
||||
);
|
||||
return Scaffold(
|
||||
appBar: appBar,
|
||||
extendBodyBehindAppBar: true,
|
||||
body: Container(
|
||||
decoration: const BoxDecoration(
|
||||
image: DecorationImage(
|
||||
fit: BoxFit.cover,
|
||||
image: AssetImage(
|
||||
'assets/login_wallpaper.png',
|
||||
),
|
||||
),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 480),
|
||||
child: body,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -1,48 +0,0 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:fluffychat/config/themes.dart';
|
||||
import 'package:fluffychat/widgets/matrix.dart';
|
||||
|
||||
class OnePageCard extends StatelessWidget {
|
||||
final Widget child;
|
||||
|
||||
/// This will cause the "isLogged()" check to be skipped and force a
|
||||
/// OnePageCard without login wallpaper. This can be used in situations where
|
||||
/// "Matrix.of(context) is not yet available, e.g. in the LockScreen widget.
|
||||
final bool forceBackgroundless;
|
||||
|
||||
const OnePageCard(
|
||||
{Key? key, required this.child, this.forceBackgroundless = false})
|
||||
: super(key: key);
|
||||
|
||||
static const int alpha = 12;
|
||||
static num breakpoint = FluffyThemes.columnWidth * 2;
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final horizontalPadding =
|
||||
max<double>((MediaQuery.of(context).size.width - 600) / 2, 24);
|
||||
return MediaQuery.of(context).size.width <= breakpoint ||
|
||||
forceBackgroundless ||
|
||||
Matrix.of(context).client.isLogged()
|
||||
? child
|
||||
: Container(
|
||||
decoration: const BoxDecoration(
|
||||
image: DecorationImage(
|
||||
image: AssetImage('assets/login_wallpaper.jpg'),
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(
|
||||
top: 16,
|
||||
left: horizontalPadding,
|
||||
right: horizontalPadding,
|
||||
bottom: max((MediaQuery.of(context).size.height - 600) / 2, 24),
|
||||
),
|
||||
child: SafeArea(child: Card(elevation: 16, child: child)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue