Skip to content

Reorganize data-managing widgets to accommodate global data #20

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 3 commits into from
Mar 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
52 changes: 49 additions & 3 deletions lib/model/store.dart
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,50 @@ import '../api/route/messages.dart';
import '../credential_fixture.dart' as credentials;
import 'message_list.dart';

/// Store for the user's cross-account data.
///
/// This includes data that is independent of the account, like some settings.
/// It also includes a small amount of data for each account: enough to
/// authenticate as the active account, if there is one.
class GlobalStore extends ChangeNotifier {
GlobalStore._({required Map<int, Account> accounts})
: _accounts = accounts;

// For convenience, a number we won't use as an ID in the database table.
static const fixtureAccountId = -1;

// 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};
return GlobalStore._(accounts: accounts);
}

final Map<int, Account> _accounts;

// TODO settings (those that are per-device rather than per-account)
// TODO push token, and other data corresponding to GlobalSessionState

// Just an Iterable, not the actual Map, to avoid clients mutating the map.
// Mutations should go through the setters/mutators below.
Iterable<Account> get accounts => _accounts.values;

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

// TODO add setters/mutators; will want to write to database
// Future<void> insertAccount...
// Future<void> updateAccount...

// TODO add a registry of [PerAccountStore]s, like the latter's of [MessageListView]
// That will allow us to have many [PerAccountRoot] widgets for a given
// account, e.g. at the top of each page; and to access server data from
// outside any [PerAccountRoot], e.g. for handling a notification.
Comment on lines +50 to +53
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

many [PerAccountRoot] widgets for a given account, e.g. at the top of each page

This seems like a case where PerAccountRoot's instruction to build a "loading page" could be problematic. Until the store is available, each page will have a loading page at the top?

I don't yet understand why we'll want to have multiple PerAccountRoots per account. In what sense is each one a "root", then? 🤔

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Until the store is available, each page will have a loading page at the top?

Hmm, I suppose there's more for me to think through on that front. (But this is a TODO describing #21, not really part of what this PR itself does — so I think it needn't block merging this one.)

In what sense is each one a "root", then? 🤔

I think as part of this the name will change to not say "root".

I don't yet understand why we'll want to have multiple PerAccountRoots per account.

Does #21 explain it? If not, let's discuss there.

}

/// Store for the user's data for a given Zulip account.
///
/// This should always have a consistent snapshot of the state on the server,
/// as maintained by the Zulip event system.
class PerAccountStore extends ChangeNotifier {
PerAccountStore._({
required this.account,
Expand All @@ -23,9 +67,10 @@ class PerAccountStore extends ChangeNotifier {
required this.subscriptions,
});

// Load the user's data from storage. (Once we have such a thing.)
static Future<PerAccountStore> load() async {
const account = _fixtureAccount;
/// Load the user's data from the server, and start an event queue going.
///
/// In the future this might load an old snapshot from local storage first.
static Future<PerAccountStore> load(Account account) async {
final connection = ApiConnection(auth: account);

final stopwatch = Stopwatch()..start();
Expand Down Expand Up @@ -124,6 +169,7 @@ const Account _fixtureAccount = Account(
apiKey: credentials.api_key,
);

@immutable
class Account implements Auth {
const Account(
{required this.realmUrl, required this.email, required this.apiKey});
Expand Down
100 changes: 18 additions & 82 deletions lib/widgets/app.dart
Original file line number Diff line number Diff line change
@@ -1,69 +1,33 @@
import 'package:flutter/material.dart';

import '../model/store.dart';
import 'compose_box.dart';
import 'message_list.dart';
import '../model/store.dart';
import 'store.dart';

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

@override
Widget build(BuildContext context) {
// Just one account for now.
return const PerAccountRoot();
}
}

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

@override
State<PerAccountRoot> createState() => _PerAccountRootState();
}

class _PerAccountRootState extends State<PerAccountRoot> {
PerAccountStore? store;

@override
void initState() {
super.initState();
(() async {
final store = await PerAccountStore.load();
setState(() {
this.store = store;
});
})();
}

@override
void reassemble() {
// The [reassemble] method runs upon hot reload, in development.
// Here, we rerun parsing the messages. This gives us the same
// highly productive workflow of Flutter hot reload when developing
// changes there as we have on changes to widgets.
store?.reassemble();
super.reassemble();
}

@override
Widget build(BuildContext context) {
if (store == null) return const LoadingPage();
return PerAccountStoreWidget(
store: store!,
final theme = ThemeData(
// This applies Material 3's color system to produce a palette of
// appropriately matching and contrasting colors for use in a UI.
// The Zulip brand color is a starting point, but doesn't end up as
// one that's directly used. (After all, we didn't design it for that
// purpose; we designed a logo.) See docs:
// https://api.flutter.dev/flutter/material/ColorScheme/ColorScheme.fromSeed.html
// Or try this tool to see the whole palette:
// https://m3.material.io/theme-builder#/custom
colorScheme: ColorScheme.fromSeed(seedColor: kZulipBrandColor));
return DataRoot(
child: PerAccountRoot(
// Just one account for now.
accountId: GlobalStore.fixtureAccountId,
child: MaterialApp(
title: 'Zulip',
theme: ThemeData(
// This applies Material 3's color system to produce a palette of
// appropriately matching and contrasting colors for use in a UI.
// The Zulip brand color is a starting point, but doesn't end up as
// one that's directly used. (After all, we didn't design it for that
// purpose; we designed a logo.) See docs:
// https://api.flutter.dev/flutter/material/ColorScheme/ColorScheme.fromSeed.html
// Or try this tool to see the whole palette:
// https://m3.material.io/theme-builder#/custom
colorScheme: ColorScheme.fromSeed(seedColor: kZulipBrandColor)),
home: const HomePage(),
));
theme: theme,
home: const HomePage())));
}
}

Expand All @@ -73,34 +37,6 @@ class _PerAccountRootState extends State<PerAccountRoot> {
// As computed by Anders: https://github.com/zulip/zulip-mobile/pull/4467
const kZulipBrandColor = Color.fromRGBO(0x64, 0x92, 0xfe, 1);

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

@override
Widget build(BuildContext context) {
return const Center(child: CircularProgressIndicator());
}
}

class PerAccountStoreWidget extends InheritedNotifier<PerAccountStore> {
const PerAccountStoreWidget(
{super.key, required PerAccountStore store, required super.child})
: super(notifier: store);

PerAccountStore get store => notifier!;

static PerAccountStore of(BuildContext context) {
final widget =
context.dependOnInheritedWidgetOfExactType<PerAccountStoreWidget>();
assert(widget != null, 'No PerAccountStoreWidget ancestor');
return widget!.store;
}

@override
bool updateShouldNotify(covariant PerAccountStoreWidget oldWidget) =>
store != oldWidget.store;
}

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

Expand Down
2 changes: 1 addition & 1 deletion lib/widgets/compose_box.dart
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import 'package:flutter/material.dart';
import 'dialog.dart';

import 'app.dart';
import '../api/route/messages.dart';
import 'store.dart';

const double _inputVerticalPadding = 8;
const double _sendButtonSize = 36;
Expand Down
2 changes: 1 addition & 1 deletion lib/widgets/content.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import 'package:html/dom.dart' as dom;
import '../api/model/model.dart';
import '../model/content.dart';
import '../model/store.dart';
import 'app.dart';
import 'store.dart';

/// The font size for message content in a plain unstyled paragraph.
const double kBaseFontSize = 14;
Expand Down
2 changes: 1 addition & 1 deletion lib/widgets/message_list.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@ import '../model/content.dart';
import '../model/message_list.dart';
import '../model/narrow.dart';
import '../model/store.dart';
import 'app.dart';
import 'content.dart';
import 'sticky_header.dart';
import 'store.dart';

class MessageList extends StatefulWidget {
const MessageList({Key? key}) : super(key: key);
Expand Down
137 changes: 137 additions & 0 deletions lib/widgets/store.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import 'package:flutter/material.dart';

import '../model/store.dart';

class DataRoot extends StatefulWidget {
const DataRoot({super.key, required this.child});

final Widget child;

@override
State<DataRoot> createState() => _DataRootState();
}

class _DataRootState extends State<DataRoot> {
GlobalStore? store;

@override
void initState() {
super.initState();
(() async {
final store = await GlobalStore.load();
setState(() {
this.store = store;
});
})();
}

@override
Widget build(BuildContext context) {
final store = this.store;
// TODO: factor out the use of LoadingPage to be configured by the widget, like [widget.child] is
if (store == null) return const LoadingPage();
return GlobalStoreWidget(store: store, child: widget.child);
}
}

class GlobalStoreWidget extends InheritedNotifier<GlobalStore> {
const GlobalStoreWidget(
{super.key, required GlobalStore store, required super.child})
: super(notifier: store);

GlobalStore get store => notifier!;

static GlobalStore of(BuildContext context) {
final widget =
context.dependOnInheritedWidgetOfExactType<GlobalStoreWidget>();
assert(widget != null, 'No GlobalStoreWidget ancestor');
return widget!.store;
}

@override
bool updateShouldNotify(covariant GlobalStoreWidget oldWidget) =>
store != oldWidget.store;
}

class PerAccountRoot extends StatefulWidget {
const PerAccountRoot(
{super.key, required this.accountId, required this.child});

final int accountId;
final Widget child;

@override
State<PerAccountRoot> createState() => _PerAccountRootState();
}

class _PerAccountRootState extends State<PerAccountRoot> {
PerAccountStore? store;

@override
void didChangeDependencies() {
super.didChangeDependencies();
final globalStore = GlobalStoreWidget.of(context);
final account = globalStore.getAccount(widget.accountId);
assert(account != null, 'Account not found on global store');
if (store != null) {
// The data we use to auth to the server should be unchanged;
// changing those should mean a new account ID in our database.
assert(account!.realmUrl == store!.account.realmUrl);
assert(account!.email == store!.account.email);
assert(account!.apiKey == store!.account.apiKey);
// TODO if Account has anything else change, update the PerAccountStore for that
return;
}
(() async {
final store = await PerAccountStore.load(account!);
setState(() {
this.store = store;
});
})();
}

@override
void reassemble() {
// The [reassemble] method runs upon hot reload, in development.
// Here, we rerun parsing the messages. This gives us the same
// highly productive workflow of Flutter hot reload when developing
// changes there as we have on changes to widgets.
store?.reassemble();
super.reassemble();
}

@override
Widget build(BuildContext context) {
// TODO: factor out the use of LoadingPage to be configured by the widget, like [widget.child] is
if (store == null) return const LoadingPage();
return PerAccountStoreWidget(store: store!, child: widget.child);
}
}

class PerAccountStoreWidget extends InheritedNotifier<PerAccountStore> {
const PerAccountStoreWidget(
{super.key, required PerAccountStore store, required super.child})
: super(notifier: store);

PerAccountStore get store => notifier!;

static PerAccountStore of(BuildContext context) {
final widget =
context.dependOnInheritedWidgetOfExactType<PerAccountStoreWidget>();
assert(widget != null, 'No PerAccountStoreWidget ancestor');
return widget!.store;
}

@override
bool updateShouldNotify(covariant PerAccountStoreWidget oldWidget) =>
store != oldWidget.store;
}

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

@override
Widget build(BuildContext context) {
return const Center(child: CircularProgressIndicator());
}
}