Skip to content

Commit 1ce5634

Browse files
committed
login: Add a basic login flow
There's plenty more to do to improve the UX of this, and I've filed several follow-up issues. Most critically, we'll need to store the resulting credentials on the device, so you don't have to repeat the login each time you start the app. But this is enough that it becomes possible to log in at an account of your choice, making the flow testable end to end. In particular this makes a useful bootstrapping step toward the task of storing data locally: with this, there's now some useful data we could store. Fixes: zulip#11
1 parent 42391e0 commit 1ce5634

File tree

3 files changed

+209
-5
lines changed

3 files changed

+209
-5
lines changed

lib/widgets/app.dart

Lines changed: 49 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import 'package:flutter/material.dart';
22

3-
import '../model/store.dart';
43
import 'compose_box.dart';
4+
import 'login.dart';
55
import 'message_list.dart';
66
import 'page.dart';
77
import 'store.dart';
@@ -25,10 +25,7 @@ class ZulipApp extends StatelessWidget {
2525
child: MaterialApp(
2626
title: 'Zulip',
2727
theme: theme,
28-
home: const PerAccountStoreWidget(
29-
// Just one account for now.
30-
accountId: LiveGlobalStore.fixtureAccountId,
31-
child: HomePage())));
28+
home: const ChooseAccountPage()));
3229
}
3330
}
3431

@@ -38,9 +35,56 @@ class ZulipApp extends StatelessWidget {
3835
// As computed by Anders: https://github.com/zulip/zulip-mobile/pull/4467
3936
const kZulipBrandColor = Color.fromRGBO(0x64, 0x92, 0xfe, 1);
4037

38+
class ChooseAccountPage extends StatelessWidget {
39+
const ChooseAccountPage({super.key});
40+
41+
Widget _buildAccountItem(
42+
BuildContext context, {
43+
required int accountId,
44+
required Widget title,
45+
Widget? subtitle,
46+
}) {
47+
return Card(
48+
clipBehavior: Clip.hardEdge,
49+
child: InkWell(
50+
onTap: () => Navigator.push(context,
51+
HomePage.buildRoute(accountId: accountId)),
52+
child: ListTile(title: title, subtitle: subtitle)));
53+
}
54+
55+
@override
56+
Widget build(BuildContext context) {
57+
assert(!PerAccountStoreWidget.debugExistsOf(context));
58+
final globalStore = GlobalStoreWidget.of(context);
59+
return Scaffold(
60+
appBar: AppBar(title: const Text('Choose account')),
61+
body: Center(
62+
child: ConstrainedBox(
63+
constraints: const BoxConstraints(maxWidth: 400),
64+
child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
65+
for (final (:accountId, :account) in globalStore.accountEntries)
66+
_buildAccountItem(context,
67+
accountId: accountId,
68+
title: Text(account.realmUrl),
69+
subtitle: Text(account.email)),
70+
const SizedBox(height: 12),
71+
ElevatedButton(
72+
onPressed: () => Navigator.push(context,
73+
AddAccountPage.buildRoute()),
74+
child: const Text('Add an account')),
75+
]))));
76+
}
77+
}
78+
4179
class HomePage extends StatelessWidget {
4280
const HomePage({super.key});
4381

82+
static Route<void> buildRoute({required int accountId}) {
83+
return MaterialPageRoute(builder: (context) =>
84+
PerAccountStoreWidget(accountId: accountId,
85+
child: const HomePage()));
86+
}
87+
4488
@override
4589
Widget build(BuildContext context) {
4690
final store = PerAccountStoreWidget.of(context);

lib/widgets/login.dart

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
import 'package:flutter/material.dart';
2+
3+
import '../api/route/account.dart';
4+
import '../model/store.dart';
5+
import 'app.dart';
6+
import 'store.dart';
7+
8+
class _LoginSequenceRoute extends MaterialPageRoute<void> {
9+
_LoginSequenceRoute({
10+
required super.builder,
11+
});
12+
}
13+
14+
class AddAccountPage extends StatefulWidget {
15+
const AddAccountPage({super.key});
16+
17+
static Route<void> buildRoute() {
18+
return _LoginSequenceRoute(builder: (context) =>
19+
const AddAccountPage());
20+
}
21+
22+
@override
23+
State<AddAccountPage> createState() => _AddAccountPageState();
24+
}
25+
26+
class _AddAccountPageState extends State<AddAccountPage> {
27+
final TextEditingController _controller = TextEditingController();
28+
29+
@override
30+
void dispose() {
31+
_controller.dispose();
32+
super.dispose();
33+
}
34+
35+
void _onSubmitted(BuildContext context, String value) {
36+
final Uri? url = Uri.tryParse(value);
37+
switch (url) {
38+
case Uri(scheme: 'https' || 'http'):
39+
// TODO(#35): validate realm URL further?
40+
// TODO(#36): support login methods beyond email/password
41+
Navigator.push(context,
42+
EmailPasswordLoginPage.buildRoute(realmUrl: url));
43+
default:
44+
// TODO(#35): give feedback to user on bad realm URL
45+
}
46+
}
47+
48+
@override
49+
Widget build(BuildContext context) {
50+
assert(!PerAccountStoreWidget.debugExistsOf(context));
51+
// TODO(#35): more help to user on entering realm URL
52+
return Scaffold(
53+
appBar: AppBar(title: const Text('Add an account')),
54+
body: Center(
55+
child: ConstrainedBox(
56+
constraints: const BoxConstraints(maxWidth: 400),
57+
child: TextField(
58+
controller: _controller,
59+
onSubmitted: (value) => _onSubmitted(context, value),
60+
keyboardType: TextInputType.url,
61+
decoration: InputDecoration(
62+
labelText: 'Your Zulip server URL',
63+
suffixIcon: InkWell(
64+
onTap: () => _onSubmitted(context, _controller.text),
65+
child: const Icon(Icons.arrow_forward)))))));
66+
}
67+
}
68+
69+
class EmailPasswordLoginPage extends StatefulWidget {
70+
const EmailPasswordLoginPage({super.key, required this.realmUrl});
71+
72+
final Uri realmUrl;
73+
74+
static Route<void> buildRoute({required Uri realmUrl}) {
75+
return _LoginSequenceRoute(builder: (context) =>
76+
EmailPasswordLoginPage(realmUrl: realmUrl));
77+
}
78+
79+
@override
80+
State<EmailPasswordLoginPage> createState() => _EmailPasswordLoginPageState();
81+
}
82+
83+
class _EmailPasswordLoginPageState extends State<EmailPasswordLoginPage> {
84+
final GlobalKey<FormFieldState<String>> _emailKey = GlobalKey();
85+
final GlobalKey<FormFieldState<String>> _passwordKey = GlobalKey();
86+
87+
void _submit() async {
88+
final context = _emailKey.currentContext!;
89+
final realmUrl = widget.realmUrl;
90+
final String? email = _emailKey.currentState!.value;
91+
final String? password = _passwordKey.currentState!.value;
92+
if (email == null || password == null) {
93+
// TODO can these FormField values actually be null? when?
94+
return;
95+
}
96+
// TODO(#35): validate email is in the shape of an email
97+
98+
final FetchApiKeyResult result;
99+
try {
100+
result = await fetchApiKey(
101+
realmUrl: realmUrl.toString(), username: email, password: password);
102+
} on Exception catch (e) { // TODO(#37): distinguish API exceptions
103+
// TODO(#35): give feedback to user on failed login
104+
debugPrint(e.toString());
105+
return;
106+
}
107+
if (context.mounted) {} // https://github.com/dart-lang/linter/issues/4007
108+
else {
109+
return;
110+
}
111+
112+
final account = Account(
113+
realmUrl: realmUrl.toString(), email: result.email, apiKey: result.api_key);
114+
final globalStore = GlobalStoreWidget.of(context);
115+
final accountId = await globalStore.insertAccount(account);
116+
if (context.mounted) {} // https://github.com/dart-lang/linter/issues/4007
117+
else {
118+
return;
119+
}
120+
121+
Navigator.of(context).pushAndRemoveUntil(
122+
HomePage.buildRoute(accountId: accountId),
123+
(route) => (route is! _LoginSequenceRoute),
124+
);
125+
}
126+
127+
@override
128+
Widget build(BuildContext context) {
129+
assert(!PerAccountStoreWidget.debugExistsOf(context));
130+
return Scaffold(
131+
appBar: AppBar(title: const Text('Log in')),
132+
body: Center(
133+
child: ConstrainedBox(
134+
constraints: const BoxConstraints(maxWidth: 400),
135+
child: Form(
136+
child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
137+
TextFormField(
138+
key: _emailKey,
139+
keyboardType: TextInputType.emailAddress,
140+
decoration: const InputDecoration(
141+
labelText: 'Email address')),
142+
const SizedBox(height: 8),
143+
TextFormField(
144+
key: _passwordKey,
145+
obscureText: true,
146+
keyboardType: TextInputType.visiblePassword,
147+
decoration: const InputDecoration(
148+
labelText: 'Password')),
149+
const SizedBox(height: 8),
150+
ElevatedButton(
151+
onPressed: _submit,
152+
child: const Text('Log in')),
153+
])))));
154+
}
155+
}

lib/widgets/store.dart

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,11 @@ class PerAccountStoreWidget extends StatefulWidget {
164164
return widget!.accountId;
165165
}
166166

167+
/// Whether there is a relevant account specified for this widget.
168+
static bool debugExistsOf(BuildContext context) {
169+
return context.getElementForInheritedWidgetOfExactType<_PerAccountStoreInheritedWidget>() != null;
170+
}
171+
167172
@override
168173
State<PerAccountStoreWidget> createState() => _PerAccountStoreWidgetState();
169174
}

0 commit comments

Comments
 (0)