Skip to content

login: Add a basic login flow #39

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Apr 4, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions lib/api/route/account.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// ignore_for_file: non_constant_identifier_names

import 'dart:convert';

import 'package:json_annotation/json_annotation.dart';

import '../core.dart';
import 'package:http/http.dart' as http;

part 'account.g.dart';

/// https://zulip.com/api/fetch-api-key
Future<FetchApiKeyResult> fetchApiKey({
required String realmUrl,
required String username,
required String password,
}) async {
// TODO dedupe this part with LiveApiConnection; make this function testable
final response = await http.post(
Uri.parse("$realmUrl/api/v1/fetch_api_key"),
body: encodeParameters({
'username': RawParameter(username),
'password': RawParameter(password),
}));
if (response.statusCode != 200) {
throw Exception('error on POST fetch_api_key: status ${response.statusCode}');
}
final data = utf8.decode(response.bodyBytes);

final json = jsonDecode(data);
return FetchApiKeyResult.fromJson(json);
}

@JsonSerializable()
class FetchApiKeyResult {
final String api_key;
final String email;

FetchApiKeyResult({required this.api_key, required this.email});

factory FetchApiKeyResult.fromJson(Map<String, dynamic> json) =>
_$FetchApiKeyResultFromJson(json);

Map<String, dynamic> toJson() => _$FetchApiKeyResultToJson(this);
}
19 changes: 19 additions & 0 deletions lib/api/route/account.g.dart

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

27 changes: 23 additions & 4 deletions lib/model/store.dart
Original file line number Diff line number Diff line change
Expand Up @@ -95,14 +95,33 @@ abstract class GlobalStore extends ChangeNotifier {
/// and/or [perAccountSync].
Future<PerAccountStore> loadPerAccount(Account account);

// Just an Iterable, not the actual Map, to avoid clients mutating the map.
// Just the Iterables, not the actual Map, to avoid clients mutating the map.
// Mutations should go through the setters/mutators below.
Iterable<Account> get accounts => _accounts.values;
Iterable<int> get accountIds => _accounts.keys;
Iterable<({ int accountId, Account account })> get accountEntries {
return _accounts.entries.map((entry) {
return (accountId: entry.key, account: entry.value);
});
}

Account? getAccount(int id) => _accounts[id];

// TODO add setters/mutators; will want to write to database
// Future<void> insertAccount...
// TODO(#13): rewrite these setters/mutators with a database

int _nextAccountId = 1;

/// Add an account to the store, returning its assigned account ID.
Future<int> insertAccount(Account account) async {
final accountId = _nextAccountId;
_nextAccountId++;
assert(!_accounts.containsKey(accountId));
_accounts[accountId] = account;
notifyListeners();
return accountId;
}

// More mutators as needed:
// Future<void> updateAccount...
}

Expand Down Expand Up @@ -205,7 +224,7 @@ class LiveGlobalStore extends GlobalStore {
// We keep the API simple and synchronous for the bulk of the app's code
// by doing this loading up front before constructing a [GlobalStore].
static Future<GlobalStore> load() async {
const accounts = {fixtureAccountId: _fixtureAccount};
final accounts = {fixtureAccountId: _fixtureAccount};
return LiveGlobalStore._(accounts: accounts);
}

Expand Down
62 changes: 55 additions & 7 deletions lib/widgets/app.dart
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import 'package:flutter/material.dart';

import '../model/store.dart';
import 'compose_box.dart';
import 'login.dart';
import 'message_list.dart';
import 'page.dart';
import 'store.dart';
Expand All @@ -25,10 +25,7 @@ class ZulipApp extends StatelessWidget {
child: MaterialApp(
title: 'Zulip',
theme: theme,
home: const PerAccountStoreWidget(
// Just one account for now.
accountId: LiveGlobalStore.fixtureAccountId,
child: HomePage())));
home: const ChooseAccountPage()));
}
}

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

class ChooseAccountPage extends StatelessWidget {
const ChooseAccountPage({super.key});

Widget _buildAccountItem(
BuildContext context, {
required int accountId,
required Widget title,
Widget? subtitle,
}) {
return Card(
clipBehavior: Clip.hardEdge,
child: InkWell(
onTap: () => Navigator.push(context,
HomePage.buildRoute(accountId: accountId)),
child: ListTile(title: title, subtitle: subtitle)));
}

@override
Widget build(BuildContext context) {
assert(!PerAccountStoreWidget.debugExistsOf(context));
final globalStore = GlobalStoreWidget.of(context);
return Scaffold(
appBar: AppBar(title: const Text('Choose account')),
body: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 400),
child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
for (final (:accountId, :account) in globalStore.accountEntries)
_buildAccountItem(context,
accountId: accountId,
title: Text(account.realmUrl),
subtitle: Text(account.email)),
const SizedBox(height: 12),
ElevatedButton(
onPressed: () => Navigator.push(context,
AddAccountPage.buildRoute()),
child: const Text('Add an account')),
]))));
}
}

class HomePage extends StatelessWidget {
const HomePage({super.key});

static Route<void> buildRoute({required int accountId}) {
return MaterialPageRoute(builder: (context) =>
PerAccountStoreWidget(accountId: accountId,
child: const HomePage()));
}

@override
Widget build(BuildContext context) {
final store = PerAccountStoreWidget.of(context);
Expand Down Expand Up @@ -71,8 +115,7 @@ class HomePage extends StatelessWidget {
const SizedBox(height: 16),
ElevatedButton(
onPressed: () => Navigator.push(context,
MaterialAccountPageRoute(context: context, builder: (context) =>
const MessageListPage())),
MessageListPage.buildRoute(context)),
child: const Text("All messages")),
])));
}
Expand All @@ -81,6 +124,11 @@ class HomePage extends StatelessWidget {
class MessageListPage extends StatelessWidget {
const MessageListPage({super.key});

static Route<void> buildRoute(BuildContext context) {
return MaterialAccountPageRoute(context: context,
builder: (context) => const MessageListPage());
}

@override
Widget build(BuildContext context) {
return Scaffold(
Expand Down
159 changes: 159 additions & 0 deletions lib/widgets/login.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import 'package:flutter/material.dart';

import '../api/route/account.dart';
import '../model/store.dart';
import 'app.dart';
import 'store.dart';

class _LoginSequenceRoute extends MaterialPageRoute<void> {
_LoginSequenceRoute({
required super.builder,
});
}

class AddAccountPage extends StatefulWidget {
const AddAccountPage({super.key});

static Route<void> buildRoute() {
return _LoginSequenceRoute(builder: (context) =>
const AddAccountPage());
}

@override
State<AddAccountPage> createState() => _AddAccountPageState();
}

class _AddAccountPageState extends State<AddAccountPage> {
final TextEditingController _controller = TextEditingController();

@override
void dispose() {
_controller.dispose();
super.dispose();
}

void _onSubmitted(BuildContext context, String value) {
final Uri? url = Uri.tryParse(value);
switch (url) {
case Uri(scheme: 'https' || 'http'):
// TODO(#35): validate realm URL further?
// TODO(#36): support login methods beyond email/password
Navigator.push(context,
EmailPasswordLoginPage.buildRoute(realmUrl: url));
default:
// TODO(#35): give feedback to user on bad realm URL
}
}

@override
Widget build(BuildContext context) {
assert(!PerAccountStoreWidget.debugExistsOf(context));
// TODO(#35): more help to user on entering realm URL
return Scaffold(
appBar: AppBar(title: const Text('Add an account')),
body: SafeArea(
minimum: const EdgeInsets.all(8),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 400),
child: TextField(
controller: _controller,
onSubmitted: (value) => _onSubmitted(context, value),
keyboardType: TextInputType.url,
decoration: InputDecoration(
labelText: 'Your Zulip server URL',
suffixIcon: InkWell(
onTap: () => _onSubmitted(context, _controller.text),
child: const Icon(Icons.arrow_forward))))))));
}
}

class EmailPasswordLoginPage extends StatefulWidget {
const EmailPasswordLoginPage({super.key, required this.realmUrl});

final Uri realmUrl;

static Route<void> buildRoute({required Uri realmUrl}) {
return _LoginSequenceRoute(builder: (context) =>
EmailPasswordLoginPage(realmUrl: realmUrl));
}

@override
State<EmailPasswordLoginPage> createState() => _EmailPasswordLoginPageState();
}

class _EmailPasswordLoginPageState extends State<EmailPasswordLoginPage> {
final GlobalKey<FormFieldState<String>> _emailKey = GlobalKey();
final GlobalKey<FormFieldState<String>> _passwordKey = GlobalKey();

void _submit() async {
final context = _emailKey.currentContext!;
final realmUrl = widget.realmUrl;
final String? email = _emailKey.currentState!.value;
final String? password = _passwordKey.currentState!.value;
if (email == null || password == null) {
// TODO can these FormField values actually be null? when?
return;
}
// TODO(#35): validate email is in the shape of an email

final FetchApiKeyResult result;
try {
result = await fetchApiKey(
realmUrl: realmUrl.toString(), username: email, password: password);
} on Exception catch (e) { // TODO(#37): distinguish API exceptions
// TODO(#35): give feedback to user on failed login
debugPrint(e.toString());
return;
}
if (context.mounted) {} // https://github.com/dart-lang/linter/issues/4007
else {
return;
}

final account = Account(
realmUrl: realmUrl.toString(), email: result.email, apiKey: result.api_key);
final globalStore = GlobalStoreWidget.of(context);
final accountId = await globalStore.insertAccount(account);
if (context.mounted) {} // https://github.com/dart-lang/linter/issues/4007
else {
return;
}

Navigator.of(context).pushAndRemoveUntil(
HomePage.buildRoute(accountId: accountId),
(route) => (route is! _LoginSequenceRoute),
);
}

@override
Widget build(BuildContext context) {
assert(!PerAccountStoreWidget.debugExistsOf(context));
return Scaffold(
appBar: AppBar(title: const Text('Log in')),
body: SafeArea(
minimum: const EdgeInsets.all(8),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 400),
child: Form(
child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
TextFormField(
key: _emailKey,
keyboardType: TextInputType.emailAddress,
decoration: const InputDecoration(
labelText: 'Email address')),
const SizedBox(height: 8),
TextFormField(
key: _passwordKey,
obscureText: true,
keyboardType: TextInputType.visiblePassword,
decoration: const InputDecoration(
labelText: 'Password')),
const SizedBox(height: 8),
ElevatedButton(
onPressed: _submit,
child: const Text('Log in')),
]))))));
}
}
5 changes: 5 additions & 0 deletions lib/widgets/store.dart
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,11 @@ class PerAccountStoreWidget extends StatefulWidget {
return widget!.accountId;
}

/// Whether there is a relevant account specified for this widget.
static bool debugExistsOf(BuildContext context) {
return context.getElementForInheritedWidgetOfExactType<_PerAccountStoreInheritedWidget>() != null;
}

@override
State<PerAccountStoreWidget> createState() => _PerAccountStoreWidgetState();
}
Expand Down